Retrait des compétences ; disponibilités en fenêtres horaires libres
Compétences supprimées de bout en bout :
- tables `skill`, `volunteer_skill`, `shift_required_skill` ;
- `Volunteer.skill_codes`, `Shift.required_skill_codes`, colonne `skills`
de l'import, règle de validation `MISSING_REQUIRED_SKILL`, jeu de démo.
Disponibilités repensées : `Availability` passe de (`day_part_id`,
`status` tri-état) à une **fenêtre horaire libre** (`date`, `start_time`,
`end_time` ; `end <= start` ⇒ passe minuit), plusieurs par jour.
- Nouveaux endpoints `GET`/`PUT /editions/{id}/volunteers/{vid}/availability`
(PUT = remplacement complet, COORDINATION) et
`GET /editions/{id}/availability` (toute l'édition, pour la grille).
- `OUTSIDE_AVAILABILITY` (toujours WARNING) : compare le créneau à l'union
des fenêtres du bénévole ce jour-là ; renvoie `date` + `uncovered_minutes`.
`DayPart` ne sert plus qu'à la borne de « journée » du cumul 8 h.
- L'import ne gère plus les disponibilités (fenêtres saisies dans l'app) :
`values.py` supprimé, mapping réduit à nom/email/téléphone/commentaire.
Migration 0007 (recrée `availability`). `AvailabilityStatus` supprimé.
158 tests, `alembic check` et `ruff` au vert. Front à suivre.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
81609eecc5
commit
be51770b51
@@ -1,13 +1,10 @@
|
||||
"""Helpers partagés par les routers CRUD."""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Iterable
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import delete
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.models import Skill
|
||||
from sqlmodel import Session
|
||||
|
||||
|
||||
def owned_or_404(instance, edition_id: uuid.UUID):
|
||||
@@ -17,33 +14,6 @@ def owned_or_404(instance, edition_id: uuid.UUID):
|
||||
return instance
|
||||
|
||||
|
||||
def resolve_skills(
|
||||
session: Session, edition_id: uuid.UUID, codes: Iterable[str]
|
||||
) -> list[Skill]:
|
||||
"""Récupère ou crée les compétences de l'édition pour les codes donnés."""
|
||||
existing = {
|
||||
s.code: s
|
||||
for s in session.exec(
|
||||
select(Skill).where(Skill.edition_id == edition_id)
|
||||
)
|
||||
}
|
||||
resolved: list[Skill] = []
|
||||
seen: set[str] = set()
|
||||
for raw in codes:
|
||||
code = raw.strip()
|
||||
if not code or code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
skill = existing.get(code)
|
||||
if skill is None:
|
||||
skill = Skill(edition_id=edition_id, code=code, label=code)
|
||||
session.add(skill)
|
||||
session.flush()
|
||||
existing[code] = skill
|
||||
resolved.append(skill)
|
||||
return resolved
|
||||
|
||||
|
||||
def replace_links(
|
||||
session: Session, link_model, *, filter_column, filter_value, pairs
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Disponibilités déclarées par les bénévoles.
|
||||
|
||||
Une disponibilité = « le <date>, je peux de <start_time> à <end_time> ».
|
||||
Fenêtre horaire libre (pas liée aux créneaux types) ; plusieurs par jour
|
||||
possibles. `end_time <= start_time` ⇒ la fenêtre se termine le lendemain.
|
||||
|
||||
Ces fenêtres sont prises en compte par le moteur de validation (règle
|
||||
`OUTSIDE_AVAILABILITY`) au moment d'affecter un bénévole à un créneau.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import delete
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api._common import owned_or_404
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import AvailabilityRead, AvailabilitySet
|
||||
from app.auth.deps import (
|
||||
current_account_in_edition,
|
||||
require_coordinator,
|
||||
require_staff,
|
||||
)
|
||||
from app.db import get_session
|
||||
from app.models import Account, Availability, Edition, Role, Volunteer
|
||||
|
||||
router = APIRouter(prefix="/editions/{edition_id}", tags=["availability"])
|
||||
|
||||
|
||||
def _ensure_can_read(account: Account, volunteer_id: uuid.UUID) -> None:
|
||||
if account.role in (Role.COORDINATOR, Role.LEAD):
|
||||
return
|
||||
if account.volunteer_id == volunteer_id:
|
||||
return
|
||||
raise HTTPException(status_code=403, detail={"code": "error.forbidden"})
|
||||
|
||||
|
||||
def _windows(session: Session, volunteer_id: uuid.UUID) -> list[Availability]:
|
||||
return list(
|
||||
session.exec(
|
||||
select(Availability)
|
||||
.where(Availability.volunteer_id == volunteer_id)
|
||||
.order_by(Availability.date, Availability.start_time)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/volunteers/{volunteer_id}/availability",
|
||||
response_model=list[AvailabilityRead],
|
||||
)
|
||||
def list_availability(
|
||||
volunteer_id: uuid.UUID,
|
||||
edition: Edition = Depends(get_edition),
|
||||
account: Account = Depends(current_account_in_edition),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[Availability]:
|
||||
_ensure_can_read(account, volunteer_id)
|
||||
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||
return _windows(session, volunteer_id)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/volunteers/{volunteer_id}/availability",
|
||||
response_model=list[AvailabilityRead],
|
||||
)
|
||||
def replace_availability(
|
||||
volunteer_id: uuid.UUID,
|
||||
payload: AvailabilitySet,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[Availability]:
|
||||
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||
|
||||
for w in payload.windows:
|
||||
if not edition.start_date <= w.date <= edition.end_date:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"code": "availability.date_outside_edition",
|
||||
"params": {"date": w.date.isoformat()},
|
||||
},
|
||||
)
|
||||
if w.start_time == w.end_time:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"code": "availability.empty_window"},
|
||||
)
|
||||
|
||||
session.execute(
|
||||
delete(Availability).where(Availability.volunteer_id == volunteer_id)
|
||||
)
|
||||
# Dé-doublonne (date, start, end) : la contrainte d'unicité l'imposerait.
|
||||
seen: set[tuple] = set()
|
||||
for w in payload.windows:
|
||||
key = (w.date, w.start_time, w.end_time)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
session.add(
|
||||
Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer_id,
|
||||
date=w.date,
|
||||
start_time=w.start_time,
|
||||
end_time=w.end_time,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return _windows(session, volunteer_id)
|
||||
|
||||
|
||||
@router.get("/availability", response_model=list[AvailabilityRead])
|
||||
def list_edition_availability(
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_staff),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[Availability]:
|
||||
"""Toutes les disponibilités de l'édition (pour la grille des besoins)."""
|
||||
return list(
|
||||
session.exec(
|
||||
select(Availability)
|
||||
.where(Availability.edition_id == edition.id)
|
||||
.order_by(
|
||||
Availability.volunteer_id,
|
||||
Availability.date,
|
||||
Availability.start_time,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -94,7 +94,7 @@ def delete_day_part(
|
||||
session: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
day_part = owned_or_404(session.get(DayPart, day_part_id), edition.id)
|
||||
# Cascade base : disponibilités supprimées, day_part_id des créneaux -> NULL.
|
||||
# Cascade base : day_part_id des créneaux issus de ce type repasse à NULL.
|
||||
session.delete(day_part)
|
||||
session.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""Endpoints d'import : analyse puis exécution."""
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from pydantic import ValidationError
|
||||
from sqlmodel import Session, select
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.api.deps import get_edition
|
||||
from app.auth.deps import require_coordinator
|
||||
@@ -12,13 +10,12 @@ from app.db import get_session
|
||||
from app.importing.parser import ImportParseError, parse_upload
|
||||
from app.importing.schemas import (
|
||||
AnalyzeResponse,
|
||||
DayPartInfo,
|
||||
ImportMapping,
|
||||
ImportReport,
|
||||
PreviewRow,
|
||||
)
|
||||
from app.importing.service import run_import, suggest_mapping, validate_mapping
|
||||
from app.models import Account, DayPart, Edition
|
||||
from app.models import Account, Edition
|
||||
|
||||
router = APIRouter(prefix="/editions/{edition_id}/import", tags=["import"])
|
||||
|
||||
@@ -45,27 +42,11 @@ def _is_xlsx(filename: str, content: bytes) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _edition_dates(edition: Edition) -> list[dt.date]:
|
||||
span = (edition.end_date - edition.start_date).days
|
||||
return [edition.start_date + dt.timedelta(days=i) for i in range(span + 1)]
|
||||
|
||||
|
||||
def _day_parts(session: Session, edition: Edition) -> list[DayPart]:
|
||||
return list(
|
||||
session.exec(
|
||||
select(DayPart)
|
||||
.where(DayPart.edition_id == edition.id)
|
||||
.order_by(DayPart.order)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/analyze", response_model=AnalyzeResponse)
|
||||
async def analyze(
|
||||
file: UploadFile = File(...),
|
||||
edition: Edition = Depends(get_edition),
|
||||
_edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> AnalyzeResponse:
|
||||
content = await _read_upload(file)
|
||||
try:
|
||||
@@ -87,17 +68,6 @@ async def analyze(
|
||||
row_count=len(table.rows),
|
||||
preview=preview,
|
||||
suggested_mapping=suggest_mapping(table.headers),
|
||||
edition_dates=_edition_dates(edition),
|
||||
day_parts=[
|
||||
DayPartInfo(
|
||||
code=dp.code,
|
||||
label=dp.label,
|
||||
start_time=dp.start_time,
|
||||
end_time=dp.end_time,
|
||||
order=dp.order,
|
||||
)
|
||||
for dp in _day_parts(session, edition)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -127,9 +97,7 @@ async def execute(
|
||||
detail={"code": exc.code, "params": exc.params},
|
||||
) from exc
|
||||
|
||||
mapping_errors = validate_mapping(
|
||||
table, parsed_mapping, edition, _day_parts(session, edition)
|
||||
)
|
||||
mapping_errors = validate_mapping(table, parsed_mapping)
|
||||
if mapping_errors:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
|
||||
@@ -49,7 +49,6 @@ class VolunteerCreate(ApiModel):
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
comment: str | None = None
|
||||
skill_codes: list[str] = Field(default_factory=list)
|
||||
# Vœux du bénévole : artistes à voir, postes souhaités.
|
||||
artist_ids: list[uuid.UUID] = Field(default_factory=list)
|
||||
wished_position_ids: list[uuid.UUID] = Field(default_factory=list)
|
||||
@@ -62,7 +61,6 @@ class VolunteerUpdate(ApiModel):
|
||||
phone: str | None = None
|
||||
comment: str | None = None
|
||||
# None = inchangé ; [] = vider. Idem pour artist_ids / wished_position_ids.
|
||||
skill_codes: list[str] | None = None
|
||||
artist_ids: list[uuid.UUID] | None = None
|
||||
wished_position_ids: list[uuid.UUID] | None = None
|
||||
|
||||
@@ -75,7 +73,6 @@ class VolunteerRead(ApiModel):
|
||||
email: str | None
|
||||
phone: str | None
|
||||
comment: str | None
|
||||
skill_codes: list[str]
|
||||
artist_ids: list[uuid.UUID]
|
||||
wished_position_ids: list[uuid.UUID]
|
||||
created_at: dt.datetime
|
||||
@@ -141,7 +138,6 @@ class ShiftCreate(ApiModel):
|
||||
ends_at: dt.datetime
|
||||
min_staff: int = Field(default=0, ge=0)
|
||||
ideal_staff: int = Field(default=0, ge=0)
|
||||
required_skill_codes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ShiftUpdate(ApiModel):
|
||||
@@ -150,7 +146,6 @@ class ShiftUpdate(ApiModel):
|
||||
ends_at: dt.datetime | None = None
|
||||
min_staff: int | None = Field(default=None, ge=0)
|
||||
ideal_staff: int | None = Field(default=None, ge=0)
|
||||
required_skill_codes: list[str] | None = None
|
||||
|
||||
|
||||
class ShiftRead(ApiModel):
|
||||
@@ -162,7 +157,6 @@ class ShiftRead(ApiModel):
|
||||
ends_at: dt.datetime
|
||||
min_staff: int
|
||||
ideal_staff: int
|
||||
required_skill_codes: list[str]
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
@@ -202,6 +196,33 @@ class DayPartUpdate(ApiModel):
|
||||
order: int | None = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Availability (disponibilités déclarées par le bénévole) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class AvailabilityWindowIn(ApiModel):
|
||||
date: dt.date
|
||||
start_time: dt.time
|
||||
# end_time <= start_time ⇒ la fenêtre se termine le lendemain.
|
||||
end_time: dt.time
|
||||
|
||||
|
||||
class AvailabilitySet(ApiModel):
|
||||
"""Remplace l'intégralité des disponibilités d'un bénévole."""
|
||||
|
||||
windows: list[AvailabilityWindowIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AvailabilityRead(ApiModel):
|
||||
id: uuid.UUID
|
||||
edition_id: uuid.UUID
|
||||
volunteer_id: uuid.UUID
|
||||
date: dt.date
|
||||
start_time: dt.time
|
||||
end_time: dt.time
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Assignment #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -5,7 +5,7 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api._common import owned_or_404, replace_links, resolve_skills
|
||||
from app.api._common import owned_or_404
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import ShiftCreate, ShiftRead, ShiftUpdate
|
||||
from app.auth.deps import (
|
||||
@@ -15,44 +15,13 @@ from app.auth.deps import (
|
||||
require_staff,
|
||||
)
|
||||
from app.db import get_session
|
||||
from app.models import (
|
||||
Account,
|
||||
Edition,
|
||||
Position,
|
||||
Shift,
|
||||
ShiftRequiredSkill,
|
||||
Skill,
|
||||
)
|
||||
from app.models import Account, Edition, Position, Shift
|
||||
|
||||
router = APIRouter(prefix="/editions/{edition_id}/shifts", tags=["shifts"])
|
||||
|
||||
|
||||
def _skill_codes(session: Session, shift_id: uuid.UUID) -> list[str]:
|
||||
return sorted(
|
||||
session.exec(
|
||||
select(Skill.code)
|
||||
.join(ShiftRequiredSkill, ShiftRequiredSkill.skill_id == Skill.id)
|
||||
.where(ShiftRequiredSkill.shift_id == shift_id)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _read(session: Session, shift: Shift) -> ShiftRead:
|
||||
return ShiftRead(
|
||||
**shift.model_dump(),
|
||||
required_skill_codes=_skill_codes(session, shift.id),
|
||||
)
|
||||
|
||||
|
||||
def _set_skills(session: Session, shift: Shift, codes: list[str]) -> None:
|
||||
skills = resolve_skills(session, shift.edition_id, codes)
|
||||
replace_links(
|
||||
session,
|
||||
ShiftRequiredSkill,
|
||||
filter_column=ShiftRequiredSkill.shift_id,
|
||||
filter_value=shift.id,
|
||||
pairs=[{"shift_id": shift.id, "skill_id": s.id} for s in skills],
|
||||
)
|
||||
def _read(shift: Shift) -> ShiftRead:
|
||||
return ShiftRead(**shift.model_dump())
|
||||
|
||||
|
||||
def _check_range(starts_at, ends_at) -> None:
|
||||
@@ -84,16 +53,11 @@ def create_shift(
|
||||
_check_range(payload.starts_at, payload.ends_at)
|
||||
position = _require_position(session, payload.position_id, edition.id)
|
||||
ensure_can_write_position(account, position)
|
||||
shift = Shift(
|
||||
edition_id=edition.id,
|
||||
**payload.model_dump(exclude={"required_skill_codes"}),
|
||||
)
|
||||
shift = Shift(edition_id=edition.id, **payload.model_dump())
|
||||
session.add(shift)
|
||||
session.flush()
|
||||
_set_skills(session, shift, payload.required_skill_codes)
|
||||
session.commit()
|
||||
session.refresh(shift)
|
||||
return _read(session, shift)
|
||||
return _read(shift)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ShiftRead])
|
||||
@@ -110,7 +74,7 @@ def list_shifts(
|
||||
)
|
||||
if position_id is not None:
|
||||
stmt = stmt.where(Shift.position_id == position_id)
|
||||
return [_read(session, s) for s in session.exec(stmt)]
|
||||
return [_read(s) for s in session.exec(stmt)]
|
||||
|
||||
|
||||
@router.get("/{shift_id}", response_model=ShiftRead)
|
||||
@@ -121,7 +85,7 @@ def get_shift(
|
||||
session: Session = Depends(get_session),
|
||||
) -> ShiftRead:
|
||||
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
|
||||
return _read(session, shift)
|
||||
return _read(shift)
|
||||
|
||||
|
||||
@router.patch("/{shift_id}", response_model=ShiftRead)
|
||||
@@ -135,18 +99,15 @@ def update_shift(
|
||||
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
|
||||
ensure_can_write_shift(account, session, shift)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
skill_codes = changes.pop("required_skill_codes", None)
|
||||
if "position_id" in changes:
|
||||
target = _require_position(session, changes["position_id"], edition.id)
|
||||
ensure_can_write_position(account, target)
|
||||
for field, value in changes.items():
|
||||
setattr(shift, field, value)
|
||||
_check_range(shift.starts_at, shift.ends_at)
|
||||
if skill_codes is not None:
|
||||
_set_skills(session, shift, skill_codes)
|
||||
session.commit()
|
||||
session.refresh(shift)
|
||||
return _read(session, shift)
|
||||
return _read(shift)
|
||||
|
||||
|
||||
@router.delete("/{shift_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api._common import owned_or_404, replace_links, resolve_skills
|
||||
from app.api._common import owned_or_404, replace_links
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import VolunteerCreate, VolunteerRead, VolunteerUpdate
|
||||
from app.auth.deps import (
|
||||
@@ -21,11 +21,9 @@ from app.models import (
|
||||
Edition,
|
||||
Position,
|
||||
Role,
|
||||
Skill,
|
||||
Volunteer,
|
||||
VolunteerArtist,
|
||||
VolunteerPositionWish,
|
||||
VolunteerSkill,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
@@ -41,16 +39,6 @@ def _ensure_can_read(account: Account, volunteer_id: uuid.UUID) -> None:
|
||||
raise HTTPException(status_code=403, detail={"code": "error.forbidden"})
|
||||
|
||||
|
||||
def _skill_codes(session: Session, volunteer_id: uuid.UUID) -> list[str]:
|
||||
return sorted(
|
||||
session.exec(
|
||||
select(Skill.code)
|
||||
.join(VolunteerSkill, VolunteerSkill.skill_id == Skill.id)
|
||||
.where(VolunteerSkill.volunteer_id == volunteer_id)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _artist_ids(session: Session, volunteer_id: uuid.UUID) -> list[uuid.UUID]:
|
||||
return list(
|
||||
session.exec(
|
||||
@@ -81,27 +69,11 @@ def _wished_position_ids(
|
||||
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
|
||||
return VolunteerRead(
|
||||
**volunteer.model_dump(),
|
||||
skill_codes=_skill_codes(session, volunteer.id),
|
||||
artist_ids=_artist_ids(session, volunteer.id),
|
||||
wished_position_ids=_wished_position_ids(session, volunteer.id),
|
||||
)
|
||||
|
||||
|
||||
def _set_skills(
|
||||
session: Session, volunteer: Volunteer, codes: list[str]
|
||||
) -> None:
|
||||
skills = resolve_skills(session, volunteer.edition_id, codes)
|
||||
replace_links(
|
||||
session,
|
||||
VolunteerSkill,
|
||||
filter_column=VolunteerSkill.volunteer_id,
|
||||
filter_value=volunteer.id,
|
||||
pairs=[
|
||||
{"volunteer_id": volunteer.id, "skill_id": s.id} for s in skills
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _resolve_owned_ids(
|
||||
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID], code: str
|
||||
) -> list[uuid.UUID]:
|
||||
@@ -168,9 +140,7 @@ def create_volunteer(
|
||||
) -> VolunteerRead:
|
||||
volunteer = Volunteer(
|
||||
edition_id=edition.id,
|
||||
**payload.model_dump(
|
||||
exclude={"skill_codes", "artist_ids", "wished_position_ids"}
|
||||
),
|
||||
**payload.model_dump(exclude={"artist_ids", "wished_position_ids"}),
|
||||
)
|
||||
session.add(volunteer)
|
||||
try:
|
||||
@@ -180,7 +150,6 @@ def create_volunteer(
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "volunteer.duplicate_email"}
|
||||
) from exc
|
||||
_set_skills(session, volunteer, payload.skill_codes)
|
||||
_set_artists(session, volunteer, payload.artist_ids)
|
||||
_set_wished_positions(session, volunteer, payload.wished_position_ids)
|
||||
session.commit()
|
||||
@@ -224,7 +193,6 @@ def update_volunteer(
|
||||
) -> VolunteerRead:
|
||||
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
skill_codes = changes.pop("skill_codes", None)
|
||||
artist_ids = changes.pop("artist_ids", None)
|
||||
wished_position_ids = changes.pop("wished_position_ids", None)
|
||||
for field, value in changes.items():
|
||||
@@ -236,8 +204,6 @@ def update_volunteer(
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "volunteer.duplicate_email"}
|
||||
) from exc
|
||||
if skill_codes is not None:
|
||||
_set_skills(session, volunteer, skill_codes)
|
||||
if artist_ids is not None:
|
||||
_set_artists(session, volunteer, artist_ids)
|
||||
if wished_position_ids is not None:
|
||||
@@ -256,7 +222,7 @@ def delete_volunteer(
|
||||
) -> Response:
|
||||
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||
# Effacement complet : la cascade base supprime disponibilités,
|
||||
# affectations, liens de compétence et compte éventuel.
|
||||
# affectations, vœux et compte éventuel.
|
||||
session.delete(volunteer)
|
||||
session.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -24,40 +24,30 @@
|
||||
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition.",
|
||||
"account.password_required": "Un compte coordination requiert un mot de passe d'au moins {min} caractères.",
|
||||
"account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe.",
|
||||
|
||||
"import.error.empty_file": "Le fichier est vide.",
|
||||
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
|
||||
"import.error.unreadable_file": "Fichier illisible ou format non pris en charge.",
|
||||
"import.error.file_too_large": "Le fichier est trop volumineux.",
|
||||
|
||||
"import.mapping.invalid": "Le paramétrage des colonnes est invalide.",
|
||||
"import.mapping.required_field_missing": "Le champ « {field} » doit être associé à une colonne.",
|
||||
"import.mapping.column_not_found": "La colonne « {column} » est absente du fichier.",
|
||||
"import.mapping.unknown_day_part": "Demi-journée « {day_part_code} » inconnue pour cette édition (colonne « {column} »).",
|
||||
"import.mapping.date_outside_edition": "La date {date} de la colonne « {column} » est hors des dates de l'édition.",
|
||||
"import.mapping.duplicate_availability_column": "La colonne « {column} » est associée plusieurs fois.",
|
||||
|
||||
"import.row.missing_required_field": "Champ obligatoire manquant : {field}.",
|
||||
"import.row.invalid_email": "Adresse email invalide : {value}.",
|
||||
"import.row.duplicate_email_in_file": "Email déjà présent à la ligne {first_line} du fichier.",
|
||||
"import.row.duplicate_name_in_file": "Nom et prénom déjà présents à la ligne {first_line} du fichier.",
|
||||
"import.row.updated_existing": "Bénévole existant mis à jour.",
|
||||
"import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l'édition.",
|
||||
"import.row.skill_created": "Nouvelle compétence créée : {label}.",
|
||||
"import.row.unknown_availability_value": "Valeur de disponibilité non comprise « {value} » (colonne « {column} »).",
|
||||
|
||||
"validation.SHIFT_OVERLAP": "Chevauchement avec un autre créneau du bénévole.",
|
||||
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées ({date}, {day_part_code}).",
|
||||
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées le {date} ({uncovered_minutes} min non couvertes).",
|
||||
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h d'affectation cumulées le {date}.",
|
||||
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux.",
|
||||
"validation.MISSING_REQUIRED_SKILL": "Compétence requise absente : {skill_code}.",
|
||||
|
||||
"notification.no_recipients": "Aucun bénévole ciblé.",
|
||||
|
||||
"email.magic_link.subject": "Ton lien de connexion",
|
||||
"email.magic_link.body": "Bonjour,\n\nVoici ton lien de connexion, valable {minutes} minutes :\n\n{link}\n\nSi tu n'es pas à l'origine de cette demande, ignore simplement cet email.",
|
||||
"email.planning.default_subject": "Ton planning bénévole",
|
||||
"email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !",
|
||||
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
|
||||
"email.planning.shift_line": "- {position} : {start} → {end}"
|
||||
"email.planning.shift_line": "- {position} : {start} → {end}",
|
||||
"availability.date_outside_edition": "La date {date} est hors des dates de l'édition.",
|
||||
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes."
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ Le rapport ne contient que des **codes** de traduction + des paramètres
|
||||
structurés, jamais de phrase (voir `locales/fr.json`).
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
@@ -20,21 +19,11 @@ class ColumnMapping(BaseModel):
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
comment: str | None = None
|
||||
skills: str | None = None
|
||||
|
||||
|
||||
class AvailabilityColumnMapping(BaseModel):
|
||||
header: str
|
||||
date: dt.date
|
||||
day_part_code: str
|
||||
|
||||
|
||||
class ImportMapping(BaseModel):
|
||||
dry_run: bool = True
|
||||
columns: ColumnMapping
|
||||
availability_columns: list[AvailabilityColumnMapping] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
|
||||
class Issue(BaseModel):
|
||||
@@ -57,8 +46,6 @@ class ImportReport(BaseModel):
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
skipped: int = 0
|
||||
availabilities_written: int = 0
|
||||
skills_created: list[str] = Field(default_factory=list)
|
||||
rows: list[RowReport] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -67,14 +54,6 @@ class PreviewRow(BaseModel):
|
||||
values: dict[str, str]
|
||||
|
||||
|
||||
class DayPartInfo(BaseModel):
|
||||
code: str
|
||||
label: str
|
||||
start_time: dt.time
|
||||
end_time: dt.time
|
||||
order: int
|
||||
|
||||
|
||||
class AnalyzeResponse(BaseModel):
|
||||
filename: str
|
||||
format: Literal["csv", "xlsx"]
|
||||
@@ -82,5 +61,3 @@ class AnalyzeResponse(BaseModel):
|
||||
row_count: int
|
||||
preview: list[PreviewRow]
|
||||
suggested_mapping: dict[str, str]
|
||||
edition_dates: list[dt.date]
|
||||
day_parts: list[DayPartInfo]
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Cœur de l'import : suggestion de mapping, validation, exécution."""
|
||||
"""Cœur de l'import : suggestion de mapping, validation, exécution.
|
||||
|
||||
L'import ne porte que l'identité des bénévoles (nom, email, téléphone,
|
||||
commentaire). Les disponibilités se déclarent dans l'application.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
@@ -11,16 +15,8 @@ from app.importing.schemas import (
|
||||
Issue,
|
||||
RowReport,
|
||||
)
|
||||
from app.importing.text import normalize, slug_code, split_skills
|
||||
from app.importing.values import parse_availability_cell
|
||||
from app.models import (
|
||||
Availability,
|
||||
DayPart,
|
||||
Edition,
|
||||
Skill,
|
||||
Volunteer,
|
||||
VolunteerSkill,
|
||||
)
|
||||
from app.importing.text import normalize
|
||||
from app.models import Edition, Volunteer
|
||||
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
@@ -30,7 +26,6 @@ _FIELD_HINTS: dict[str, tuple[str, ...]] = {
|
||||
"last_name": ("nom", "last name", "lastname", "surname", "famille"),
|
||||
"phone": ("tel", "phone", "portable", "mobile", "gsm"),
|
||||
"comment": ("commentaire", "remarque", "note", "precision"),
|
||||
"skills": ("competence", "skill", "habilitation", "brevet", "diplome"),
|
||||
}
|
||||
|
||||
|
||||
@@ -50,10 +45,7 @@ def suggest_mapping(headers: list[str]) -> dict[str, str]:
|
||||
|
||||
|
||||
def validate_mapping(
|
||||
table: ParsedTable,
|
||||
mapping: ImportMapping,
|
||||
edition: Edition,
|
||||
day_parts: list[DayPart],
|
||||
table: ParsedTable, mapping: ImportMapping
|
||||
) -> list[Issue]:
|
||||
"""Contrôles préalables. Retourne une liste vide si le mapping est correct."""
|
||||
errors: list[Issue] = []
|
||||
@@ -80,7 +72,7 @@ def validate_mapping(
|
||||
)
|
||||
)
|
||||
|
||||
for field in ("email", "phone", "comment", "skills"):
|
||||
for field in ("email", "phone", "comment"):
|
||||
header = getattr(columns, field)
|
||||
if header and header not in headers:
|
||||
errors.append(
|
||||
@@ -91,50 +83,6 @@ def validate_mapping(
|
||||
params={"column": header},
|
||||
)
|
||||
)
|
||||
|
||||
valid_codes = {dp.code for dp in day_parts}
|
||||
seen: set[str] = set()
|
||||
for column in mapping.availability_columns:
|
||||
if column.header not in headers:
|
||||
errors.append(
|
||||
Issue(
|
||||
code="import.mapping.column_not_found",
|
||||
severity="ERROR",
|
||||
field="availability",
|
||||
params={"column": column.header},
|
||||
)
|
||||
)
|
||||
if column.header in seen:
|
||||
errors.append(
|
||||
Issue(
|
||||
code="import.mapping.duplicate_availability_column",
|
||||
severity="ERROR",
|
||||
params={"column": column.header},
|
||||
)
|
||||
)
|
||||
seen.add(column.header)
|
||||
if column.day_part_code not in valid_codes:
|
||||
errors.append(
|
||||
Issue(
|
||||
code="import.mapping.unknown_day_part",
|
||||
severity="ERROR",
|
||||
params={
|
||||
"column": column.header,
|
||||
"day_part_code": column.day_part_code,
|
||||
},
|
||||
)
|
||||
)
|
||||
if not edition.start_date <= column.date <= edition.end_date:
|
||||
errors.append(
|
||||
Issue(
|
||||
code="import.mapping.date_outside_edition",
|
||||
severity="ERROR",
|
||||
params={
|
||||
"column": column.header,
|
||||
"date": column.date.isoformat(),
|
||||
},
|
||||
)
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
@@ -150,12 +98,6 @@ def run_import(
|
||||
Le mapping est supposé déjà validé par `validate_mapping`.
|
||||
"""
|
||||
columns = mapping.columns
|
||||
day_part_by_code = {
|
||||
dp.code: dp
|
||||
for dp in session.exec(
|
||||
select(DayPart).where(DayPart.edition_id == edition.id)
|
||||
)
|
||||
}
|
||||
|
||||
existing = list(
|
||||
session.exec(
|
||||
@@ -169,14 +111,6 @@ def run_import(
|
||||
key = (normalize(volunteer.last_name), normalize(volunteer.first_name))
|
||||
by_name.setdefault(key, volunteer)
|
||||
|
||||
skills_by_code = {
|
||||
s.code: s
|
||||
for s in session.exec(
|
||||
select(Skill).where(Skill.edition_id == edition.id)
|
||||
)
|
||||
}
|
||||
created_skills: set[str] = set()
|
||||
|
||||
report = ImportReport(dry_run=mapping.dry_run, total_rows=len(table.rows))
|
||||
email_seen: dict[str, int] = {}
|
||||
name_seen: dict[tuple[str, str], int] = {}
|
||||
@@ -284,82 +218,6 @@ def run_import(
|
||||
|
||||
session.flush()
|
||||
|
||||
if columns.skills:
|
||||
linked = set(
|
||||
session.exec(
|
||||
select(VolunteerSkill.skill_id).where(
|
||||
VolunteerSkill.volunteer_id == volunteer.id
|
||||
)
|
||||
)
|
||||
)
|
||||
for token in split_skills(row.get(columns.skills) or ""):
|
||||
code = slug_code(token)
|
||||
if not code:
|
||||
continue
|
||||
skill = skills_by_code.get(code)
|
||||
if skill is None:
|
||||
skill = Skill(
|
||||
edition_id=edition.id, code=code, label=token.strip()
|
||||
)
|
||||
session.add(skill)
|
||||
session.flush()
|
||||
skills_by_code[code] = skill
|
||||
created_skills.add(code)
|
||||
entry.issues.append(
|
||||
Issue(
|
||||
code="import.row.skill_created",
|
||||
severity="WARNING",
|
||||
params={"code": code, "label": skill.label},
|
||||
)
|
||||
)
|
||||
if skill.id not in linked:
|
||||
session.add(
|
||||
VolunteerSkill(
|
||||
volunteer_id=volunteer.id, skill_id=skill.id
|
||||
)
|
||||
)
|
||||
linked.add(skill.id)
|
||||
|
||||
for column in mapping.availability_columns:
|
||||
status, recognized = parse_availability_cell(
|
||||
row.get(column.header, "") or ""
|
||||
)
|
||||
if not recognized:
|
||||
entry.issues.append(
|
||||
Issue(
|
||||
code="import.row.unknown_availability_value",
|
||||
severity="WARNING",
|
||||
field=column.header,
|
||||
params={
|
||||
"column": column.header,
|
||||
"value": (row.get(column.header, "") or "").strip(),
|
||||
},
|
||||
)
|
||||
)
|
||||
continue
|
||||
if status is None:
|
||||
continue
|
||||
day_part = day_part_by_code[column.day_part_code]
|
||||
current = session.exec(
|
||||
select(Availability)
|
||||
.where(Availability.volunteer_id == volunteer.id)
|
||||
.where(Availability.date == column.date)
|
||||
.where(Availability.day_part_id == day_part.id)
|
||||
).first()
|
||||
if current is not None:
|
||||
current.status = status
|
||||
else:
|
||||
session.add(
|
||||
Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=column.date,
|
||||
day_part_id=day_part.id,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
report.availabilities_written += 1
|
||||
|
||||
entry.volunteer_id = volunteer.id
|
||||
report.rows.append(entry)
|
||||
if entry.action == "CREATED":
|
||||
@@ -367,8 +225,6 @@ def run_import(
|
||||
else:
|
||||
report.updated += 1
|
||||
|
||||
report.skills_created = sorted(created_skills)
|
||||
|
||||
if mapping.dry_run:
|
||||
session.rollback()
|
||||
else:
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Normalisation de texte partagée par l'import (accents, casse, découpage)."""
|
||||
"""Normalisation de texte partagée par l'import (accents, casse)."""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
_SKILL_SEPARATORS = re.compile(r"[;,/\n]+")
|
||||
_MULTI_UNDERSCORE = re.compile(r"_+")
|
||||
|
||||
|
||||
@@ -15,12 +14,8 @@ def normalize(value: str) -> str:
|
||||
|
||||
|
||||
def slug_code(value: str) -> str:
|
||||
"""Code stable pour une compétence : MAJUSCULES, séparateurs en `_`."""
|
||||
"""Code stable (MAJUSCULES, séparateurs en `_`) — utilisé pour les
|
||||
créneaux types (`DayPart.code`)."""
|
||||
base = normalize(value).upper()
|
||||
chars = [c if c.isalnum() else "_" for c in base]
|
||||
return _MULTI_UNDERSCORE.sub("_", "".join(chars)).strip("_")
|
||||
|
||||
|
||||
def split_skills(value: str) -> list[str]:
|
||||
parts = (p.strip() for p in _SKILL_SEPARATORS.split(value or ""))
|
||||
return [p for p in parts if p]
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Interprétation des valeurs de cellule : disponibilités."""
|
||||
|
||||
from app.importing.text import normalize
|
||||
from app.models import AvailabilityStatus
|
||||
|
||||
_AVAILABLE = {
|
||||
"x", "o", "oui", "y", "yes", "ok", "1", "vrai", "true",
|
||||
"dispo", "disponible", "present", "presente",
|
||||
"matin", "aprem", "apres-midi", "apres midi", "midi", "soir", "soiree",
|
||||
"journee", "jour",
|
||||
}
|
||||
_IF_NEEDED = {
|
||||
"si besoin", "sib", "peut-etre", "peut etre", "?", "2",
|
||||
"backup", "reserve", "au besoin",
|
||||
}
|
||||
_UNAVAILABLE = {
|
||||
"non", "n", "no", "0", "faux", "false", "indispo", "indisponible", "absent",
|
||||
"-", "x-",
|
||||
}
|
||||
_EMPTY = {"", "na", "n/a", "nc"}
|
||||
|
||||
|
||||
def parse_availability_cell(value: str) -> tuple[AvailabilityStatus | None, bool]:
|
||||
"""(statut, reconnu).
|
||||
|
||||
- statut `None` + reconnu `True` → cellule vide, pas de déclaration.
|
||||
- statut `None` + reconnu `False` → valeur non comprise, à signaler.
|
||||
"""
|
||||
norm = normalize(value)
|
||||
if norm in _EMPTY:
|
||||
return None, True
|
||||
if norm in _AVAILABLE:
|
||||
return AvailabilityStatus.AVAILABLE, True
|
||||
if norm in _IF_NEEDED:
|
||||
return AvailabilityStatus.IF_NEEDED, True
|
||||
if norm in _UNAVAILABLE:
|
||||
return AvailabilityStatus.UNAVAILABLE, True
|
||||
return None, False
|
||||
@@ -5,6 +5,7 @@ from app.api.accounts import router as account_router
|
||||
from app.api.artists import router as artist_router
|
||||
from app.api.assignments import router as assignment_router
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.availability import router as availability_router
|
||||
from app.api.day_parts import router as day_part_router
|
||||
from app.api.editions import router as edition_router
|
||||
from app.api.health import router as health_router
|
||||
@@ -39,6 +40,7 @@ app.include_router(account_router)
|
||||
app.include_router(import_router)
|
||||
app.include_router(artist_router)
|
||||
app.include_router(volunteer_router)
|
||||
app.include_router(availability_router)
|
||||
app.include_router(position_router)
|
||||
app.include_router(shift_router)
|
||||
app.include_router(assignment_router)
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Retrait des compétences ; disponibilités en fenêtres horaires libres
|
||||
|
||||
Revision ID: 0007_drop_skills_avail_windows
|
||||
Revises: 0006_artist_and_volunteer_wishes
|
||||
Create Date: 2026-09-10
|
||||
|
||||
- Suppression des tables `skill`, `volunteer_skill`, `shift_required_skill`.
|
||||
- `availability` : on abandonne (`day_part_id`, `status`) au profit de
|
||||
(`start_time`, `end_time`) — fenêtre horaire libre, plusieurs par jour.
|
||||
La table est recréée (les anciennes lignes ne sont pas convertibles).
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0007_drop_skills_avail_windows"
|
||||
down_revision: str | None = "0006_artist_and_volunteer_wishes"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_STATUS = sa.Enum(
|
||||
"AVAILABLE", "IF_NEEDED", "UNAVAILABLE",
|
||||
name="availabilitystatus", native_enum=False,
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_table("shift_required_skill")
|
||||
op.drop_table("volunteer_skill")
|
||||
op.drop_index("ix_skill_edition_id", table_name="skill")
|
||||
op.drop_table("skill")
|
||||
|
||||
op.drop_table("availability")
|
||||
op.create_table(
|
||||
"availability",
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("date", sa.Date(), nullable=False),
|
||||
sa.Column("start_time", sa.Time(), nullable=False),
|
||||
sa.Column("end_time", sa.Time(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["edition_id"], ["edition.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"volunteer_id", "date", "start_time", "end_time",
|
||||
name="uq_availability_window",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_availability_edition_id"),
|
||||
"availability",
|
||||
["edition_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_availability_volunteer_id"),
|
||||
"availability",
|
||||
["volunteer_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_availability_volunteer_id"), table_name="availability")
|
||||
op.drop_index(op.f("ix_availability_edition_id"), table_name="availability")
|
||||
op.drop_table("availability")
|
||||
op.create_table(
|
||||
"availability",
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("date", sa.Date(), nullable=False),
|
||||
sa.Column("day_part_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("status", _STATUS, nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["edition_id"], ["edition.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["day_part_id"], ["day_part.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"volunteer_id", "date", "day_part_id", name="uq_availability_slot"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_availability_edition_id"),
|
||||
"availability", ["edition_id"], unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_availability_volunteer_id"),
|
||||
"availability", ["volunteer_id"], unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_availability_day_part_id"),
|
||||
"availability", ["day_part_id"], unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"skill",
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("code", sa.String(), nullable=False),
|
||||
sa.Column("label", sa.String(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["edition_id"], ["edition.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"edition_id", "code", name="uq_skill_edition_code"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_skill_edition_id"), "skill", ["edition_id"], unique=False
|
||||
)
|
||||
op.create_table(
|
||||
"volunteer_skill",
|
||||
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("skill_id", sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["skill_id"], ["skill.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("volunteer_id", "skill_id"),
|
||||
)
|
||||
op.create_table(
|
||||
"shift_required_skill",
|
||||
sa.Column("shift_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("skill_id", sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["shift_id"], ["shift.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["skill_id"], ["skill.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("shift_id", "skill_id"),
|
||||
)
|
||||
@@ -15,16 +15,12 @@ from app.models.domain import (
|
||||
MagicLinkToken,
|
||||
Position,
|
||||
Shift,
|
||||
ShiftRequiredSkill,
|
||||
Skill,
|
||||
Volunteer,
|
||||
VolunteerArtist,
|
||||
VolunteerPositionWish,
|
||||
VolunteerSkill,
|
||||
)
|
||||
from app.models.enums import (
|
||||
AssignmentSource,
|
||||
AvailabilityStatus,
|
||||
PositionCategory,
|
||||
Role,
|
||||
)
|
||||
@@ -35,7 +31,6 @@ __all__ = [
|
||||
"Assignment",
|
||||
"AssignmentSource",
|
||||
"Availability",
|
||||
"AvailabilityStatus",
|
||||
"DayPart",
|
||||
"Edition",
|
||||
"MagicLinkToken",
|
||||
@@ -43,8 +38,6 @@ __all__ = [
|
||||
"PositionCategory",
|
||||
"Role",
|
||||
"Shift",
|
||||
"ShiftRequiredSkill",
|
||||
"Skill",
|
||||
"TimestampMixin",
|
||||
"Volunteer",
|
||||
"VolunteerArtist",
|
||||
|
||||
@@ -16,7 +16,6 @@ from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
|
||||
from app.models.base import TimestampMixin
|
||||
from app.models.enums import (
|
||||
AssignmentSource,
|
||||
AvailabilityStatus,
|
||||
PositionCategory,
|
||||
Role,
|
||||
)
|
||||
@@ -27,28 +26,6 @@ _PASSIVE = {"passive_deletes": True}
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tables d'association (sans champs d'audit) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class VolunteerSkill(SQLModel, table=True):
|
||||
__tablename__ = "volunteer_skill"
|
||||
|
||||
volunteer_id: uuid.UUID = Field(
|
||||
foreign_key="volunteer.id", primary_key=True, ondelete="CASCADE"
|
||||
)
|
||||
skill_id: uuid.UUID = Field(
|
||||
foreign_key="skill.id", primary_key=True, ondelete="CASCADE"
|
||||
)
|
||||
|
||||
|
||||
class ShiftRequiredSkill(SQLModel, table=True):
|
||||
__tablename__ = "shift_required_skill"
|
||||
|
||||
shift_id: uuid.UUID = Field(
|
||||
foreign_key="shift.id", primary_key=True, ondelete="CASCADE"
|
||||
)
|
||||
skill_id: uuid.UUID = Field(
|
||||
foreign_key="skill.id", primary_key=True, ondelete="CASCADE"
|
||||
)
|
||||
|
||||
|
||||
class VolunteerArtist(SQLModel, table=True):
|
||||
"""Vœu d'un bénévole : « je veux voir cet artiste »."""
|
||||
|
||||
@@ -114,38 +91,11 @@ class DayPart(TimestampMixin, table=True):
|
||||
)
|
||||
|
||||
edition: Edition = Relationship(back_populates="day_parts")
|
||||
availabilities: list["Availability"] = Relationship(
|
||||
back_populates="day_part", sa_relationship_kwargs=_PASSIVE
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Personnes : bénévoles, compétences, comptes #
|
||||
# Personnes : bénévoles, comptes #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class Skill(TimestampMixin, table=True):
|
||||
__table_args__ = (
|
||||
UniqueConstraint("edition_id", "code", name="uq_skill_edition_code"),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
code: str
|
||||
label: str
|
||||
|
||||
volunteers: list["Volunteer"] = Relationship(
|
||||
back_populates="skills",
|
||||
link_model=VolunteerSkill,
|
||||
sa_relationship_kwargs=_PASSIVE,
|
||||
)
|
||||
shifts_requiring: list["Shift"] = Relationship(
|
||||
back_populates="required_skills",
|
||||
link_model=ShiftRequiredSkill,
|
||||
sa_relationship_kwargs=_PASSIVE,
|
||||
)
|
||||
|
||||
|
||||
class Artist(TimestampMixin, table=True):
|
||||
"""Artiste programmé sur l'édition.
|
||||
|
||||
@@ -194,11 +144,6 @@ class Volunteer(TimestampMixin, table=True):
|
||||
phone: str | None = None
|
||||
comment: str | None = None
|
||||
|
||||
skills: list[Skill] = Relationship(
|
||||
back_populates="volunteers",
|
||||
link_model=VolunteerSkill,
|
||||
sa_relationship_kwargs=_PASSIVE,
|
||||
)
|
||||
wished_artists: list[Artist] = Relationship(
|
||||
back_populates="fans",
|
||||
link_model=VolunteerArtist,
|
||||
@@ -319,25 +264,25 @@ class Shift(TimestampMixin, table=True):
|
||||
)
|
||||
|
||||
position: Position = Relationship(back_populates="shifts")
|
||||
required_skills: list[Skill] = Relationship(
|
||||
back_populates="shifts_requiring",
|
||||
link_model=ShiftRequiredSkill,
|
||||
sa_relationship_kwargs=_PASSIVE,
|
||||
)
|
||||
assignments: list["Assignment"] = Relationship(
|
||||
back_populates="shift", sa_relationship_kwargs=_PASSIVE
|
||||
)
|
||||
|
||||
|
||||
class Availability(TimestampMixin, table=True):
|
||||
"""Déclaration du bénévole : « je peux venir tel jour, telle demi-journée ».
|
||||
"""Déclaration du bénévole : « tel jour, je peux venir de telle à telle
|
||||
heure ». Fenêtre horaire libre (pas liée aux créneaux types). Plusieurs
|
||||
fenêtres par jour possibles.
|
||||
|
||||
À ne pas confondre avec Assignment (décision de l'organisation).
|
||||
Heures naïves, locales Europe/Paris. `end_time <= start_time` ⇒ la fenêtre
|
||||
passe minuit (se termine le lendemain). À ne pas confondre avec Assignment
|
||||
(décision de l'organisation).
|
||||
"""
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"volunteer_id", "date", "day_part_id", name="uq_availability_slot"
|
||||
"volunteer_id", "date", "start_time", "end_time",
|
||||
name="uq_availability_window",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -349,17 +294,10 @@ class Availability(TimestampMixin, table=True):
|
||||
foreign_key="volunteer.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
date: dt.date
|
||||
day_part_id: uuid.UUID = Field(
|
||||
foreign_key="day_part.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
status: AvailabilityStatus = Field(
|
||||
sa_column=Column(
|
||||
SAEnum(AvailabilityStatus, native_enum=False), nullable=False
|
||||
)
|
||||
)
|
||||
start_time: dt.time
|
||||
end_time: dt.time
|
||||
|
||||
volunteer: Volunteer = Relationship(back_populates="availabilities")
|
||||
day_part: DayPart = Relationship(back_populates="availabilities")
|
||||
|
||||
|
||||
class Assignment(TimestampMixin, table=True):
|
||||
|
||||
@@ -13,12 +13,6 @@ class Role(enum.StrEnum):
|
||||
VOLUNTEER = "VOLUNTEER"
|
||||
|
||||
|
||||
class AvailabilityStatus(enum.StrEnum):
|
||||
AVAILABLE = "AVAILABLE"
|
||||
IF_NEEDED = "IF_NEEDED"
|
||||
UNAVAILABLE = "UNAVAILABLE"
|
||||
|
||||
|
||||
class AssignmentSource(enum.StrEnum):
|
||||
SOLVER = "SOLVER"
|
||||
MANUAL = "MANUAL"
|
||||
|
||||
+73
-166
@@ -11,6 +11,7 @@ import random
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth.passwords import hash_password
|
||||
@@ -21,27 +22,22 @@ from app.models import (
|
||||
Assignment,
|
||||
AssignmentSource,
|
||||
Availability,
|
||||
AvailabilityStatus,
|
||||
DayPart,
|
||||
Edition,
|
||||
Position,
|
||||
PositionCategory,
|
||||
Role,
|
||||
Shift,
|
||||
ShiftRequiredSkill,
|
||||
Skill,
|
||||
Volunteer,
|
||||
VolunteerSkill,
|
||||
)
|
||||
from app.validation import (
|
||||
AvailabilityDeclaration,
|
||||
AvailabilityWindow,
|
||||
DayPartWindow,
|
||||
Severity,
|
||||
ShiftInfo,
|
||||
ValidationInput,
|
||||
validate,
|
||||
)
|
||||
from app.validation.engine import covered_slots
|
||||
from app.validation.loader import build_validation_input
|
||||
|
||||
SEED = 20260709
|
||||
@@ -136,19 +132,8 @@ LAST_NAMES = [
|
||||
"Nicolas",
|
||||
]
|
||||
|
||||
SKILLS: list[tuple[str, str]] = [
|
||||
("SST", "Sauveteur secouriste du travail"),
|
||||
("PERMIS_B", "Permis B"),
|
||||
("REGIE_SON", "Régie son"),
|
||||
("REGIE_LUM", "Régie lumière"),
|
||||
("CACES", "CACES nacelle"),
|
||||
("ELEC", "Habilitation électrique"),
|
||||
("EN", "Anglais courant"),
|
||||
("ES", "Espagnol courant"),
|
||||
]
|
||||
|
||||
_C = PositionCategory
|
||||
# (nom, catégorie, lieu, compétences requises sur ses créneaux)
|
||||
# (nom, catégorie, lieu, _ inutilisé)
|
||||
POSITIONS: list[tuple[str, PositionCategory, str, list[str]]] = [
|
||||
("Bar central", _C.BAR, "Chapiteau", []),
|
||||
("Bar scène", _C.BAR, "Grande scène", []),
|
||||
@@ -291,11 +276,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
dp_windows = tuple(
|
||||
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order) for dp in day_parts
|
||||
)
|
||||
dp_by_code = {dp.code: dp for dp in day_parts}
|
||||
|
||||
skills = {code: Skill(edition_id=edition.id, code=code, label=label) for code, label in SKILLS}
|
||||
session.add_all(skills.values())
|
||||
session.flush()
|
||||
|
||||
coordination = Account(
|
||||
edition_id=edition.id,
|
||||
@@ -337,7 +317,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
}
|
||||
|
||||
volunteers: list[Volunteer] = []
|
||||
vol_skill_codes: dict[object, set[str]] = {}
|
||||
used_names: set[tuple[str, str]] = set()
|
||||
for i in range(120):
|
||||
while True:
|
||||
@@ -366,45 +345,29 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
volunteer_id=volunteer.id,
|
||||
)
|
||||
)
|
||||
count = rng.choices([0, 1, 2, 3], weights=[0.3, 0.34, 0.24, 0.12])[0]
|
||||
codes = set(rng.sample(list(skills), k=count))
|
||||
# Le permis B est courant : on en garantit chez ~40 % des bénévoles
|
||||
# pour que le parking soit gréable.
|
||||
if i % 5 in (0, 1):
|
||||
codes.add("PERMIS_B")
|
||||
for code in codes:
|
||||
session.add(VolunteerSkill(volunteer_id=volunteer.id, skill_id=skills[code].id))
|
||||
vol_skill_codes[volunteer.id] = codes
|
||||
|
||||
availabilities: dict[object, dict[tuple[dt.date, str], AvailabilityStatus]] = {}
|
||||
# Disponibilités : chaque bénévole déclare, pour ~80 % des jours, une
|
||||
# large fenêtre couvrant la journée et la nuit (06:00 → 04:00 le lendemain).
|
||||
_FULL_DAY = (dt.time(6, 0), dt.time(4, 0))
|
||||
availabilities: dict[object, list[AvailabilityWindow]] = {}
|
||||
for volunteer in volunteers:
|
||||
declared: dict[tuple[dt.date, str], AvailabilityStatus] = {}
|
||||
windows: list[AvailabilityWindow] = []
|
||||
for day in dates:
|
||||
for dp in day_parts:
|
||||
if rng.random() < 0.72:
|
||||
status = rng.choices(
|
||||
[
|
||||
AvailabilityStatus.AVAILABLE,
|
||||
AvailabilityStatus.IF_NEEDED,
|
||||
AvailabilityStatus.UNAVAILABLE,
|
||||
],
|
||||
weights=[0.78, 0.14, 0.08],
|
||||
)[0]
|
||||
session.add(
|
||||
Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=day,
|
||||
day_part_id=dp.id,
|
||||
status=status,
|
||||
)
|
||||
if rng.random() < 0.8:
|
||||
start, end = _FULL_DAY
|
||||
session.add(
|
||||
Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=day,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
)
|
||||
declared[(day, dp.code)] = status
|
||||
availabilities[volunteer.id] = declared
|
||||
)
|
||||
windows.append(AvailabilityWindow(day, start, end))
|
||||
availabilities[volunteer.id] = windows
|
||||
|
||||
shifts: list[Shift] = []
|
||||
shift_required: dict[object, set[str]] = {}
|
||||
for position, (_n, category, _l, required) in zip(positions, POSITIONS, strict=True):
|
||||
for position, (_n, category, _l, _r) in zip(positions, POSITIONS, strict=True):
|
||||
for day in dates:
|
||||
for start_hour, end_hour in _WINDOWS[category][phase_of[day]]:
|
||||
min_staff, ideal_staff = _STAFF[category]
|
||||
@@ -420,21 +383,13 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
)
|
||||
session.add(shift)
|
||||
session.flush()
|
||||
for code in required:
|
||||
session.add(ShiftRequiredSkill(shift_id=shift.id, skill_id=skills[code].id))
|
||||
shifts.append(shift)
|
||||
shift_required[shift.id] = set(required)
|
||||
|
||||
assigned: dict[object, list[Shift]] = {v.id: [] for v in volunteers}
|
||||
|
||||
# Trois bénévoles réservés aux scénarios d'avertissement : exclus de
|
||||
# l'affectation générale pour rester libres.
|
||||
reserved_unskilled = next(
|
||||
v for v in volunteers if not vol_skill_codes[v.id]
|
||||
)
|
||||
reserved = [reserved_unskilled] + [
|
||||
v for v in volunteers if v is not reserved_unskilled
|
||||
][:2]
|
||||
reserved = volunteers[:3]
|
||||
reserved_ids = {v.id for v in reserved}
|
||||
assignable = [v for v in volunteers if v.id not in reserved_ids]
|
||||
|
||||
@@ -444,17 +399,12 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
id=str(shift.id),
|
||||
starts_at=shift.starts_at,
|
||||
ends_at=shift.ends_at,
|
||||
required_skill_codes=frozenset(shift_required[shift.id]),
|
||||
),
|
||||
volunteer_skill_codes=frozenset(vol_skill_codes[volunteer.id]),
|
||||
existing_shifts=tuple(
|
||||
ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at)
|
||||
for s in assigned[volunteer.id]
|
||||
),
|
||||
availabilities=tuple(
|
||||
AvailabilityDeclaration(day, code, status)
|
||||
for (day, code), status in availabilities[volunteer.id].items()
|
||||
),
|
||||
availabilities=tuple(availabilities[volunteer.id]),
|
||||
day_parts=dp_windows,
|
||||
)
|
||||
return not validate(data)
|
||||
@@ -490,15 +440,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
n_assignments += 1
|
||||
|
||||
n_assignments += _add_demo_warnings(
|
||||
session,
|
||||
edition,
|
||||
shifts,
|
||||
reserved,
|
||||
assigned,
|
||||
availabilities,
|
||||
dp_by_code,
|
||||
day_parts,
|
||||
shift_required,
|
||||
session, edition, shifts, reserved, assigned, availabilities
|
||||
)
|
||||
session.flush()
|
||||
|
||||
@@ -526,99 +468,67 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
return summary
|
||||
|
||||
|
||||
def _set_availability(session, edition, volunteer, day, day_part, status, availabilities) -> None:
|
||||
row = session.exec(
|
||||
select(Availability)
|
||||
def _clear_day(session, volunteer, day, availabilities) -> None:
|
||||
session.execute(
|
||||
delete(Availability)
|
||||
.where(Availability.volunteer_id == volunteer.id)
|
||||
.where(Availability.date == day)
|
||||
.where(Availability.day_part_id == day_part.id)
|
||||
).first()
|
||||
if row is not None:
|
||||
row.status = status
|
||||
else:
|
||||
session.add(
|
||||
Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=day,
|
||||
day_part_id=day_part.id,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
availabilities[volunteer.id] = [
|
||||
w for w in availabilities[volunteer.id] if w.date != day
|
||||
]
|
||||
|
||||
|
||||
def _declare_window(
|
||||
session, edition, volunteer, day, start, end, availabilities
|
||||
) -> None:
|
||||
session.add(
|
||||
Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=day,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
)
|
||||
availabilities[volunteer.id][(day, day_part.code)] = status
|
||||
)
|
||||
availabilities[volunteer.id].append(AvailabilityWindow(day, start, end))
|
||||
|
||||
|
||||
def _add_demo_warnings(
|
||||
session,
|
||||
edition,
|
||||
shifts,
|
||||
reserved,
|
||||
assigned,
|
||||
availabilities,
|
||||
dp_by_code,
|
||||
day_parts,
|
||||
shift_required,
|
||||
session, edition, shifts, reserved, assigned, availabilities
|
||||
) -> int:
|
||||
"""Trois affectations qui déclenchent chacune un avertissement, et rien
|
||||
d'autre : compétence absente, hors disponibilité, repos insuffisant.
|
||||
|
||||
`reserved` = [bénévole sans compétence, deux autres], tenus à l'écart de
|
||||
l'affectation générale.
|
||||
"""Quelques affectations qui déclenchent des avertissements (et rien
|
||||
d'autre) : hors disponibilité, repos insuffisant. `reserved` = trois
|
||||
bénévoles tenus à l'écart de l'affectation générale.
|
||||
"""
|
||||
unskilled, reluctant, tired = reserved
|
||||
a, b, tired = reserved
|
||||
added = 0
|
||||
|
||||
# 1. MISSING_REQUIRED_SKILL : sur la régie, un bénévole sans compétence.
|
||||
regie = next(s for s in shifts if "REGIE_SON" in shift_required[s.id])
|
||||
for day, code in covered_slots(regie, day_parts):
|
||||
_set_availability(
|
||||
session,
|
||||
edition,
|
||||
unskilled,
|
||||
day,
|
||||
dp_by_code[code],
|
||||
AvailabilityStatus.AVAILABLE,
|
||||
availabilities,
|
||||
)
|
||||
_add_active(session, edition, unskilled, regie, assigned)
|
||||
added += 1
|
||||
# OUTSIDE_AVAILABILITY : deux bénévoles affectés un jour où ils n'ont
|
||||
# déclaré aucune disponibilité.
|
||||
plain = sorted(shifts, key=lambda s: s.starts_at)
|
||||
for volunteer, shift in ((a, plain[3]), (b, plain[7])):
|
||||
_clear_day(session, volunteer, shift.starts_at.date(), availabilities)
|
||||
_add_active(session, edition, volunteer, shift, assigned)
|
||||
added += 1
|
||||
|
||||
# 2. OUTSIDE_AVAILABILITY : un créneau de bar sur une demi-journée déclarée
|
||||
# indisponible.
|
||||
bar = next(
|
||||
s
|
||||
for s in shifts
|
||||
if not shift_required[s.id] and (s.ends_at - s.starts_at) <= dt.timedelta(hours=8)
|
||||
)
|
||||
for offset, (day, code) in enumerate(covered_slots(bar, day_parts)):
|
||||
_set_availability(
|
||||
session,
|
||||
edition,
|
||||
reluctant,
|
||||
day,
|
||||
dp_by_code[code],
|
||||
AvailabilityStatus.UNAVAILABLE if offset == 0 else AvailabilityStatus.AVAILABLE,
|
||||
availabilities,
|
||||
)
|
||||
_add_active(session, edition, reluctant, bar, assigned)
|
||||
added += 1
|
||||
|
||||
# 3. INSUFFICIENT_REST : deux créneaux sans compétence espacés de moins de
|
||||
# 8 h, sur deux journées (pour ne pas déclencher le cumul).
|
||||
pair = _find_rest_pair(shifts, shift_required)
|
||||
# INSUFFICIENT_REST : deux créneaux espacés de moins de 8 h, sur deux
|
||||
# journées (pour ne pas déclencher le cumul), tous deux couverts par une
|
||||
# disponibilité.
|
||||
pair = _find_rest_pair(shifts)
|
||||
if pair is not None:
|
||||
first, second = pair
|
||||
for shift in (first, second):
|
||||
for day, code in covered_slots(shift, day_parts):
|
||||
_set_availability(
|
||||
session,
|
||||
edition,
|
||||
tired,
|
||||
day,
|
||||
dp_by_code[code],
|
||||
AvailabilityStatus.AVAILABLE,
|
||||
availabilities,
|
||||
)
|
||||
_clear_day(session, tired, shift.starts_at.date(), availabilities)
|
||||
_declare_window(
|
||||
session,
|
||||
edition,
|
||||
tired,
|
||||
shift.starts_at.date(),
|
||||
dt.time(6, 0),
|
||||
dt.time(4, 0),
|
||||
availabilities,
|
||||
)
|
||||
_add_active(session, edition, tired, first, assigned)
|
||||
_add_active(session, edition, tired, second, assigned)
|
||||
added += 2
|
||||
@@ -626,11 +536,8 @@ def _add_demo_warnings(
|
||||
return added
|
||||
|
||||
|
||||
def _find_rest_pair(shifts: list[Shift], shift_required: dict) -> tuple[Shift, Shift] | None:
|
||||
plain = sorted(
|
||||
(s for s in shifts if not shift_required[s.id]),
|
||||
key=lambda s: s.starts_at,
|
||||
)
|
||||
def _find_rest_pair(shifts: list[Shift]) -> tuple[Shift, Shift] | None:
|
||||
plain = sorted(shifts, key=lambda s: s.starts_at)
|
||||
for i, first in enumerate(plain):
|
||||
for second in plain[i + 1 :]:
|
||||
gap = second.starts_at - first.ends_at
|
||||
|
||||
@@ -5,7 +5,7 @@ Voir CLAUDE.md § 3 pour les règles et la sémantique.
|
||||
|
||||
from app.validation.engine import has_blocking, validate
|
||||
from app.validation.models import (
|
||||
AvailabilityDeclaration,
|
||||
AvailabilityWindow,
|
||||
DayPartWindow,
|
||||
Severity,
|
||||
ShiftInfo,
|
||||
@@ -15,7 +15,7 @@ from app.validation.models import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AvailabilityDeclaration",
|
||||
"AvailabilityWindow",
|
||||
"DayPartWindow",
|
||||
"Severity",
|
||||
"ShiftInfo",
|
||||
|
||||
@@ -6,8 +6,8 @@ sortie. Règles et sémantique figées : voir CLAUDE.md § 3.
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from app.models import AvailabilityStatus
|
||||
from app.validation.models import (
|
||||
AvailabilityWindow,
|
||||
Severity,
|
||||
ShiftInfo,
|
||||
ValidationInput,
|
||||
@@ -24,7 +24,6 @@ def validate(data: ValidationInput) -> list[Violation]:
|
||||
violations.extend(_check_outside_availability(data))
|
||||
violations.extend(_check_daily_hours(data))
|
||||
violations.extend(_check_insufficient_rest(data))
|
||||
violations.extend(_check_missing_skills(data))
|
||||
return violations
|
||||
|
||||
|
||||
@@ -58,59 +57,51 @@ def _check_overlap(data: ValidationInput) -> list[Violation]:
|
||||
# --------------------------------------------------------------------------- #
|
||||
# OUTSIDE_AVAILABILITY (avertissement) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def covered_slots(
|
||||
shift: ShiftInfo, day_parts: tuple
|
||||
) -> list[tuple[dt.date, str]]:
|
||||
"""Demi-journées (date, code) dont la fenêtre horaire recoupe le créneau."""
|
||||
if not day_parts:
|
||||
return []
|
||||
slots: list[tuple[dt.date, str]] = []
|
||||
day = shift.starts_at.date() - dt.timedelta(days=1)
|
||||
last = shift.ends_at.date()
|
||||
while day <= last:
|
||||
for part in day_parts:
|
||||
window_start = dt.datetime.combine(day, part.start_time)
|
||||
if part.end_time > part.start_time:
|
||||
window_end = dt.datetime.combine(day, part.end_time)
|
||||
else:
|
||||
window_end = dt.datetime.combine(
|
||||
day + dt.timedelta(days=1), part.end_time
|
||||
)
|
||||
if shift.starts_at < window_end and window_start < shift.ends_at:
|
||||
slots.append((day, part.code))
|
||||
day += dt.timedelta(days=1)
|
||||
return slots
|
||||
def _window_interval(w: AvailabilityWindow) -> tuple[dt.datetime, dt.datetime]:
|
||||
start = dt.datetime.combine(w.date, w.start_time)
|
||||
end_day = w.date if w.end_time > w.start_time else w.date + dt.timedelta(days=1)
|
||||
return start, dt.datetime.combine(end_day, w.end_time)
|
||||
|
||||
|
||||
def uncovered_minutes(
|
||||
shift: ShiftInfo, windows: tuple[AvailabilityWindow, ...]
|
||||
) -> int:
|
||||
"""Minutes du créneau qui ne tombent dans aucune fenêtre de disponibilité.
|
||||
|
||||
Le bénévole peut déclarer plusieurs fenêtres ; on regarde leur union.
|
||||
"""
|
||||
cursor = shift.starts_at
|
||||
intervals = sorted(
|
||||
(iv for iv in map(_window_interval, windows) if iv[1] > iv[0]),
|
||||
key=lambda iv: iv[0],
|
||||
)
|
||||
gap = dt.timedelta()
|
||||
for start, end in intervals:
|
||||
if end <= cursor:
|
||||
continue
|
||||
if start > shift.ends_at:
|
||||
break
|
||||
if start > cursor:
|
||||
gap += min(start, shift.ends_at) - cursor
|
||||
cursor = max(cursor, end)
|
||||
if cursor >= shift.ends_at:
|
||||
return int(gap.total_seconds() // 60)
|
||||
if cursor < shift.ends_at:
|
||||
gap += shift.ends_at - cursor
|
||||
return int(gap.total_seconds() // 60)
|
||||
|
||||
|
||||
def _check_outside_availability(data: ValidationInput) -> list[Violation]:
|
||||
slots = covered_slots(data.candidate_shift, data.day_parts)
|
||||
if not slots:
|
||||
missing = uncovered_minutes(data.candidate_shift, data.availabilities)
|
||||
if missing == 0:
|
||||
return []
|
||||
declared = {
|
||||
(a.date, a.day_part_code): a.status for a in data.availabilities
|
||||
}
|
||||
out: list[Violation] = []
|
||||
for day, code in slots:
|
||||
status = declared.get((day, code))
|
||||
if status is None:
|
||||
out.append(
|
||||
Violation.of(
|
||||
ViolationCode.OUTSIDE_AVAILABILITY,
|
||||
date=day.isoformat(),
|
||||
day_part_code=code,
|
||||
status="UNDECLARED",
|
||||
)
|
||||
)
|
||||
elif status is AvailabilityStatus.UNAVAILABLE:
|
||||
out.append(
|
||||
Violation.of(
|
||||
ViolationCode.OUTSIDE_AVAILABILITY,
|
||||
date=day.isoformat(),
|
||||
day_part_code=code,
|
||||
status="UNAVAILABLE",
|
||||
)
|
||||
)
|
||||
return out
|
||||
return [
|
||||
Violation.of(
|
||||
ViolationCode.OUTSIDE_AVAILABILITY,
|
||||
date=data.candidate_shift.starts_at.date().isoformat(),
|
||||
uncovered_minutes=missing,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -176,17 +167,3 @@ def _check_insufficient_rest(data: ValidationInput) -> list[Violation]:
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# MISSING_REQUIRED_SKILL (avertissement) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _check_missing_skills(data: ValidationInput) -> list[Violation]:
|
||||
missing = sorted(
|
||||
data.candidate_shift.required_skill_codes
|
||||
- data.volunteer_skill_codes
|
||||
)
|
||||
return [
|
||||
Violation.of(ViolationCode.MISSING_REQUIRED_SKILL, skill_code=code)
|
||||
for code in missing
|
||||
]
|
||||
|
||||
@@ -12,45 +12,16 @@ from app.models import (
|
||||
DayPart,
|
||||
Edition,
|
||||
Shift,
|
||||
ShiftRequiredSkill,
|
||||
Skill,
|
||||
Volunteer,
|
||||
VolunteerSkill,
|
||||
)
|
||||
from app.validation.models import (
|
||||
AvailabilityDeclaration,
|
||||
AvailabilityWindow,
|
||||
DayPartWindow,
|
||||
ShiftInfo,
|
||||
ValidationInput,
|
||||
)
|
||||
|
||||
|
||||
def _volunteer_skill_codes(
|
||||
session: Session, volunteer_id: uuid.UUID
|
||||
) -> frozenset[str]:
|
||||
return frozenset(
|
||||
session.exec(
|
||||
select(Skill.code)
|
||||
.join(VolunteerSkill, VolunteerSkill.skill_id == Skill.id)
|
||||
.where(VolunteerSkill.volunteer_id == volunteer_id)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _shift_required_skill_codes(
|
||||
session: Session, shift_id: uuid.UUID
|
||||
) -> frozenset[str]:
|
||||
return frozenset(
|
||||
session.exec(
|
||||
select(Skill.code)
|
||||
.join(
|
||||
ShiftRequiredSkill, ShiftRequiredSkill.skill_id == Skill.id
|
||||
)
|
||||
.where(ShiftRequiredSkill.shift_id == shift_id)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def build_validation_input(
|
||||
*,
|
||||
session: Session,
|
||||
@@ -62,7 +33,6 @@ def build_validation_input(
|
||||
day_parts = list(
|
||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||
)
|
||||
code_by_day_part_id = {dp.id: dp.code for dp in day_parts}
|
||||
|
||||
# Toute affectation existante du bénévole compte (une affectation = une
|
||||
# décision, toujours active).
|
||||
@@ -80,10 +50,8 @@ def build_validation_input(
|
||||
)
|
||||
|
||||
declarations = tuple(
|
||||
AvailabilityDeclaration(
|
||||
date=a.date,
|
||||
day_part_code=code_by_day_part_id.get(a.day_part_id, ""),
|
||||
status=a.status,
|
||||
AvailabilityWindow(
|
||||
date=a.date, start_time=a.start_time, end_time=a.end_time
|
||||
)
|
||||
for a in session.exec(
|
||||
select(Availability).where(
|
||||
@@ -97,9 +65,7 @@ def build_validation_input(
|
||||
id=str(shift.id),
|
||||
starts_at=shift.starts_at,
|
||||
ends_at=shift.ends_at,
|
||||
required_skill_codes=_shift_required_skill_codes(session, shift.id),
|
||||
),
|
||||
volunteer_skill_codes=_volunteer_skill_codes(session, volunteer.id),
|
||||
existing_shifts=existing_shifts,
|
||||
availabilities=declarations,
|
||||
day_parts=tuple(
|
||||
|
||||
@@ -10,8 +10,6 @@ from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models import AvailabilityStatus
|
||||
|
||||
|
||||
class Severity(StrEnum):
|
||||
BLOCKING = "BLOCKING"
|
||||
@@ -23,7 +21,6 @@ class ViolationCode(StrEnum):
|
||||
OUTSIDE_AVAILABILITY = "OUTSIDE_AVAILABILITY"
|
||||
DAILY_HOURS_EXCEEDED = "DAILY_HOURS_EXCEEDED"
|
||||
INSUFFICIENT_REST = "INSUFFICIENT_REST"
|
||||
MISSING_REQUIRED_SKILL = "MISSING_REQUIRED_SKILL"
|
||||
|
||||
|
||||
SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
|
||||
@@ -31,7 +28,6 @@ SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
|
||||
ViolationCode.OUTSIDE_AVAILABILITY: Severity.WARNING,
|
||||
ViolationCode.DAILY_HOURS_EXCEEDED: Severity.WARNING,
|
||||
ViolationCode.INSUFFICIENT_REST: Severity.WARNING,
|
||||
ViolationCode.MISSING_REQUIRED_SKILL: Severity.WARNING,
|
||||
}
|
||||
|
||||
|
||||
@@ -53,11 +49,13 @@ class ShiftInfo:
|
||||
id: str
|
||||
starts_at: dt.datetime
|
||||
ends_at: dt.datetime
|
||||
required_skill_codes: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DayPartWindow:
|
||||
"""Créneau type. Ne sert plus qu'à fixer la borne de « journée » pour le
|
||||
cumul des 8 h (début du premier créneau type)."""
|
||||
|
||||
code: str
|
||||
start_time: dt.time
|
||||
# end_time <= start_time ⇒ la demi-journée passe minuit.
|
||||
@@ -66,10 +64,15 @@ class DayPartWindow:
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AvailabilityDeclaration:
|
||||
class AvailabilityWindow:
|
||||
"""« Le <date>, je peux de <start_time> à <end_time>. »
|
||||
|
||||
`end_time <= start_time` ⇒ la fenêtre se termine le lendemain.
|
||||
"""
|
||||
|
||||
date: dt.date
|
||||
day_part_code: str
|
||||
status: AvailabilityStatus
|
||||
start_time: dt.time
|
||||
end_time: dt.time
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -81,7 +84,6 @@ class ValidationInput:
|
||||
"""
|
||||
|
||||
candidate_shift: ShiftInfo
|
||||
volunteer_skill_codes: frozenset[str] = frozenset()
|
||||
existing_shifts: tuple[ShiftInfo, ...] = ()
|
||||
availabilities: tuple[AvailabilityDeclaration, ...] = ()
|
||||
availabilities: tuple[AvailabilityWindow, ...] = ()
|
||||
day_parts: tuple[DayPartWindow, ...] = ()
|
||||
|
||||
+19
-53
@@ -5,13 +5,10 @@ import datetime as dt
|
||||
from app.models import (
|
||||
Artist,
|
||||
Availability,
|
||||
AvailabilityStatus,
|
||||
Position,
|
||||
PositionCategory,
|
||||
Shift,
|
||||
Skill,
|
||||
Volunteer,
|
||||
VolunteerSkill,
|
||||
)
|
||||
|
||||
|
||||
@@ -36,7 +33,7 @@ def make_artist(
|
||||
|
||||
|
||||
def make_shift(
|
||||
session, edition, position, *, start, end, min_staff=1, ideal_staff=2, skills=()
|
||||
session, edition, position, *, start, end, min_staff=1, ideal_staff=2
|
||||
):
|
||||
shift = Shift(
|
||||
edition_id=edition.id,
|
||||
@@ -47,16 +44,13 @@ def make_shift(
|
||||
ideal_staff=ideal_staff,
|
||||
)
|
||||
session.add(shift)
|
||||
session.flush()
|
||||
for code in skills:
|
||||
_link_skill(session, edition, code=code, shift=shift)
|
||||
session.commit()
|
||||
session.refresh(shift)
|
||||
return shift
|
||||
|
||||
|
||||
def make_volunteer(
|
||||
session, edition, *, first_name="Alex", last_name="Martin", email=None, skills=()
|
||||
session, edition, *, first_name="Alex", last_name="Martin", email=None
|
||||
):
|
||||
volunteer = Volunteer(
|
||||
edition_id=edition.id,
|
||||
@@ -65,66 +59,38 @@ def make_volunteer(
|
||||
email=email,
|
||||
)
|
||||
session.add(volunteer)
|
||||
session.flush()
|
||||
for code in skills:
|
||||
_link_skill(session, edition, volunteer=volunteer, code=code)
|
||||
session.commit()
|
||||
session.refresh(volunteer)
|
||||
return volunteer
|
||||
|
||||
|
||||
def make_availability(session, edition, volunteer, day_part, *, date, status):
|
||||
def make_availability(
|
||||
session, edition, volunteer, *, date, start_time, end_time
|
||||
):
|
||||
"""Fenêtre de disponibilité libre. `end_time <= start_time` ⇒ passe minuit."""
|
||||
row = Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
day_part_id=day_part.id,
|
||||
date=date,
|
||||
status=status,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def declare_available_all_day(session, edition, volunteer, day_parts, date):
|
||||
for part in day_parts.values():
|
||||
make_availability(
|
||||
session,
|
||||
edition,
|
||||
volunteer,
|
||||
part,
|
||||
date=date,
|
||||
status=AvailabilityStatus.AVAILABLE,
|
||||
)
|
||||
|
||||
|
||||
def _get_or_create_skill(session, edition, code) -> Skill:
|
||||
from sqlmodel import select
|
||||
|
||||
skill = session.exec(
|
||||
select(Skill)
|
||||
.where(Skill.edition_id == edition.id)
|
||||
.where(Skill.code == code)
|
||||
).first()
|
||||
if skill is None:
|
||||
skill = Skill(edition_id=edition.id, code=code, label=code)
|
||||
session.add(skill)
|
||||
session.flush()
|
||||
return skill
|
||||
|
||||
|
||||
def _link_skill(session, edition, *, code, volunteer=None, shift=None):
|
||||
from app.models import ShiftRequiredSkill
|
||||
|
||||
skill = _get_or_create_skill(session, edition, code)
|
||||
if volunteer is not None:
|
||||
session.add(
|
||||
VolunteerSkill(volunteer_id=volunteer.id, skill_id=skill.id)
|
||||
)
|
||||
if shift is not None:
|
||||
session.add(
|
||||
ShiftRequiredSkill(shift_id=shift.id, skill_id=skill.id)
|
||||
)
|
||||
def declare_available_all_day(session, edition, volunteer, date):
|
||||
"""Disponible de 00:00 à 23:59 le jour donné (couvre tout un créneau
|
||||
diurne ; pour la nuit, déclarer une fenêtre dédiée)."""
|
||||
make_availability(
|
||||
session,
|
||||
edition,
|
||||
volunteer,
|
||||
date=date,
|
||||
start_time=dt.time(0, 0),
|
||||
end_time=dt.time(23, 59),
|
||||
)
|
||||
|
||||
|
||||
def dt_at(day: int, hour: int, minute: int = 0) -> dt.datetime:
|
||||
|
||||
@@ -13,21 +13,21 @@ def _url(edition, suffix=""):
|
||||
return f"/editions/{edition.id}/assignments{suffix}"
|
||||
|
||||
|
||||
def _setup(session, edition, day_parts, *, available=True, skills=()):
|
||||
def _setup(session, edition, *, available=True):
|
||||
position = make_position(session, edition)
|
||||
shift = make_shift(
|
||||
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18)
|
||||
)
|
||||
volunteer = make_volunteer(session, edition, email="v@ex.fr", skills=skills)
|
||||
volunteer = make_volunteer(session, edition, email="v@ex.fr")
|
||||
if available:
|
||||
declare_available_all_day(
|
||||
session, edition, volunteer, day_parts, dt.date(2026, 7, 10)
|
||||
session, edition, volunteer, dt.date(2026, 7, 10)
|
||||
)
|
||||
return volunteer, shift
|
||||
|
||||
|
||||
def test_create_assignment_clean(client, edition, session, day_parts):
|
||||
volunteer, shift = _setup(session, edition, day_parts)
|
||||
volunteer, shift = _setup(session, edition)
|
||||
resp = client.post(
|
||||
_url(edition),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||
@@ -40,32 +40,19 @@ def test_create_assignment_clean(client, edition, session, day_parts):
|
||||
|
||||
|
||||
def test_create_assignment_returns_warnings(client, edition, session, day_parts):
|
||||
# Bénévole non déclaré disponible + compétence requise absente.
|
||||
volunteer, shift = _setup(session, edition, day_parts, available=False)
|
||||
position = make_position(session, edition, name="Régie")
|
||||
shift_with_skill = make_shift(
|
||||
session,
|
||||
edition,
|
||||
position,
|
||||
start=dt_at(10, 14),
|
||||
end=dt_at(10, 18),
|
||||
skills=["SOUND"],
|
||||
)
|
||||
# Bénévole non déclaré disponible sur le créneau.
|
||||
volunteer, shift = _setup(session, edition, available=False)
|
||||
resp = client.post(
|
||||
_url(edition),
|
||||
json={
|
||||
"volunteer_id": str(volunteer.id),
|
||||
"shift_id": str(shift_with_skill.id),
|
||||
},
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
codes = {w["code"] for w in resp.json()["warnings"]}
|
||||
assert "MISSING_REQUIRED_SKILL" in codes
|
||||
assert "OUTSIDE_AVAILABILITY" in codes
|
||||
|
||||
|
||||
def test_create_assignment_blocked_by_overlap(client, edition, session, day_parts):
|
||||
volunteer, shift = _setup(session, edition, day_parts)
|
||||
volunteer, shift = _setup(session, edition)
|
||||
assert client.post(
|
||||
_url(edition),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||
@@ -94,7 +81,7 @@ def test_create_assignment_blocked_by_overlap(client, edition, session, day_part
|
||||
|
||||
|
||||
def test_create_duplicate_assignment_conflicts(client, edition, session, day_parts):
|
||||
volunteer, shift = _setup(session, edition, day_parts)
|
||||
volunteer, shift = _setup(session, edition)
|
||||
body = {"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)}
|
||||
assert client.post(_url(edition), json=body).status_code == 201
|
||||
resp = client.post(_url(edition), json=body)
|
||||
@@ -112,7 +99,7 @@ def test_assignment_wrong_edition_is_422(client, edition, session, day_parts):
|
||||
session.commit()
|
||||
session.refresh(other)
|
||||
stranger = make_volunteer(session, other, email="s@ex.fr")
|
||||
_, shift = _setup(session, edition, day_parts)
|
||||
_, shift = _setup(session, edition)
|
||||
|
||||
resp = client.post(
|
||||
_url(edition),
|
||||
@@ -123,7 +110,7 @@ def test_assignment_wrong_edition_is_422(client, edition, session, day_parts):
|
||||
|
||||
|
||||
def test_validate_endpoint_does_not_persist(client, edition, session, day_parts):
|
||||
volunteer, shift = _setup(session, edition, day_parts, available=False)
|
||||
volunteer, shift = _setup(session, edition, available=False)
|
||||
resp = client.post(
|
||||
_url(edition, "/validate"),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||
@@ -138,7 +125,7 @@ def test_validate_endpoint_does_not_persist(client, edition, session, day_parts)
|
||||
def test_patch_locked_keeps_assignment_and_returns_warnings(
|
||||
client, edition, session, day_parts
|
||||
):
|
||||
volunteer, shift = _setup(session, edition, day_parts, available=False)
|
||||
volunteer, shift = _setup(session, edition, available=False)
|
||||
aid = client.post(
|
||||
_url(edition),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||
@@ -153,7 +140,7 @@ def test_patch_locked_keeps_assignment_and_returns_warnings(
|
||||
|
||||
|
||||
def test_delete_assignment(client, edition, session, day_parts):
|
||||
volunteer, shift = _setup(session, edition, day_parts)
|
||||
volunteer, shift = _setup(session, edition)
|
||||
aid = client.post(
|
||||
_url(edition),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
from app.models import Role
|
||||
from tests.factories import make_volunteer
|
||||
|
||||
|
||||
def _url(edition, volunteer, suffix=""):
|
||||
return f"/editions/{edition.id}/volunteers/{volunteer.id}/availability{suffix}"
|
||||
|
||||
|
||||
def test_put_then_get_returns_windows_sorted(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-11", "start_time": "14:00", "end_time": "19:00"},
|
||||
{"date": "2026-07-10", "start_time": "08:00", "end_time": "13:00"},
|
||||
{"date": "2026-07-10", "start_time": "19:00", "end_time": "02:00"},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
got = [(w["date"], w["start_time"], w["end_time"]) for w in resp.json()]
|
||||
assert got == [
|
||||
("2026-07-10", "08:00:00", "13:00:00"),
|
||||
("2026-07-10", "19:00:00", "02:00:00"),
|
||||
("2026-07-11", "14:00:00", "19:00:00"),
|
||||
]
|
||||
assert client.get(_url(edition, v)).json() == resp.json()
|
||||
|
||||
|
||||
def test_put_replaces_the_whole_set_and_dedupes(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-10", "start_time": "08:00", "end_time": "12:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-11", "start_time": "09:00", "end_time": "17:00"},
|
||||
{"date": "2026-07-11", "start_time": "09:00", "end_time": "17:00"},
|
||||
]
|
||||
},
|
||||
)
|
||||
body = resp.json()
|
||||
assert len(body) == 1
|
||||
assert body[0]["date"] == "2026-07-11"
|
||||
|
||||
|
||||
def test_put_rejects_date_outside_edition(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-08-01", "start_time": "08:00", "end_time": "12:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "availability.date_outside_edition"
|
||||
|
||||
|
||||
def test_put_rejects_empty_window(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-10", "start_time": "09:00", "end_time": "09:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "availability.empty_window"
|
||||
|
||||
|
||||
def test_edition_wide_listing(client, edition, session):
|
||||
v1 = make_volunteer(session, edition, first_name="A", email="a@e.fr")
|
||||
v2 = make_volunteer(session, edition, first_name="B", email="b@e.fr")
|
||||
for v in (v1, v2):
|
||||
client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-10", "start_time": "08:00", "end_time": "13:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
body = client.get(f"/editions/{edition.id}/availability").json()
|
||||
assert {w["volunteer_id"] for w in body} == {str(v1.id), str(v2.id)}
|
||||
|
||||
|
||||
def test_lead_can_read_but_not_write(edition, session, make_account, as_account):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
api = as_account(lead)
|
||||
assert api.get(_url(edition, v)).status_code == 200
|
||||
resp = api.put(_url(edition, v), json={"windows": []})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_volunteer_reads_only_their_own(edition, session, make_account, as_account):
|
||||
me = make_volunteer(session, edition, email="me@e.fr")
|
||||
other = make_volunteer(session, edition, email="other@e.fr")
|
||||
account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="acc@e.fr")
|
||||
api = as_account(account)
|
||||
assert api.get(_url(edition, me)).status_code == 200
|
||||
assert api.get(_url(edition, other)).status_code == 403
|
||||
@@ -1,9 +1,5 @@
|
||||
import datetime as dt
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from app.models import Availability, AvailabilityStatus, Role, Shift
|
||||
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
||||
from app.models import Role, Shift
|
||||
from tests.factories import dt_at, make_position, make_shift
|
||||
|
||||
|
||||
def _url(edition, suffix=""):
|
||||
@@ -46,29 +42,18 @@ def test_update_day_part(client, edition):
|
||||
assert resp.json()["end_time"] == "12:00:00"
|
||||
|
||||
|
||||
def test_delete_unlinks_shifts_and_removes_availabilities(
|
||||
client, edition, session, day_parts
|
||||
):
|
||||
def test_delete_sets_shift_day_part_to_null(client, edition, session, day_parts):
|
||||
morning = day_parts["MORNING"]
|
||||
position = make_position(session, edition)
|
||||
shift = make_shift(
|
||||
session, edition, position, start=dt_at(10, 6), end=dt_at(10, 13)
|
||||
)
|
||||
shift.day_part_id = morning.id
|
||||
volunteer = make_volunteer(session, edition, email="v@e.fr")
|
||||
session.add(
|
||||
Availability(
|
||||
edition_id=edition.id, volunteer_id=volunteer.id,
|
||||
date=dt.date(2026, 7, 10), day_part_id=morning.id,
|
||||
status=AvailabilityStatus.AVAILABLE,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
assert client.delete(_url(edition, f"/{morning.id}")).status_code == 204
|
||||
session.expire_all()
|
||||
assert session.get(Shift, shift.id).day_part_id is None
|
||||
assert session.exec(select(Availability)).all() == []
|
||||
|
||||
|
||||
def test_write_is_coordinator_only(edition, make_account, as_account):
|
||||
|
||||
@@ -14,18 +14,16 @@ def _payload(position, **overrides):
|
||||
"ends_at": "2026-07-10T22:00:00",
|
||||
"min_staff": 2,
|
||||
"ideal_staff": 4,
|
||||
"required_skill_codes": ["SST"],
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_create_shift_with_required_skills(client, edition, session):
|
||||
def test_create_shift(client, edition, session):
|
||||
position = make_position(session, edition)
|
||||
resp = client.post(_url(edition), json=_payload(position))
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["required_skill_codes"] == ["SST"]
|
||||
assert body["min_staff"] == 2
|
||||
assert body["position_id"] == str(position.id)
|
||||
|
||||
@@ -75,20 +73,16 @@ def test_list_filter_by_position(client, edition, session):
|
||||
assert len(client.get(_url(edition)).json()) == 2
|
||||
|
||||
|
||||
def test_patch_shift_times_and_skills(client, edition, session):
|
||||
def test_patch_shift_times(client, edition, session):
|
||||
position = make_position(session, edition)
|
||||
sid = client.post(_url(edition), json=_payload(position)).json()["id"]
|
||||
|
||||
patched = client.patch(
|
||||
_url(edition, f"/{sid}"),
|
||||
json={
|
||||
"ends_at": "2026-07-10T23:30:00",
|
||||
"required_skill_codes": ["SOUND", "SST"],
|
||||
},
|
||||
json={"ends_at": "2026-07-10T23:30:00"},
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
assert patched.json()["ends_at"] == "2026-07-10T23:30:00"
|
||||
assert patched.json()["required_skill_codes"] == ["SOUND", "SST"]
|
||||
|
||||
|
||||
def test_patch_shift_into_invalid_range_is_422(client, edition, session):
|
||||
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from app.models import Assignment, Availability, AvailabilityStatus, Volunteer
|
||||
from app.models import Assignment, Availability, Volunteer
|
||||
from tests.factories import (
|
||||
dt_at,
|
||||
make_artist,
|
||||
@@ -18,20 +18,20 @@ def _url(edition, suffix=""):
|
||||
return f"/editions/{edition.id}/volunteers{suffix}"
|
||||
|
||||
|
||||
def test_create_volunteer_with_skills(client, edition):
|
||||
def test_create_volunteer(client, edition):
|
||||
resp = client.post(
|
||||
_url(edition),
|
||||
json={
|
||||
"first_name": "Alex",
|
||||
"last_name": "Martin",
|
||||
"email": "alex@ex.fr",
|
||||
"skill_codes": ["SST", "PERMIS_B", "SST"],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["skill_codes"] == ["PERMIS_B", "SST"]
|
||||
assert body["edition_id"] == str(edition.id)
|
||||
assert body["artist_ids"] == []
|
||||
assert body["wished_position_ids"] == []
|
||||
|
||||
|
||||
def test_create_duplicate_email_conflicts(client, edition):
|
||||
@@ -131,23 +131,18 @@ def test_patch_replaces_and_clears_wishes(client, edition, session):
|
||||
assert r3.json()["artist_ids"] == []
|
||||
|
||||
|
||||
def test_patch_replaces_skills_and_can_clear(client, edition, session):
|
||||
def test_patch_updates_simple_fields(client, edition, session):
|
||||
volunteer = make_volunteer(session, edition, email="p@ex.fr")
|
||||
r1 = client.patch(
|
||||
_url(edition, f"/{volunteer.id}"),
|
||||
json={"first_name": "Neo", "skill_codes": ["SST"]},
|
||||
json={"first_name": "Neo", "comment": "dispo tôt"},
|
||||
)
|
||||
assert r1.status_code == 200
|
||||
assert r1.json()["first_name"] == "Neo"
|
||||
assert r1.json()["skill_codes"] == ["SST"]
|
||||
|
||||
r2 = client.patch(
|
||||
_url(edition, f"/{volunteer.id}"), json={"skill_codes": []}
|
||||
)
|
||||
assert r2.json()["skill_codes"] == []
|
||||
assert r1.json()["comment"] == "dispo tôt"
|
||||
|
||||
|
||||
def test_delete_is_a_full_cascade(client, edition, session, day_parts):
|
||||
def test_delete_is_a_full_cascade(client, edition, session):
|
||||
volunteer = make_volunteer(session, edition, email="gone@ex.fr")
|
||||
position = make_position(session, edition)
|
||||
shift = make_shift(
|
||||
@@ -157,9 +152,9 @@ def test_delete_is_a_full_cascade(client, edition, session, day_parts):
|
||||
session,
|
||||
edition,
|
||||
volunteer,
|
||||
day_parts["MORNING"],
|
||||
date=dt.date(2026, 7, 10),
|
||||
status=AvailabilityStatus.AVAILABLE,
|
||||
start_time=dt.time(8, 0),
|
||||
end_time=dt.time(13, 0),
|
||||
)
|
||||
session.add(
|
||||
Assignment(
|
||||
|
||||
@@ -6,9 +6,9 @@ from sqlmodel import select
|
||||
from app.models import Volunteer
|
||||
|
||||
_CSV = (
|
||||
"Prénom;Nom;Email;Compétences;Ven matin\n"
|
||||
"Alex;Martin;alex@ex.fr;SST, Permis B;x\n"
|
||||
"Sam;Nguyen;sam@ex.fr;SST;si besoin\n"
|
||||
"Prénom;Nom;Email;Téléphone\n"
|
||||
"Alex;Martin;alex@ex.fr;0600000000\n"
|
||||
"Sam;Nguyen;sam@ex.fr;\n"
|
||||
).encode()
|
||||
|
||||
|
||||
@@ -20,20 +20,13 @@ def _mapping(dry_run: bool) -> str:
|
||||
"first_name": "Prénom",
|
||||
"last_name": "Nom",
|
||||
"email": "Email",
|
||||
"skills": "Compétences",
|
||||
"phone": "Téléphone",
|
||||
},
|
||||
"availability_columns": [
|
||||
{
|
||||
"header": "Ven matin",
|
||||
"date": "2026-07-10",
|
||||
"day_part_code": "MORNING",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_analyze_returns_headers_preview_and_day_parts(client, edition):
|
||||
def test_analyze_returns_headers_preview_and_suggestion(client, edition):
|
||||
resp = client.post(
|
||||
f"/editions/{edition.id}/import/analyze",
|
||||
files={"file": ("benevoles.csv", _CSV, "text/csv")},
|
||||
@@ -41,16 +34,11 @@ def test_analyze_returns_headers_preview_and_day_parts(client, edition):
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["format"] == "csv"
|
||||
assert body["headers"] == ["Prénom", "Nom", "Email", "Compétences", "Ven matin"]
|
||||
assert body["headers"] == ["Prénom", "Nom", "Email", "Téléphone"]
|
||||
assert body["row_count"] == 2
|
||||
assert body["preview"][0]["values"]["Nom"] == "Martin"
|
||||
assert body["suggested_mapping"]["first_name"] == "Prénom"
|
||||
assert {d["code"] for d in body["day_parts"]} == {
|
||||
"MORNING",
|
||||
"AFTERNOON",
|
||||
"EVENING",
|
||||
}
|
||||
assert body["edition_dates"][0] == "2026-07-09"
|
||||
assert body["suggested_mapping"]["phone"] == "Téléphone"
|
||||
|
||||
|
||||
def test_execute_dry_run_then_real(client, session, edition):
|
||||
@@ -69,9 +57,7 @@ def test_execute_dry_run_then_real(client, session, edition):
|
||||
data={"mapping": _mapping(dry_run=False)},
|
||||
)
|
||||
assert real.status_code == 200
|
||||
report = real.json()
|
||||
assert report["created"] == 2
|
||||
assert sorted(report["skills_created"]) == ["PERMIS_B", "SST"]
|
||||
assert real.json()["created"] == 2
|
||||
assert len(session.exec(select(Volunteer)).all()) == 2
|
||||
|
||||
|
||||
@@ -79,14 +65,7 @@ def test_execute_rejects_invalid_mapping(client, edition):
|
||||
bad_mapping = json.dumps(
|
||||
{
|
||||
"dry_run": True,
|
||||
"columns": {"first_name": "Prénom", "last_name": "Nom"},
|
||||
"availability_columns": [
|
||||
{
|
||||
"header": "Ven matin",
|
||||
"date": "2026-07-10",
|
||||
"day_part_code": "NIGHT",
|
||||
}
|
||||
],
|
||||
"columns": {"first_name": "Prénom", "last_name": "Absente"},
|
||||
}
|
||||
)
|
||||
resp = client.post(
|
||||
@@ -98,7 +77,7 @@ def test_execute_rejects_invalid_mapping(client, edition):
|
||||
detail = resp.json()["detail"]
|
||||
assert detail["code"] == "import.mapping.invalid"
|
||||
assert any(
|
||||
e["code"] == "import.mapping.unknown_day_part" for e in detail["errors"]
|
||||
e["code"] == "import.mapping.column_not_found" for e in detail["errors"]
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import datetime as dt
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from app.importing.parser import ParsedTable
|
||||
from app.importing.schemas import (
|
||||
AvailabilityColumnMapping,
|
||||
ColumnMapping,
|
||||
ImportMapping,
|
||||
)
|
||||
from app.importing.schemas import ColumnMapping, ImportMapping
|
||||
from app.importing.service import run_import, suggest_mapping, validate_mapping
|
||||
from app.models import Availability, Skill, Volunteer
|
||||
from app.models import Volunteer
|
||||
|
||||
HEADERS = ["Prénom", "Nom", "Email", "Taille", "Compétences", "Ven matin", "Ven aprem"]
|
||||
HEADERS = ["Prénom", "Nom", "Email", "Téléphone", "Remarque"]
|
||||
|
||||
|
||||
def _mapping(dry_run: bool = False) -> ImportMapping:
|
||||
@@ -21,18 +15,9 @@ def _mapping(dry_run: bool = False) -> ImportMapping:
|
||||
first_name="Prénom",
|
||||
last_name="Nom",
|
||||
email="Email",
|
||||
skills="Compétences",
|
||||
phone="Téléphone",
|
||||
comment="Remarque",
|
||||
),
|
||||
availability_columns=[
|
||||
AvailabilityColumnMapping(
|
||||
header="Ven matin", date=dt.date(2026, 7, 10),
|
||||
day_part_code="MORNING",
|
||||
),
|
||||
AvailabilityColumnMapping(
|
||||
header="Ven aprem", date=dt.date(2026, 7, 10),
|
||||
day_part_code="AFTERNOON",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -58,46 +43,36 @@ def test_suggest_mapping_matches_common_headers():
|
||||
assert suggestion["phone"] == "Numéro de portable"
|
||||
|
||||
|
||||
def test_happy_path_creates_volunteers_skills_and_availabilities(session, edition):
|
||||
def test_happy_path_creates_volunteers(session, edition):
|
||||
report = run_import(
|
||||
session=session,
|
||||
edition=edition,
|
||||
table=_table(
|
||||
[
|
||||
["Alex", "Martin", "alex@ex.fr", "M", "SST, Permis B", "x", "non"],
|
||||
["Sam", "Nguyen", "sam@ex.fr", "L", "SST", "si besoin", ""],
|
||||
["Alex", "Martin", "alex@ex.fr", "0600000000", "arrive tôt"],
|
||||
["Sam", "Nguyen", "sam@ex.fr", "", ""],
|
||||
]
|
||||
),
|
||||
mapping=_mapping(),
|
||||
)
|
||||
|
||||
assert (report.created, report.updated, report.skipped) == (2, 0, 0)
|
||||
assert report.skills_created == ["PERMIS_B", "SST"]
|
||||
assert report.availabilities_written == 3 # x, non, si besoin
|
||||
|
||||
volunteers = {v.email: v for v in _volunteers(session, edition)}
|
||||
assert set(volunteers) == {"alex@ex.fr", "sam@ex.fr"}
|
||||
assert {s.code for s in volunteers["alex@ex.fr"].skills} == {"SST", "PERMIS_B"}
|
||||
|
||||
avails = session.exec(
|
||||
select(Availability).where(
|
||||
Availability.volunteer_id == volunteers["alex@ex.fr"].id
|
||||
)
|
||||
).all()
|
||||
assert {a.status.value for a in avails} == {"AVAILABLE", "UNAVAILABLE"}
|
||||
assert volunteers["alex@ex.fr"].phone == "0600000000"
|
||||
assert volunteers["alex@ex.fr"].comment == "arrive tôt"
|
||||
|
||||
|
||||
def test_dry_run_persists_nothing(session, edition):
|
||||
report = run_import(
|
||||
session=session,
|
||||
edition=edition,
|
||||
table=_table([["Alex", "Martin", "alex@ex.fr", "M", "SST", "x", "x"]]),
|
||||
table=_table([["Alex", "Martin", "alex@ex.fr", "", ""]]),
|
||||
mapping=_mapping(dry_run=True),
|
||||
)
|
||||
assert report.created == 1
|
||||
assert report.dry_run is True
|
||||
assert _volunteers(session, edition) == []
|
||||
assert session.exec(select(Skill)).all() == []
|
||||
|
||||
|
||||
def test_existing_email_is_updated_not_duplicated(session, edition):
|
||||
@@ -115,7 +90,7 @@ def test_existing_email_is_updated_not_duplicated(session, edition):
|
||||
report = run_import(
|
||||
session=session,
|
||||
edition=edition,
|
||||
table=_table([["Alex", "Martin", "ALEX@ex.fr", "M", "SST", "x", ""]]),
|
||||
table=_table([["Alex", "Martin", "ALEX@ex.fr", "", ""]]),
|
||||
mapping=_mapping(),
|
||||
)
|
||||
|
||||
@@ -133,8 +108,8 @@ def test_duplicate_email_within_file_is_skipped(session, edition):
|
||||
edition=edition,
|
||||
table=_table(
|
||||
[
|
||||
["Alex", "Martin", "dup@ex.fr", "M", "", "x", ""],
|
||||
["Alex", "Doublon", "dup@ex.fr", "L", "", "x", ""],
|
||||
["Alex", "Martin", "dup@ex.fr", "", ""],
|
||||
["Alex", "Doublon", "dup@ex.fr", "", ""],
|
||||
]
|
||||
),
|
||||
mapping=_mapping(),
|
||||
@@ -161,7 +136,7 @@ def test_row_without_email_matching_existing_name_warns_but_creates(
|
||||
report = run_import(
|
||||
session=session,
|
||||
edition=edition,
|
||||
table=_table([["jean", "DUPONT", "", "", "", "x", ""]]),
|
||||
table=_table([["jean", "DUPONT", "", "", ""]]),
|
||||
mapping=_mapping(),
|
||||
)
|
||||
assert report.created == 1
|
||||
@@ -173,7 +148,7 @@ def test_missing_required_field_is_skipped(session, edition):
|
||||
report = run_import(
|
||||
session=session,
|
||||
edition=edition,
|
||||
table=_table([["", "Martin", "x@ex.fr", "", "", "x", ""]]),
|
||||
table=_table([["", "Martin", "x@ex.fr", "", ""]]),
|
||||
mapping=_mapping(),
|
||||
)
|
||||
assert report.skipped == 1
|
||||
@@ -185,45 +160,18 @@ def test_invalid_email_is_skipped(session, edition):
|
||||
report = run_import(
|
||||
session=session,
|
||||
edition=edition,
|
||||
table=_table([["Alex", "Martin", "pas-un-email", "", "", "x", ""]]),
|
||||
table=_table([["Alex", "Martin", "pas-un-email", "", ""]]),
|
||||
mapping=_mapping(),
|
||||
)
|
||||
assert report.skipped == 1
|
||||
assert report.rows[0].issues[0].code == "import.row.invalid_email"
|
||||
|
||||
|
||||
def test_unknown_availability_value_is_flagged(session, edition):
|
||||
report = run_import(
|
||||
session=session,
|
||||
edition=edition,
|
||||
table=_table([["Alex", "Martin", "a@ex.fr", "", "", "bof", "x"]]),
|
||||
mapping=_mapping(),
|
||||
)
|
||||
codes = [i.code for i in report.rows[0].issues]
|
||||
assert "import.row.unknown_availability_value" in codes
|
||||
assert report.availabilities_written == 1 # seule "Ven aprem" a été retenue
|
||||
|
||||
|
||||
def test_validate_mapping_reports_bad_day_part_and_date(session, edition):
|
||||
from app.models import DayPart
|
||||
|
||||
day_parts = list(
|
||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||
)
|
||||
def test_validate_mapping_reports_missing_and_unknown_columns():
|
||||
mapping = ImportMapping(
|
||||
columns=ColumnMapping(first_name="Prénom", last_name="Nom"),
|
||||
availability_columns=[
|
||||
AvailabilityColumnMapping(
|
||||
header="Ven matin", date=dt.date(2026, 7, 10),
|
||||
day_part_code="NIGHT",
|
||||
),
|
||||
AvailabilityColumnMapping(
|
||||
header="Ven aprem", date=dt.date(2030, 1, 1),
|
||||
day_part_code="MORNING",
|
||||
),
|
||||
],
|
||||
columns=ColumnMapping(first_name="Prénom", last_name="", email="Absente")
|
||||
)
|
||||
errors = validate_mapping(_table([]), mapping, edition, day_parts)
|
||||
errors = validate_mapping(_table([]), mapping)
|
||||
codes = {e.code for e in errors}
|
||||
assert "import.mapping.unknown_day_part" in codes
|
||||
assert "import.mapping.date_outside_edition" in codes
|
||||
assert "import.mapping.required_field_missing" in codes
|
||||
assert "import.mapping.column_not_found" in codes
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
from app.importing.text import normalize, slug_code
|
||||
|
||||
|
||||
def test_normalize_strips_accents_case_and_spaces():
|
||||
assert normalize(" Régie SON ") == "regie son"
|
||||
assert normalize("") == ""
|
||||
|
||||
|
||||
def test_slug_code_is_stable_across_accents_and_case():
|
||||
assert slug_code("Régie son") == "REGIE_SON"
|
||||
assert slug_code(" apres midi ") == "APRES_MIDI"
|
||||
assert slug_code("SST") == "SST"
|
||||
@@ -1,48 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from app.importing.text import slug_code, split_skills
|
||||
from app.importing.values import parse_availability_cell
|
||||
from app.models import AvailabilityStatus
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cell", "expected"),
|
||||
[
|
||||
("x", AvailabilityStatus.AVAILABLE),
|
||||
("OUI", AvailabilityStatus.AVAILABLE),
|
||||
(" Disponible ", AvailabilityStatus.AVAILABLE),
|
||||
("si besoin", AvailabilityStatus.IF_NEEDED),
|
||||
("Peut-être", AvailabilityStatus.IF_NEEDED),
|
||||
("non", AvailabilityStatus.UNAVAILABLE),
|
||||
("0", AvailabilityStatus.UNAVAILABLE),
|
||||
],
|
||||
)
|
||||
def test_recognized_availability_values(cell, expected):
|
||||
status, recognized = parse_availability_cell(cell)
|
||||
assert recognized is True
|
||||
assert status is expected
|
||||
|
||||
|
||||
def test_empty_cell_is_recognized_but_no_declaration():
|
||||
assert parse_availability_cell(" ") == (None, True)
|
||||
assert parse_availability_cell("N/A") == (None, True)
|
||||
|
||||
|
||||
def test_unknown_cell_is_flagged():
|
||||
assert parse_availability_cell("bof") == (None, False)
|
||||
|
||||
|
||||
def test_slug_code_is_stable_across_accents_and_case():
|
||||
assert slug_code("Régie son") == "REGIE_SON"
|
||||
assert slug_code(" permis B ") == "PERMIS_B"
|
||||
assert slug_code("SST") == "SST"
|
||||
|
||||
|
||||
def test_split_skills():
|
||||
assert split_skills("SST, Permis B / régie son\nlangues") == [
|
||||
"SST",
|
||||
"Permis B",
|
||||
"régie son",
|
||||
"langues",
|
||||
]
|
||||
assert split_skills("") == []
|
||||
@@ -8,30 +8,24 @@ from app.models import (
|
||||
Assignment,
|
||||
AssignmentSource,
|
||||
Availability,
|
||||
AvailabilityStatus,
|
||||
DayPart,
|
||||
Edition,
|
||||
Position,
|
||||
PositionCategory,
|
||||
Shift,
|
||||
Skill,
|
||||
Volunteer,
|
||||
)
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
"edition",
|
||||
"day_part",
|
||||
"skill",
|
||||
"artist",
|
||||
"volunteer",
|
||||
"volunteer_skill",
|
||||
"volunteer_artist",
|
||||
"volunteer_position_wish",
|
||||
"account",
|
||||
"magic_link_token",
|
||||
"position",
|
||||
"shift",
|
||||
"shift_required_skill",
|
||||
"availability",
|
||||
"assignment",
|
||||
}
|
||||
@@ -65,8 +59,6 @@ def test_create_core_graph(session):
|
||||
first_name="Alex",
|
||||
last_name="Martin",
|
||||
)
|
||||
skill = Skill(edition_id=edition.id, code="SST", label="Secourisme")
|
||||
volunteer.skills.append(skill)
|
||||
position = Position(
|
||||
edition_id=edition.id, name="Bar principal", category=PositionCategory.BAR
|
||||
)
|
||||
@@ -76,9 +68,6 @@ def test_create_core_graph(session):
|
||||
session.refresh(volunteer)
|
||||
session.refresh(position)
|
||||
|
||||
# Lien N-N bénévole <-> compétence.
|
||||
assert [s.code for s in volunteer.skills] == ["SST"]
|
||||
|
||||
shift = Shift(
|
||||
edition_id=edition.id,
|
||||
position_id=position.id,
|
||||
@@ -107,37 +96,29 @@ def test_create_core_graph(session):
|
||||
assert volunteer.assignments[0].id == assignment.id
|
||||
|
||||
|
||||
def test_enum_round_trip(session):
|
||||
def test_availability_window_round_trip(session):
|
||||
edition = _edition(session)
|
||||
day_part = DayPart(
|
||||
edition_id=edition.id,
|
||||
code="EVENING",
|
||||
label="Soirée",
|
||||
start_time=dt.time(19, 0),
|
||||
end_time=dt.time(6, 0),
|
||||
order=2,
|
||||
)
|
||||
volunteer = Volunteer(
|
||||
edition_id=edition.id, first_name="Sam", last_name="Nuit"
|
||||
)
|
||||
session.add_all([day_part, volunteer])
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
session.refresh(day_part)
|
||||
session.refresh(volunteer)
|
||||
|
||||
avail = Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=dt.date(2026, 7, 9),
|
||||
day_part_id=day_part.id,
|
||||
status=AvailabilityStatus.IF_NEEDED,
|
||||
start_time=dt.time(19, 0),
|
||||
end_time=dt.time(2, 0), # passe minuit
|
||||
)
|
||||
session.add(avail)
|
||||
session.commit()
|
||||
session.expire_all()
|
||||
|
||||
reloaded = session.get(Availability, avail.id)
|
||||
assert reloaded.status is AvailabilityStatus.IF_NEEDED
|
||||
assert reloaded.start_time == dt.time(19, 0)
|
||||
assert reloaded.end_time == dt.time(2, 0)
|
||||
|
||||
|
||||
def test_volunteer_email_unique_per_edition(session):
|
||||
|
||||
@@ -3,9 +3,8 @@ règle, plus les cas limites de la sémantique figée (CLAUDE.md § 3)."""
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from app.models import AvailabilityStatus
|
||||
from app.validation import (
|
||||
AvailabilityDeclaration,
|
||||
AvailabilityWindow,
|
||||
DayPartWindow,
|
||||
Severity,
|
||||
ShiftInfo,
|
||||
@@ -26,18 +25,20 @@ def _dt(day: int, hour: int, minute: int = 0) -> dt.datetime:
|
||||
return dt.datetime(2026, 7, day, hour, minute)
|
||||
|
||||
|
||||
def _shift(sid: str, start: dt.datetime, end: dt.datetime, skills=()) -> ShiftInfo:
|
||||
return ShiftInfo(
|
||||
id=sid, starts_at=start, ends_at=end, required_skill_codes=frozenset(skills)
|
||||
)
|
||||
def _shift(sid: str, start: dt.datetime, end: dt.datetime) -> ShiftInfo:
|
||||
return ShiftInfo(id=sid, starts_at=start, ends_at=end)
|
||||
|
||||
|
||||
def _codes(violations) -> list[ViolationCode]:
|
||||
return [v.code for v in violations]
|
||||
|
||||
|
||||
def _avail(day: int, code: str, status: AvailabilityStatus) -> AvailabilityDeclaration:
|
||||
return AvailabilityDeclaration(dt.date(2026, 7, day), code, status)
|
||||
def _win(day: int, start_h: int, end_h: int) -> AvailabilityWindow:
|
||||
"""Fenêtre de dispo le <day> juillet, de start_h:00 à end_h:00.
|
||||
`end_h <= start_h` ⇒ se termine le lendemain."""
|
||||
return AvailabilityWindow(
|
||||
dt.date(2026, 7, day), dt.time(start_h % 24, 0), dt.time(end_h % 24, 0)
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -47,10 +48,7 @@ def test_overlap_pass_when_shifts_are_adjacent():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(
|
||||
_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),
|
||||
),
|
||||
availabilities=(_win(10, 8, 23),),
|
||||
)
|
||||
assert ViolationCode.SHIFT_OVERLAP not in _codes(validate(data))
|
||||
|
||||
@@ -59,6 +57,7 @@ def test_overlap_triggers_and_is_blocking():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),),
|
||||
availabilities=(_win(10, 8, 23),),
|
||||
)
|
||||
violations = validate(data)
|
||||
overlap = [v for v in violations if v.code is ViolationCode.SHIFT_OVERLAP]
|
||||
@@ -71,73 +70,68 @@ def test_overlap_triggers_and_is_blocking():
|
||||
# --------------------------------------------------------------------------- #
|
||||
# OUTSIDE_AVAILABILITY #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_availability_pass_when_declared_available():
|
||||
def test_availability_pass_when_shift_within_declared_window():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
|
||||
availabilities=(_win(10, 13, 19),),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_availability_pass_when_declared_if_needed():
|
||||
def test_availability_pass_when_window_exactly_matches_shift():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.IF_NEEDED),),
|
||||
availabilities=(_win(10, 14, 18),),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_availability_triggers_when_slot_unavailable():
|
||||
def test_availability_triggers_when_nothing_declared():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.UNAVAILABLE),),
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert violations[0].severity is Severity.WARNING
|
||||
assert violations[0].params["status"] == "UNAVAILABLE"
|
||||
assert violations[0].params["day_part_code"] == "AFTERNOON"
|
||||
assert violations[0].params["date"] == "2026-07-10"
|
||||
assert violations[0].params["uncovered_minutes"] == 240
|
||||
|
||||
|
||||
def test_availability_triggers_when_slot_undeclared():
|
||||
def test_availability_triggers_on_partial_coverage_and_counts_the_gap():
|
||||
# Créneau 16:00 -> 20:00 ; dispo seulement 13:00 -> 19:00 ⇒ 60 min à découvert.
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
day_parts=DAY_PARTS,
|
||||
candidate_shift=_shift("c", _dt(10, 16), _dt(10, 20)),
|
||||
availabilities=(_win(10, 13, 19),),
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert violations[0].params["status"] == "UNDECLARED"
|
||||
assert violations[0].params["uncovered_minutes"] == 60
|
||||
|
||||
|
||||
def test_availability_covers_two_slots_and_flags_only_the_missing_one():
|
||||
# 18:00 -> 22:00 recoupe AFTERNOON (jusqu'à 19:00) et EVENING (dès 19:00).
|
||||
def test_availability_union_of_several_windows_covers_the_shift():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 18), _dt(10, 22)),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
|
||||
)
|
||||
violations = validate(data)
|
||||
assert len(violations) == 1
|
||||
assert violations[0].params["day_part_code"] == "EVENING"
|
||||
|
||||
|
||||
def test_availability_midnight_crossing_shift_uses_evening_slot_only():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 22), _dt(11, 2)),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_avail(10, "EVENING", AvailabilityStatus.AVAILABLE),),
|
||||
candidate_shift=_shift("c", _dt(10, 12), _dt(10, 20)),
|
||||
availabilities=(_win(10, 12, 16), _win(10, 15, 20)),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_availability_rule_skipped_without_day_parts():
|
||||
def test_availability_midnight_crossing_window_covers_night_shift():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
candidate_shift=_shift("c", _dt(10, 22), _dt(11, 2)),
|
||||
availabilities=(_win(10, 19, 4),), # 19:00 -> 04:00 le lendemain
|
||||
)
|
||||
assert ViolationCode.OUTSIDE_AVAILABILITY not in _codes(validate(data))
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_availability_gap_between_two_windows_is_flagged():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 18)),
|
||||
availabilities=(_win(10, 10, 12), _win(10, 14, 18)), # trou 12:00-14:00
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert violations[0].params["uncovered_minutes"] == 120
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -147,10 +141,7 @@ def test_daily_hours_pass_at_exactly_eight():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h pile
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(
|
||||
_avail(10, "MORNING", AvailabilityStatus.AVAILABLE),
|
||||
_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),
|
||||
),
|
||||
availabilities=(_win(10, 8, 18),),
|
||||
)
|
||||
assert ViolationCode.DAILY_HOURS_EXCEEDED not in _codes(validate(data))
|
||||
|
||||
@@ -200,8 +191,7 @@ def test_rest_pass_with_more_than_eight_hours_gap():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 8), _dt(10, 12)),
|
||||
existing_shifts=(_shift("e", _dt(9, 18), _dt(9, 23)),), # écart 9 h
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_avail(10, "MORNING", AvailabilityStatus.AVAILABLE),),
|
||||
availabilities=(_win(10, 8, 12),),
|
||||
)
|
||||
assert ViolationCode.INSUFFICIENT_REST not in _codes(validate(data))
|
||||
|
||||
@@ -243,81 +233,45 @@ def test_back_to_back_shifts_are_rest_violation_not_overlap():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),),
|
||||
availabilities=(_win(10, 8, 23),),
|
||||
)
|
||||
codes = _codes(validate(data))
|
||||
assert ViolationCode.SHIFT_OVERLAP not in codes
|
||||
assert ViolationCode.INSUFFICIENT_REST in codes
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# MISSING_REQUIRED_SKILL #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_skill_pass_when_volunteer_has_all_required():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18), skills=["SST"]),
|
||||
volunteer_skill_codes=frozenset({"SST", "PERMIS_B"}),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_skill_triggers_for_each_missing_code_sorted():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift(
|
||||
"c", _dt(10, 14), _dt(10, 18), skills=["SOUND", "SST", "PERMIS_B"]
|
||||
),
|
||||
volunteer_skill_codes=frozenset({"SST"}),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
|
||||
)
|
||||
violations = validate(data)
|
||||
assert [v.params["skill_code"] for v in violations] == ["PERMIS_B", "SOUND"]
|
||||
assert all(v.severity is Severity.WARNING for v in violations)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Combinaisons #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_clean_assignment_has_no_violation():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18), skills=["SST"]),
|
||||
volunteer_skill_codes=frozenset({"SST"}),
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
existing_shifts=(_shift("e", _dt(9, 8), _dt(9, 12)),),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
|
||||
availabilities=(_win(10, 13, 19),),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_multiple_rules_fire_together():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift(
|
||||
"c", _dt(10, 14), _dt(10, 18), skills=["SOUND"]
|
||||
),
|
||||
volunteer_skill_codes=frozenset(),
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),), # chevauchement
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(
|
||||
_avail(10, "AFTERNOON", AvailabilityStatus.UNAVAILABLE),
|
||||
),
|
||||
# aucune dispo déclarée ⇒ OUTSIDE_AVAILABILITY
|
||||
)
|
||||
codes = set(_codes(validate(data)))
|
||||
assert {
|
||||
ViolationCode.SHIFT_OVERLAP,
|
||||
ViolationCode.OUTSIDE_AVAILABILITY,
|
||||
ViolationCode.MISSING_REQUIRED_SKILL,
|
||||
} <= codes
|
||||
assert has_blocking(validate(data)) is True
|
||||
|
||||
|
||||
def test_only_warnings_is_not_blocking():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18), skills=["SOUND"]),
|
||||
volunteer_skill_codes=frozenset(),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
# pas de dispo ⇒ un seul avertissement OUTSIDE_AVAILABILITY
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.MISSING_REQUIRED_SKILL]
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert has_blocking(violations) is False
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime as dt
|
||||
|
||||
from app.models import Assignment, AvailabilityStatus
|
||||
from app.validation import AvailabilityDeclaration
|
||||
from app.models import Assignment
|
||||
from app.validation import AvailabilityWindow
|
||||
from app.validation.loader import build_validation_input
|
||||
from tests.factories import (
|
||||
dt_at,
|
||||
@@ -15,18 +15,16 @@ from tests.factories import (
|
||||
def test_build_input_gathers_everything_from_db(client, edition, session, day_parts):
|
||||
position = make_position(session, edition)
|
||||
candidate = make_shift(
|
||||
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18),
|
||||
skills=["SST"],
|
||||
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18)
|
||||
)
|
||||
other_shift = make_shift(
|
||||
session, edition, position, start=dt_at(11, 9), end=dt_at(11, 12)
|
||||
)
|
||||
volunteer = make_volunteer(
|
||||
session, edition, email="v@ex.fr", skills=["SST", "PERMIS_B"]
|
||||
)
|
||||
volunteer = make_volunteer(session, edition, email="v@ex.fr")
|
||||
make_availability(
|
||||
session, edition, volunteer, day_parts["AFTERNOON"],
|
||||
date=dt.date(2026, 7, 10), status=AvailabilityStatus.IF_NEEDED,
|
||||
session, edition, volunteer,
|
||||
date=dt.date(2026, 7, 10),
|
||||
start_time=dt.time(13, 0), end_time=dt.time(19, 0),
|
||||
)
|
||||
session.add(
|
||||
Assignment(
|
||||
@@ -41,15 +39,11 @@ def test_build_input_gathers_everything_from_db(client, edition, session, day_pa
|
||||
)
|
||||
|
||||
assert data.candidate_shift.id == str(candidate.id)
|
||||
assert data.candidate_shift.required_skill_codes == frozenset({"SST"})
|
||||
assert data.volunteer_skill_codes == frozenset({"SST", "PERMIS_B"})
|
||||
# Toute affectation existante compte, sauf le créneau candidat lui-même.
|
||||
assert [s.id for s in data.existing_shifts] == [str(other_shift.id)]
|
||||
assert len(data.day_parts) == 3
|
||||
assert data.availabilities == (
|
||||
AvailabilityDeclaration(
|
||||
dt.date(2026, 7, 10), "AFTERNOON", AvailabilityStatus.IF_NEEDED
|
||||
),
|
||||
AvailabilityWindow(dt.date(2026, 7, 10), dt.time(13, 0), dt.time(19, 0)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user