diff --git a/CLAUDE.md b/CLAUDE.md index 8123925..9e08ae1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,10 +187,12 @@ pointage sur site · fonctions d'IA. │ │ ├── config.py # settings (pydantic-settings) │ │ ├── db.py # engine + session │ │ ├── logging_config.py # RGPD : pas de PII dans les logs -│ │ ├── api/ # routers (health, imports) +│ │ ├── api/ # routers + schemas Pydantic + deps +│ │ │ # health, imports, volunteers, positions, +│ │ │ # shifts, assignments, matrix │ │ ├── importing/ # import CSV/Excel (parser, values, service) │ │ ├── models/ # tables SQLModel -│ │ ├── validation/ # moteur de règles pur (models, engine) +│ │ ├── validation/ # moteur pur (models, engine) + loader (base) │ │ ├── i18n/ │ │ │ ├── __init__.py # t(key, **params) │ │ │ └── locales/fr.json @@ -263,12 +265,9 @@ Aucune dépendance locale requise hors Docker. | 1 | Squelette : monorepo, Docker, CI/CD, backend qui démarre | validée | | 2 | Modèle de données complet + migration Alembic initiale | validée | | 3 | Import CSV/Excel (analyse + exécution, rapport, doublons, simulation) | validée | -| 4 | Moteur de validation des affectations (pur, TDD, 5 règles) | à valider | +| 4 | Moteur de validation des affectations (pur, TDD, 5 règles) | validée | +| 5 | API CRUD (Position, Shift, Volunteer, Assignment) + matrice + validation branchée | à valider | > **En attente** : la couche d'authentification (lien magique, `Account`, > JWT, dépendances de rôle) fera l'objet d'une étape dédiée. Les endpoints > actuels ne sont pas encore protégés. -> -> **À brancher plus tard** : `app.validation.validate()` sera appelé par l'API -> CRUD des affectations (étape 5) via un chargeur qui construit la -> `ValidationInput` depuis la base. diff --git a/backend/app/api/_common.py b/backend/app/api/_common.py new file mode 100644 index 0000000..60fe4b8 --- /dev/null +++ b/backend/app/api/_common.py @@ -0,0 +1,55 @@ +"""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 + + +def owned_or_404(instance, edition_id: uuid.UUID): + """404 si l'instance est absente ou rattachée à une autre édition.""" + if instance is None or instance.edition_id != edition_id: + raise HTTPException(status_code=404, detail={"code": "error.not_found"}) + 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: + """Remplace toutes les lignes d'une table d'association pour une clé.""" + session.execute( + delete(link_model).where(filter_column == filter_value) + ) + for row in pairs: + session.add(link_model(**row)) diff --git a/backend/app/api/assignments.py b/backend/app/api/assignments.py new file mode 100644 index 0000000..5788e3a --- /dev/null +++ b/backend/app/api/assignments.py @@ -0,0 +1,207 @@ +"""CRUD des affectations. Toute écriture passe par le moteur de validation : +une violation bloquante interdit l'enregistrement, les avertissements sont +renvoyés.""" + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Response, status +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 ( + AssignmentCreate, + AssignmentRead, + AssignmentUpdate, + AssignmentValidateRequest, + AssignmentValidateResult, + AssignmentWriteResult, +) +from app.db import get_session +from app.models import ( + ACTIVE_ASSIGNMENT_STATUSES, + Assignment, + Edition, + Shift, + Volunteer, +) +from app.validation import has_blocking, validate +from app.validation.loader import build_validation_input + +router = APIRouter( + prefix="/editions/{edition_id}/assignments", tags=["assignments"] +) + + +def _pair_or_422( + session: Session, edition_id: uuid.UUID, volunteer_id: uuid.UUID, shift_id: uuid.UUID +) -> tuple[Volunteer, Shift]: + volunteer = session.get(Volunteer, volunteer_id) + shift = session.get(Shift, shift_id) + if ( + volunteer is None + or shift is None + or volunteer.edition_id != edition_id + or shift.edition_id != edition_id + ): + raise HTTPException( + status_code=422, detail={"code": "assignment.wrong_edition"} + ) + return volunteer, shift + + +@router.post( + "/validate", response_model=AssignmentValidateResult, tags=["assignments"] +) +def validate_assignment( + payload: AssignmentValidateRequest, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> AssignmentValidateResult: + volunteer, shift = _pair_or_422( + session, edition.id, payload.volunteer_id, payload.shift_id + ) + violations = validate( + build_validation_input( + session=session, edition=edition, volunteer=volunteer, shift=shift + ) + ) + return AssignmentValidateResult( + blocking=has_blocking(violations), violations=violations + ) + + +@router.post( + "", response_model=AssignmentWriteResult, status_code=status.HTTP_201_CREATED +) +def create_assignment( + payload: AssignmentCreate, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> AssignmentWriteResult: + volunteer, shift = _pair_or_422( + session, edition.id, payload.volunteer_id, payload.shift_id + ) + existing = session.exec( + select(Assignment) + .where(Assignment.volunteer_id == volunteer.id) + .where(Assignment.shift_id == shift.id) + ).first() + if existing is not None: + raise HTTPException( + status_code=409, detail={"code": "assignment.duplicate"} + ) + + # La validation ne s'applique qu'aux affectations actives : créer + # directement une affectation DECLINEE ou ANNULEE n'a rien à valider. + violations: list = [] + if payload.status in ACTIVE_ASSIGNMENT_STATUSES: + violations = validate( + build_validation_input( + session=session, + edition=edition, + volunteer=volunteer, + shift=shift, + ) + ) + if has_blocking(violations): + raise HTTPException( + status_code=422, + detail={ + "code": "error.validation_failed", + "violations": [v.model_dump() for v in violations], + }, + ) + + assignment = Assignment( + edition_id=edition.id, + volunteer_id=volunteer.id, + shift_id=shift.id, + status=payload.status, + source=payload.source, + locked=payload.locked, + ) + session.add(assignment) + session.commit() + session.refresh(assignment) + return AssignmentWriteResult(assignment=assignment, warnings=violations) + + +@router.get("", response_model=list[AssignmentRead]) +def list_assignments( + volunteer_id: uuid.UUID | None = None, + shift_id: uuid.UUID | None = None, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> list[Assignment]: + stmt = select(Assignment).where(Assignment.edition_id == edition.id) + if volunteer_id is not None: + stmt = stmt.where(Assignment.volunteer_id == volunteer_id) + if shift_id is not None: + stmt = stmt.where(Assignment.shift_id == shift_id) + return list(session.exec(stmt)) + + +@router.get("/{assignment_id}", response_model=AssignmentRead) +def get_assignment( + assignment_id: uuid.UUID, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> Assignment: + return owned_or_404(session.get(Assignment, assignment_id), edition.id) + + +@router.patch("/{assignment_id}", response_model=AssignmentWriteResult) +def update_assignment( + assignment_id: uuid.UUID, + payload: AssignmentUpdate, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> AssignmentWriteResult: + assignment = owned_or_404( + session.get(Assignment, assignment_id), edition.id + ) + changes = payload.model_dump(exclude_unset=True) + for field, value in changes.items(): + setattr(assignment, field, value) + + warnings: list = [] + if assignment.status in ACTIVE_ASSIGNMENT_STATUSES: + volunteer = session.get(Volunteer, assignment.volunteer_id) + shift = session.get(Shift, assignment.shift_id) + warnings = validate( + build_validation_input( + session=session, + edition=edition, + volunteer=volunteer, + shift=shift, + exclude_assignment_id=assignment.id, + ) + ) + if has_blocking(warnings): + session.rollback() + raise HTTPException( + status_code=422, + detail={ + "code": "error.validation_failed", + "violations": [v.model_dump() for v in warnings], + }, + ) + + session.commit() + session.refresh(assignment) + return AssignmentWriteResult(assignment=assignment, warnings=warnings) + + +@router.delete("/{assignment_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_assignment( + assignment_id: uuid.UUID, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> Response: + assignment = owned_or_404( + session.get(Assignment, assignment_id), edition.id + ) + session.delete(assignment) + session.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..ca1e98c --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,20 @@ +"""Dépendances FastAPI partagées par les routers.""" + +import uuid + +from fastapi import Depends, HTTPException +from sqlmodel import Session + +from app.db import get_session +from app.models import Edition + + +def get_edition( + edition_id: uuid.UUID, session: Session = Depends(get_session) +) -> Edition: + edition = session.get(Edition, edition_id) + if edition is None: + raise HTTPException( + status_code=404, detail={"code": "error.not_found"} + ) + return edition diff --git a/backend/app/api/matrix.py b/backend/app/api/matrix.py new file mode 100644 index 0000000..5788a3a --- /dev/null +++ b/backend/app/api/matrix.py @@ -0,0 +1,75 @@ +"""Matrice poste × créneau d'une édition : source de l'écran principal.""" + +from fastapi import APIRouter, Depends +from sqlalchemy import func +from sqlmodel import Session, select + +from app.api.deps import get_edition +from app.api.schemas import MatrixPosition, MatrixResponse, MatrixShift +from app.db import get_session +from app.models import Assignment, AssignmentStatus, Edition, Position, Shift + +router = APIRouter(prefix="/editions/{edition_id}/matrix", tags=["matrix"]) + + +@router.get("", response_model=MatrixResponse) +def get_matrix( + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> MatrixResponse: + positions = list( + session.exec( + select(Position) + .where(Position.edition_id == edition.id) + .order_by(Position.name) + ) + ) + shifts = list( + session.exec( + select(Shift) + .where(Shift.edition_id == edition.id) + .order_by(Shift.starts_at) + ) + ) + counts = { + (shift_id, status): total + for shift_id, status, total in session.exec( + select( + Assignment.shift_id, Assignment.status, func.count() + ) + .where(Assignment.edition_id == edition.id) + .group_by(Assignment.shift_id, Assignment.status) + ) + } + + shifts_by_position: dict = {} + for shift in shifts: + shifts_by_position.setdefault(shift.position_id, []).append( + MatrixShift( + shift_id=shift.id, + starts_at=shift.starts_at, + ends_at=shift.ends_at, + min_staff=shift.min_staff, + ideal_staff=shift.ideal_staff, + confirmed=counts.get( + (shift.id, AssignmentStatus.CONFIRMED), 0 + ), + proposed=counts.get( + (shift.id, AssignmentStatus.PROPOSED), 0 + ), + ) + ) + + return MatrixResponse( + edition_id=edition.id, + positions=[ + MatrixPosition( + id=position.id, + name=position.name, + category=position.category, + location=position.location, + shifts=shifts_by_position.get(position.id, []), + ) + for position in positions + ], + ) diff --git a/backend/app/api/positions.py b/backend/app/api/positions.py new file mode 100644 index 0000000..31d399e --- /dev/null +++ b/backend/app/api/positions.py @@ -0,0 +1,79 @@ +"""CRUD des postes.""" + +import uuid + +from fastapi import APIRouter, Depends, Response, status +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 PositionCreate, PositionRead, PositionUpdate +from app.db import get_session +from app.models import Edition, Position + +router = APIRouter( + prefix="/editions/{edition_id}/positions", tags=["positions"] +) + + +@router.post("", response_model=PositionRead, status_code=status.HTTP_201_CREATED) +def create_position( + payload: PositionCreate, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> Position: + position = Position(edition_id=edition.id, **payload.model_dump()) + session.add(position) + session.commit() + session.refresh(position) + return position + + +@router.get("", response_model=list[PositionRead]) +def list_positions( + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> list[Position]: + return list( + session.exec( + select(Position) + .where(Position.edition_id == edition.id) + .order_by(Position.name) + ) + ) + + +@router.get("/{position_id}", response_model=PositionRead) +def get_position( + position_id: uuid.UUID, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> Position: + return owned_or_404(session.get(Position, position_id), edition.id) + + +@router.patch("/{position_id}", response_model=PositionRead) +def update_position( + position_id: uuid.UUID, + payload: PositionUpdate, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> Position: + position = owned_or_404(session.get(Position, position_id), edition.id) + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(position, field, value) + session.commit() + session.refresh(position) + return position + + +@router.delete("/{position_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_position( + position_id: uuid.UUID, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> Response: + position = owned_or_404(session.get(Position, position_id), edition.id) + session.delete(position) + session.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py new file mode 100644 index 0000000..b4fe5be --- /dev/null +++ b/backend/app/api/schemas.py @@ -0,0 +1,192 @@ +"""Schémas d'entrée/sortie de l'API CRUD et de la matrice.""" + +import datetime as dt +import uuid + +from pydantic import BaseModel, ConfigDict, Field + +from app.models import ( + AssignmentSource, + AssignmentStatus, + PositionCategory, + TshirtSize, +) +from app.validation import Violation + + +class ApiModel(BaseModel): + """Base commune : autorise la construction depuis un objet ORM.""" + + model_config = ConfigDict(from_attributes=True) + +# --------------------------------------------------------------------------- # +# Volunteer # +# --------------------------------------------------------------------------- # +class VolunteerCreate(ApiModel): + first_name: str + last_name: str + email: str | None = None + phone: str | None = None + tshirt_size: TshirtSize | None = None + diet: str | None = None + comment: str | None = None + skill_codes: list[str] = Field(default_factory=list) + + +class VolunteerUpdate(ApiModel): + first_name: str | None = None + last_name: str | None = None + email: str | None = None + phone: str | None = None + tshirt_size: TshirtSize | None = None + diet: str | None = None + comment: str | None = None + # None = inchangé ; [] = vider les compétences. + skill_codes: list[str] | None = None + + +class VolunteerRead(ApiModel): + id: uuid.UUID + edition_id: uuid.UUID + first_name: str + last_name: str + email: str | None + phone: str | None + tshirt_size: TshirtSize | None + diet: str | None + comment: str | None + skill_codes: list[str] + created_at: dt.datetime + updated_at: dt.datetime + + +# --------------------------------------------------------------------------- # +# Position # +# --------------------------------------------------------------------------- # +class PositionCreate(ApiModel): + name: str + location: str | None = None + category: PositionCategory + lead_account_id: uuid.UUID | None = None + + +class PositionUpdate(ApiModel): + name: str | None = None + location: str | None = None + category: PositionCategory | None = None + lead_account_id: uuid.UUID | None = None + + +class PositionRead(ApiModel): + id: uuid.UUID + edition_id: uuid.UUID + name: str + location: str | None + category: PositionCategory + lead_account_id: uuid.UUID | None + created_at: dt.datetime + updated_at: dt.datetime + + +# --------------------------------------------------------------------------- # +# Shift # +# --------------------------------------------------------------------------- # +class ShiftCreate(ApiModel): + position_id: uuid.UUID + starts_at: dt.datetime + 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): + position_id: uuid.UUID | None = None + starts_at: dt.datetime | None = None + 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): + id: uuid.UUID + edition_id: uuid.UUID + position_id: uuid.UUID + starts_at: dt.datetime + ends_at: dt.datetime + min_staff: int + ideal_staff: int + required_skill_codes: list[str] + created_at: dt.datetime + updated_at: dt.datetime + + +# --------------------------------------------------------------------------- # +# Assignment # +# --------------------------------------------------------------------------- # +class AssignmentCreate(ApiModel): + volunteer_id: uuid.UUID + shift_id: uuid.UUID + status: AssignmentStatus = AssignmentStatus.PROPOSED + source: AssignmentSource = AssignmentSource.MANUAL + locked: bool = False + + +class AssignmentUpdate(ApiModel): + status: AssignmentStatus | None = None + source: AssignmentSource | None = None + locked: bool | None = None + + +class AssignmentRead(ApiModel): + id: uuid.UUID + edition_id: uuid.UUID + volunteer_id: uuid.UUID + shift_id: uuid.UUID + status: AssignmentStatus + source: AssignmentSource + locked: bool + created_at: dt.datetime + updated_at: dt.datetime + + +class AssignmentWriteResult(ApiModel): + assignment: AssignmentRead + warnings: list[Violation] = Field(default_factory=list) + + +class AssignmentValidateRequest(ApiModel): + volunteer_id: uuid.UUID + shift_id: uuid.UUID + + +class AssignmentValidateResult(ApiModel): + blocking: bool + violations: list[Violation] = Field(default_factory=list) + + +# --------------------------------------------------------------------------- # +# Matrice poste × créneau # +# --------------------------------------------------------------------------- # +class MatrixShift(ApiModel): + shift_id: uuid.UUID + starts_at: dt.datetime + ends_at: dt.datetime + min_staff: int + ideal_staff: int + confirmed: int + proposed: int + + +class MatrixPosition(ApiModel): + id: uuid.UUID + name: str + category: PositionCategory + location: str | None + shifts: list[MatrixShift] + + +class MatrixResponse(ApiModel): + edition_id: uuid.UUID + positions: list[MatrixPosition] diff --git a/backend/app/api/shifts.py b/backend/app/api/shifts.py new file mode 100644 index 0000000..26f0ca1 --- /dev/null +++ b/backend/app/api/shifts.py @@ -0,0 +1,141 @@ +"""CRUD des créneaux.""" + +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.deps import get_edition +from app.api.schemas import ShiftCreate, ShiftRead, ShiftUpdate +from app.db import get_session +from app.models import Edition, Position, Shift, ShiftRequiredSkill, Skill + +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 _check_range(starts_at, ends_at) -> None: + if ends_at <= starts_at: + raise HTTPException( + status_code=422, + detail={"code": "shift.ends_not_after_starts"}, + ) + + +def _require_position( + session: Session, position_id: uuid.UUID, edition_id: uuid.UUID +) -> Position: + position = session.get(Position, position_id) + if position is None or position.edition_id != edition_id: + raise HTTPException( + status_code=422, detail={"code": "shift.position_wrong_edition"} + ) + return position + + +@router.post("", response_model=ShiftRead, status_code=status.HTTP_201_CREATED) +def create_shift( + payload: ShiftCreate, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> ShiftRead: + _check_range(payload.starts_at, payload.ends_at) + _require_position(session, payload.position_id, edition.id) + shift = Shift( + edition_id=edition.id, + **payload.model_dump(exclude={"required_skill_codes"}), + ) + session.add(shift) + session.flush() + _set_skills(session, shift, payload.required_skill_codes) + session.commit() + session.refresh(shift) + return _read(session, shift) + + +@router.get("", response_model=list[ShiftRead]) +def list_shifts( + position_id: uuid.UUID | None = None, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> list[ShiftRead]: + stmt = ( + select(Shift) + .where(Shift.edition_id == edition.id) + .order_by(Shift.starts_at) + ) + if position_id is not None: + stmt = stmt.where(Shift.position_id == position_id) + return [_read(session, s) for s in session.exec(stmt)] + + +@router.get("/{shift_id}", response_model=ShiftRead) +def get_shift( + shift_id: uuid.UUID, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> ShiftRead: + shift = owned_or_404(session.get(Shift, shift_id), edition.id) + return _read(session, shift) + + +@router.patch("/{shift_id}", response_model=ShiftRead) +def update_shift( + shift_id: uuid.UUID, + payload: ShiftUpdate, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> ShiftRead: + shift = owned_or_404(session.get(Shift, shift_id), edition.id) + changes = payload.model_dump(exclude_unset=True) + skill_codes = changes.pop("required_skill_codes", None) + if "position_id" in changes: + _require_position(session, changes["position_id"], edition.id) + 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) + + +@router.delete("/{shift_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_shift( + shift_id: uuid.UUID, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> Response: + shift = owned_or_404(session.get(Shift, shift_id), edition.id) + session.delete(shift) + session.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/api/volunteers.py b/backend/app/api/volunteers.py new file mode 100644 index 0000000..008939e --- /dev/null +++ b/backend/app/api/volunteers.py @@ -0,0 +1,136 @@ +"""CRUD des bénévoles. La suppression est un effacement complet (RGPD).""" + +import uuid + +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.deps import get_edition +from app.api.schemas import VolunteerCreate, VolunteerRead, VolunteerUpdate +from app.db import get_session +from app.models import Edition, Skill, Volunteer, VolunteerSkill + +router = APIRouter( + prefix="/editions/{edition_id}/volunteers", tags=["volunteers"] +) + + +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 _read(session: Session, volunteer: Volunteer) -> VolunteerRead: + return VolunteerRead( + **volunteer.model_dump(), + skill_codes=_skill_codes(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 + ], + ) + + +@router.post("", response_model=VolunteerRead, status_code=status.HTTP_201_CREATED) +def create_volunteer( + payload: VolunteerCreate, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> VolunteerRead: + volunteer = Volunteer( + edition_id=edition.id, + **payload.model_dump(exclude={"skill_codes"}), + ) + session.add(volunteer) + try: + session.flush() + except IntegrityError as exc: + session.rollback() + raise HTTPException( + status_code=409, detail={"code": "volunteer.duplicate_email"} + ) from exc + _set_skills(session, volunteer, payload.skill_codes) + session.commit() + session.refresh(volunteer) + return _read(session, volunteer) + + +@router.get("", response_model=list[VolunteerRead]) +def list_volunteers( + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> list[VolunteerRead]: + volunteers = session.exec( + select(Volunteer) + .where(Volunteer.edition_id == edition.id) + .order_by(Volunteer.last_name, Volunteer.first_name) + ) + return [_read(session, v) for v in volunteers] + + +@router.get("/{volunteer_id}", response_model=VolunteerRead) +def get_volunteer( + volunteer_id: uuid.UUID, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> VolunteerRead: + volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id) + return _read(session, volunteer) + + +@router.patch("/{volunteer_id}", response_model=VolunteerRead) +def update_volunteer( + volunteer_id: uuid.UUID, + payload: VolunteerUpdate, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> 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) + for field, value in changes.items(): + setattr(volunteer, field, value) + try: + session.flush() + except IntegrityError as exc: + session.rollback() + raise HTTPException( + status_code=409, detail={"code": "volunteer.duplicate_email"} + ) from exc + if skill_codes is not None: + _set_skills(session, volunteer, skill_codes) + session.commit() + session.refresh(volunteer) + return _read(session, volunteer) + + +@router.delete("/{volunteer_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_volunteer( + volunteer_id: uuid.UUID, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> 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. + session.delete(volunteer) + session.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index 0ee1fca..2ad0f18 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -4,6 +4,12 @@ "error.forbidden": "Accès refusé", "error.unauthorized": "Authentification requise", "error.validation_failed": "La validation a échoué", + "error.conflict": "Conflit avec une ressource existante.", + "shift.ends_not_after_starts": "La fin du créneau doit être postérieure à son début.", + "shift.position_wrong_edition": "Le poste n'appartient pas à cette édition.", + "volunteer.duplicate_email": "Un bénévole avec cet email existe déjà dans l'édition.", + "assignment.wrong_edition": "Le bénévole ou le créneau n'appartient pas à cette édition.", + "assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau.", "import.error.empty_file": "Le fichier est vide.", "import.error.encoding": "L'encodage du fichier n'est pas reconnu.", diff --git a/backend/app/main.py b/backend/app/main.py index de627f2..109ed9a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,8 +1,13 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from app.api.assignments import router as assignment_router from app.api.health import router as health_router from app.api.imports import router as import_router +from app.api.matrix import router as matrix_router +from app.api.positions import router as position_router +from app.api.shifts import router as shift_router +from app.api.volunteers import router as volunteer_router from app.config import get_settings from app.logging_config import configure_logging @@ -22,3 +27,8 @@ app.add_middleware( app.include_router(health_router) app.include_router(import_router) +app.include_router(volunteer_router) +app.include_router(position_router) +app.include_router(shift_router) +app.include_router(assignment_router) +app.include_router(matrix_router) diff --git a/backend/app/validation/loader.py b/backend/app/validation/loader.py new file mode 100644 index 0000000..c77c005 --- /dev/null +++ b/backend/app/validation/loader.py @@ -0,0 +1,111 @@ +"""Construit une `ValidationInput` depuis la base pour un couple (bénévole, +créneau). Couche de liaison : c'est le seul endroit du module `validation` qui +connaît la base.""" + +import uuid + +from sqlmodel import Session, select + +from app.models import ( + ACTIVE_ASSIGNMENT_STATUSES, + Assignment, + Availability, + DayPart, + Edition, + Shift, + ShiftRequiredSkill, + Skill, + Volunteer, + VolunteerSkill, +) +from app.validation.models import ( + AvailabilityDeclaration, + 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, + edition: Edition, + volunteer: Volunteer, + shift: Shift, + exclude_assignment_id: uuid.UUID | None = None, +) -> ValidationInput: + 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} + + stmt = ( + select(Shift) + .join(Assignment, Assignment.shift_id == Shift.id) + .where(Assignment.volunteer_id == volunteer.id) + .where( + Assignment.status.in_(tuple(ACTIVE_ASSIGNMENT_STATUSES)) + ) + .where(Shift.id != shift.id) + ) + if exclude_assignment_id is not None: + stmt = stmt.where(Assignment.id != exclude_assignment_id) + existing_shifts = tuple( + ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at) + for s in session.exec(stmt) + ) + + declarations = tuple( + AvailabilityDeclaration( + date=a.date, + day_part_code=code_by_day_part_id.get(a.day_part_id, ""), + status=a.status, + ) + for a in session.exec( + select(Availability).where( + Availability.volunteer_id == volunteer.id + ) + ) + ) + + return ValidationInput( + candidate_shift=ShiftInfo( + 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( + DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order) + for dp in day_parts + ), + ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1586ca2..a0e0d8f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -2,7 +2,7 @@ import datetime as dt import pytest from fastapi.testclient import TestClient -from sqlmodel import Session, SQLModel, create_engine +from sqlmodel import Session, SQLModel, create_engine, select from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata) from app.config import get_settings @@ -75,3 +75,11 @@ def edition(session) -> Edition: ) session.commit() return ed + + +@pytest.fixture +def day_parts(session, edition) -> dict[str, DayPart]: + parts = session.exec( + select(DayPart).where(DayPart.edition_id == edition.id) + ) + return {p.code: p for p in parts} diff --git a/backend/tests/factories.py b/backend/tests/factories.py new file mode 100644 index 0000000..22178b6 --- /dev/null +++ b/backend/tests/factories.py @@ -0,0 +1,118 @@ +"""Fabriques d'objets du domaine pour les tests (écriture directe en base).""" + +import datetime as dt + +from app.models import ( + Availability, + AvailabilityStatus, + Position, + PositionCategory, + Shift, + Skill, + Volunteer, + VolunteerSkill, +) + + +def make_position(session, edition, *, name="Bar", category=PositionCategory.BAR): + position = Position(edition_id=edition.id, name=name, category=category) + session.add(position) + session.commit() + session.refresh(position) + return position + + +def make_shift( + session, edition, position, *, start, end, min_staff=1, ideal_staff=2, skills=() +): + shift = Shift( + edition_id=edition.id, + position_id=position.id, + starts_at=start, + ends_at=end, + min_staff=min_staff, + 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=() +): + volunteer = Volunteer( + edition_id=edition.id, + first_name=first_name, + last_name=last_name, + 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): + row = Availability( + edition_id=edition.id, + volunteer_id=volunteer.id, + day_part_id=day_part.id, + date=date, + status=status, + ) + 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 dt_at(day: int, hour: int, minute: int = 0) -> dt.datetime: + return dt.datetime(2026, 7, day, hour, minute) diff --git a/backend/tests/test_api_assignments.py b/backend/tests/test_api_assignments.py new file mode 100644 index 0000000..f5232ae --- /dev/null +++ b/backend/tests/test_api_assignments.py @@ -0,0 +1,195 @@ +import datetime as dt + +from tests.factories import ( + declare_available_all_day, + dt_at, + make_position, + make_shift, + make_volunteer, +) + + +def _url(edition, suffix=""): + return f"/editions/{edition.id}/assignments{suffix}" + + +def _setup(session, edition, day_parts, *, available=True, skills=()): + 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) + if available: + declare_available_all_day( + session, edition, volunteer, day_parts, 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) + resp = client.post( + _url(edition), + json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)}, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["warnings"] == [] + assert body["assignment"]["status"] == "PROPOSED" + assert body["assignment"]["source"] == "MANUAL" + + +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"], + ) + resp = client.post( + _url(edition), + json={ + "volunteer_id": str(volunteer.id), + "shift_id": str(shift_with_skill.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) + assert client.post( + _url(edition), + json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)}, + ).status_code == 201 + + position = make_position(session, edition, name="Parking") + overlapping = make_shift( + session, edition, position, start=dt_at(10, 16), end=dt_at(10, 20) + ) + resp = client.post( + _url(edition), + json={ + "volunteer_id": str(volunteer.id), + "shift_id": str(overlapping.id), + }, + ) + assert resp.status_code == 422 + detail = resp.json()["detail"] + assert detail["code"] == "error.validation_failed" + assert any(v["code"] == "SHIFT_OVERLAP" for v in detail["violations"]) + # Rien n'a été enregistré pour ce créneau. + listed = client.get( + _url(edition), params={"shift_id": str(overlapping.id)} + ).json() + assert listed == [] + + +def test_create_duplicate_assignment_conflicts(client, edition, session, day_parts): + volunteer, shift = _setup(session, edition, day_parts) + 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) + assert resp.status_code == 409 + assert resp.json()["detail"]["code"] == "assignment.duplicate" + + +def test_assignment_wrong_edition_is_422(client, edition, session, day_parts): + from app.models import Edition + + other = Edition( + year=2027, start_date=dt.date(2027, 7, 1), end_date=dt.date(2027, 7, 4) + ) + session.add(other) + session.commit() + session.refresh(other) + stranger = make_volunteer(session, other, email="s@ex.fr") + _, shift = _setup(session, edition, day_parts) + + resp = client.post( + _url(edition), + json={"volunteer_id": str(stranger.id), "shift_id": str(shift.id)}, + ) + assert resp.status_code == 422 + assert resp.json()["detail"]["code"] == "assignment.wrong_edition" + + +def test_validate_endpoint_does_not_persist(client, edition, session, day_parts): + volunteer, shift = _setup(session, edition, day_parts, available=False) + resp = client.post( + _url(edition, "/validate"), + json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["blocking"] is False + assert any(v["code"] == "OUTSIDE_AVAILABILITY" for v in body["violations"]) + assert client.get(_url(edition)).json() == [] + + +def test_update_status_revalidates_and_returns_warnings( + client, edition, session, day_parts +): + volunteer, shift = _setup(session, edition, day_parts, available=False) + aid = client.post( + _url(edition), + json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)}, + ).json()["assignment"]["id"] + + resp = client.patch( + _url(edition, f"/{aid}"), json={"status": "CONFIRMED"} + ) + assert resp.status_code == 200 + assert resp.json()["assignment"]["status"] == "CONFIRMED" + assert any( + w["code"] == "OUTSIDE_AVAILABILITY" for w in resp.json()["warnings"] + ) + + +def test_cancelling_an_assignment_skips_validation( + client, edition, session, day_parts +): + volunteer, shift = _setup(session, edition, day_parts) + first = client.post( + _url(edition), + json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)}, + ).json()["assignment"]["id"] + + # Un second créneau qui chevauche, mais l'affectation est directement DECLINEE. + position = make_position(session, edition, name="X") + overlapping = make_shift( + session, edition, position, start=dt_at(10, 16), end=dt_at(10, 20) + ) + created = client.post( + _url(edition), + json={ + "volunteer_id": str(volunteer.id), + "shift_id": str(overlapping.id), + "status": "DECLINED", + }, + ) + assert created.status_code == 201 + + patched = client.patch( + _url(edition, f"/{first}"), json={"locked": True} + ) + assert patched.status_code == 200 + assert patched.json()["assignment"]["locked"] is True + + +def test_delete_assignment(client, edition, session, day_parts): + volunteer, shift = _setup(session, edition, day_parts) + aid = client.post( + _url(edition), + json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)}, + ).json()["assignment"]["id"] + assert client.delete(_url(edition, f"/{aid}")).status_code == 204 + assert client.get(_url(edition, f"/{aid}")).status_code == 404 diff --git a/backend/tests/test_api_matrix.py b/backend/tests/test_api_matrix.py new file mode 100644 index 0000000..2d95371 --- /dev/null +++ b/backend/tests/test_api_matrix.py @@ -0,0 +1,62 @@ + +from app.models import Assignment, AssignmentStatus +from tests.factories import dt_at, make_position, make_shift, make_volunteer + + +def test_matrix_shape_and_counts(client, edition, session): + bar = make_position(session, edition, name="Bar") + parking = make_position(session, edition, name="Parking") + + shift_a = make_shift( + session, edition, bar, start=dt_at(10, 18), end=dt_at(10, 22), + min_staff=2, ideal_staff=5, + ) + shift_b = make_shift( + session, edition, bar, start=dt_at(11, 18), end=dt_at(11, 22) + ) + make_shift( + session, edition, parking, start=dt_at(10, 8), end=dt_at(10, 12) + ) + + for i, status in enumerate( + [ + AssignmentStatus.CONFIRMED, + AssignmentStatus.CONFIRMED, + AssignmentStatus.PROPOSED, + AssignmentStatus.DECLINED, + ] + ): + volunteer = make_volunteer( + session, edition, first_name=f"V{i}", last_name="X", email=f"v{i}@ex.fr" + ) + session.add( + Assignment( + edition_id=edition.id, + volunteer_id=volunteer.id, + shift_id=shift_a.id, + status=status, + ) + ) + session.commit() + + body = client.get(f"/editions/{edition.id}/matrix").json() + assert body["edition_id"] == str(edition.id) + assert [p["name"] for p in body["positions"]] == ["Bar", "Parking"] + + bar_shifts = body["positions"][0]["shifts"] + assert [s["shift_id"] for s in bar_shifts] == [str(shift_a.id), str(shift_b.id)] + + first = bar_shifts[0] + assert first["confirmed"] == 2 + assert first["proposed"] == 1 # la DECLINEE n'est pas comptée + assert first["min_staff"] == 2 + assert first["ideal_staff"] == 5 + + assert bar_shifts[1]["confirmed"] == 0 + assert body["positions"][1]["shifts"][0]["proposed"] == 0 + + +def test_matrix_unknown_edition_is_404(client): + import uuid + + assert client.get(f"/editions/{uuid.uuid4()}/matrix").status_code == 404 diff --git a/backend/tests/test_api_positions.py b/backend/tests/test_api_positions.py new file mode 100644 index 0000000..4d51e7c --- /dev/null +++ b/backend/tests/test_api_positions.py @@ -0,0 +1,37 @@ +import uuid + + +def _url(edition, suffix=""): + return f"/editions/{edition.id}/positions{suffix}" + + +def test_position_crud_roundtrip(client, edition): + created = client.post( + _url(edition), + json={"name": "Billetterie", "category": "TICKETING", "location": "Entrée A"}, + ) + assert created.status_code == 201 + pid = created.json()["id"] + assert created.json()["category"] == "TICKETING" + + assert client.get(_url(edition, f"/{pid}")).json()["name"] == "Billetterie" + assert [p["name"] for p in client.get(_url(edition)).json()] == ["Billetterie"] + + patched = client.patch( + _url(edition, f"/{pid}"), json={"location": "Entrée B"} + ) + assert patched.json()["location"] == "Entrée B" + assert patched.json()["name"] == "Billetterie" + + assert client.delete(_url(edition, f"/{pid}")).status_code == 204 + assert client.get(_url(edition, f"/{pid}")).status_code == 404 + + +def test_unknown_position_is_404(client, edition): + assert client.get(_url(edition, f"/{uuid.uuid4()}")).status_code == 404 + + +def test_unknown_edition_is_404(client): + assert ( + client.get(f"/editions/{uuid.uuid4()}/positions").status_code == 404 + ) diff --git a/backend/tests/test_api_shifts.py b/backend/tests/test_api_shifts.py new file mode 100644 index 0000000..5587541 --- /dev/null +++ b/backend/tests/test_api_shifts.py @@ -0,0 +1,100 @@ +import datetime as dt + +from tests.factories import make_position + + +def _url(edition, suffix=""): + return f"/editions/{edition.id}/shifts{suffix}" + + +def _payload(position, **overrides): + base = { + "position_id": str(position.id), + "starts_at": "2026-07-10T18:00:00", + "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): + 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) + + +def test_create_shift_end_before_start_is_422(client, edition, session): + position = make_position(session, edition) + resp = client.post( + _url(edition), + json=_payload(position, ends_at="2026-07-10T17:00:00"), + ) + assert resp.status_code == 422 + assert resp.json()["detail"]["code"] == "shift.ends_not_after_starts" + + +def test_create_shift_with_position_from_other_edition_is_422(client, edition, session): + from app.models import Edition + + other = Edition( + year=2027, start_date=dt.date(2027, 7, 1), end_date=dt.date(2027, 7, 4) + ) + session.add(other) + session.commit() + session.refresh(other) + stranger_position = make_position(session, other) + + resp = client.post(_url(edition), json=_payload(stranger_position)) + assert resp.status_code == 422 + assert resp.json()["detail"]["code"] == "shift.position_wrong_edition" + + +def test_list_filter_by_position(client, edition, session): + p1 = make_position(session, edition, name="P1") + p2 = make_position(session, edition, name="P2") + client.post(_url(edition), json=_payload(p1)) + client.post( + _url(edition), + json=_payload( + p2, + starts_at="2026-07-11T09:00:00", + ends_at="2026-07-11T12:00:00", + ), + ) + + only_p1 = client.get(_url(edition), params={"position_id": str(p1.id)}).json() + assert len(only_p1) == 1 + assert only_p1[0]["position_id"] == str(p1.id) + assert len(client.get(_url(edition)).json()) == 2 + + +def test_patch_shift_times_and_skills(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"], + }, + ) + 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): + position = make_position(session, edition) + sid = client.post(_url(edition), json=_payload(position)).json()["id"] + resp = client.patch( + _url(edition, f"/{sid}"), json={"starts_at": "2026-07-11T00:00:00"} + ) + assert resp.status_code == 422 diff --git a/backend/tests/test_api_volunteers.py b/backend/tests/test_api_volunteers.py new file mode 100644 index 0000000..0dc06e1 --- /dev/null +++ b/backend/tests/test_api_volunteers.py @@ -0,0 +1,130 @@ +import datetime as dt + +from sqlmodel import select + +from app.models import Assignment, Availability, AvailabilityStatus, Volunteer +from tests.factories import ( + dt_at, + make_availability, + make_position, + make_shift, + make_volunteer, +) + + +def _url(edition, suffix=""): + return f"/editions/{edition.id}/volunteers{suffix}" + + +def test_create_volunteer_with_skills(client, edition): + resp = client.post( + _url(edition), + json={ + "first_name": "Alex", + "last_name": "Martin", + "email": "alex@ex.fr", + "tshirt_size": "M", + "skill_codes": ["SST", "PERMIS_B", "SST"], + }, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["skill_codes"] == ["PERMIS_B", "SST"] + assert body["tshirt_size"] == "M" + assert body["edition_id"] == str(edition.id) + + +def test_create_duplicate_email_conflicts(client, edition): + payload = {"first_name": "A", "last_name": "A", "email": "dup@ex.fr"} + assert client.post(_url(edition), json=payload).status_code == 201 + resp = client.post(_url(edition), json={**payload, "first_name": "B"}) + assert resp.status_code == 409 + assert resp.json()["detail"]["code"] == "volunteer.duplicate_email" + + +def test_volunteers_without_email_are_allowed(client, edition): + for name in ("Un", "Deux"): + r = client.post( + _url(edition), json={"first_name": name, "last_name": "SansMail"} + ) + assert r.status_code == 201 + + +def test_list_is_ordered_by_name(client, edition, session): + make_volunteer(session, edition, first_name="Bob", last_name="Zoe") + make_volunteer(session, edition, first_name="Ana", last_name="Ada") + names = [v["last_name"] for v in client.get(_url(edition)).json()] + assert names == ["Ada", "Zoe"] + + +def test_get_unknown_id_is_404(client, edition): + import uuid + + assert client.get(_url(edition, f"/{uuid.uuid4()}")).status_code == 404 + + +def test_get_volunteer_of_another_edition_is_404(client, edition, session): + from app.models import Edition + + other = Edition( + year=2027, + start_date=dt.date(2027, 7, 1), + end_date=dt.date(2027, 7, 4), + ) + session.add(other) + session.commit() + session.refresh(other) + stranger = make_volunteer(session, other, email="x@ex.fr") + + assert client.get(_url(edition, f"/{stranger.id}")).status_code == 404 + + +def test_patch_replaces_skills_and_can_clear(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"]}, + ) + 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"] == [] + + +def test_delete_is_a_full_cascade(client, edition, session, day_parts): + volunteer = make_volunteer(session, edition, email="gone@ex.fr") + position = make_position(session, edition) + shift = make_shift( + session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12) + ) + make_availability( + session, + edition, + volunteer, + day_parts["MORNING"], + date=dt.date(2026, 7, 10), + status=AvailabilityStatus.AVAILABLE, + ) + session.add( + Assignment( + edition_id=edition.id, + volunteer_id=volunteer.id, + shift_id=shift.id, + ) + ) + session.commit() + + resp = client.delete(_url(edition, f"/{volunteer.id}")) + assert resp.status_code == 204 + assert client.get(_url(edition, f"/{volunteer.id}")).status_code == 404 + assert session.exec(select(Volunteer)).all() == [] + assert session.exec(select(Assignment)).all() == [] + assert session.exec(select(Availability)).all() == [] + # Le créneau subsiste. + from app.models import Shift + + assert session.get(Shift, shift.id) is not None diff --git a/backend/tests/test_validation_loader.py b/backend/tests/test_validation_loader.py new file mode 100644 index 0000000..04f4e36 --- /dev/null +++ b/backend/tests/test_validation_loader.py @@ -0,0 +1,86 @@ +import datetime as dt + +from app.models import Assignment, AssignmentStatus, AvailabilityStatus +from app.validation import AvailabilityDeclaration +from app.validation.loader import build_validation_input +from tests.factories import ( + dt_at, + make_availability, + make_position, + make_shift, + make_volunteer, +) + + +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"], + ) + active_shift = make_shift( + session, edition, position, start=dt_at(11, 9), end=dt_at(11, 12) + ) + declined_shift = make_shift( + session, edition, position, start=dt_at(12, 9), end=dt_at(12, 12) + ) + volunteer = make_volunteer( + session, edition, email="v@ex.fr", skills=["SST", "PERMIS_B"] + ) + make_availability( + session, edition, volunteer, day_parts["AFTERNOON"], + date=dt.date(2026, 7, 10), status=AvailabilityStatus.IF_NEEDED, + ) + session.add_all( + [ + Assignment( + edition_id=edition.id, volunteer_id=volunteer.id, + shift_id=active_shift.id, status=AssignmentStatus.CONFIRMED, + ), + Assignment( + edition_id=edition.id, volunteer_id=volunteer.id, + shift_id=declined_shift.id, status=AssignmentStatus.DECLINED, + ), + ] + ) + session.commit() + + data = build_validation_input( + session=session, edition=edition, volunteer=volunteer, shift=candidate + ) + + 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"}) + # Seul le créneau de l'affectation active, ni le candidat ni la DECLINEE. + assert [s.id for s in data.existing_shifts] == [str(active_shift.id)] + assert len(data.day_parts) == 3 + assert data.availabilities == ( + AvailabilityDeclaration( + dt.date(2026, 7, 10), "AFTERNOON", AvailabilityStatus.IF_NEEDED + ), + ) + + +def test_exclude_assignment_id_is_honoured(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) + ) + 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") + own = Assignment( + edition_id=edition.id, volunteer_id=volunteer.id, + shift_id=other_shift.id, status=AssignmentStatus.PROPOSED, + ) + session.add(own) + session.commit() + session.refresh(own) + + data = build_validation_input( + session=session, edition=edition, volunteer=volunteer, + shift=candidate, exclude_assignment_id=own.id, + ) + assert data.existing_shifts == ()