"""CRUD des affectations. Une affectation est valide dès sa création (pas d'étape de confirmation). La création 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.auth.deps import ( current_account_in_edition, ensure_can_write_shift, require_staff, ) from app.db import get_session from app.models import Account, Assignment, Edition, Role, 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 def _forbidden() -> HTTPException: return HTTPException(status_code=403, detail={"code": "error.forbidden"}) 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 _forbidden() @router.post( "/validate", response_model=AssignmentValidateResult, tags=["assignments"] ) def validate_assignment( payload: AssignmentValidateRequest, edition: Edition = Depends(get_edition), _guard: Account = Depends(require_staff), 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), account: Account = Depends(current_account_in_edition), session: Session = Depends(get_session), ) -> AssignmentWriteResult: volunteer, shift = _pair_or_422( session, edition.id, payload.volunteer_id, payload.shift_id ) ensure_can_write_shift(account, session, shift) 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"} ) 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, 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), account: Account = Depends(current_account_in_edition), session: Session = Depends(get_session), ) -> list[Assignment]: if account.role is Role.VOLUNTEER: # Un bénévole ne voit que ses propres affectations. if volunteer_id not in (None, account.volunteer_id): raise _forbidden() volunteer_id = account.volunteer_id 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), account: Account = Depends(current_account_in_edition), session: Session = Depends(get_session), ) -> Assignment: assignment = owned_or_404( session.get(Assignment, assignment_id), edition.id ) _ensure_can_read(account, assignment.volunteer_id) return assignment @router.patch("/{assignment_id}", response_model=AssignmentWriteResult) def update_assignment( assignment_id: uuid.UUID, payload: AssignmentUpdate, edition: Edition = Depends(get_edition), account: Account = Depends(current_account_in_edition), session: Session = Depends(get_session), ) -> AssignmentWriteResult: assignment = owned_or_404( session.get(Assignment, assignment_id), edition.id ) ensure_can_write_shift( account, session, session.get(Shift, assignment.shift_id) ) changes = payload.model_dump(exclude_unset=True) for field, value in changes.items(): setattr(assignment, field, value) # `source` / `locked` ne changent pas le résultat de validation ; on le # rejoue seulement pour renvoyer les avertissements courants. warnings = validate( build_validation_input( session=session, edition=edition, volunteer=session.get(Volunteer, assignment.volunteer_id), shift=session.get(Shift, assignment.shift_id), exclude_assignment_id=assignment.id, ) ) 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), account: Account = Depends(current_account_in_edition), session: Session = Depends(get_session), ) -> Response: assignment = owned_or_404( session.get(Assignment, assignment_id), edition.id ) ensure_can_write_shift( account, session, session.get(Shift, assignment.shift_id) ) session.delete(assignment) session.commit() return Response(status_code=status.HTTP_204_NO_CONTENT)