"""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.auth.deps import ( current_account_in_edition, require_coordinator, require_staff, ) from app.db import get_session from app.models import ( Account, Artist, Edition, Position, Role, Skill, Volunteer, VolunteerArtist, VolunteerPositionWish, VolunteerSkill, ) router = APIRouter( prefix="/editions/{edition_id}/volunteers", tags=["volunteers"] ) 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 _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( select(Artist.id) .join(VolunteerArtist, VolunteerArtist.artist_id == Artist.id) .where(VolunteerArtist.volunteer_id == volunteer_id) .order_by(Artist.performance_date, Artist.name) ) ) def _wished_position_ids( session: Session, volunteer_id: uuid.UUID ) -> list[uuid.UUID]: return list( session.exec( select(Position.id) .join( VolunteerPositionWish, VolunteerPositionWish.position_id == Position.id, ) .where(VolunteerPositionWish.volunteer_id == volunteer_id) .order_by(Position.name) ) ) 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]: """Ordre d'entrée conservé, doublons retirés ; 422 si un id est étranger.""" wanted = list(dict.fromkeys(ids)) if wanted: found = set( session.exec( select(model.id).where( model.edition_id == edition_id, model.id.in_(wanted), ) ) ) if len(found) != len(wanted): raise HTTPException(status_code=422, detail={"code": code}) return wanted def _set_artists( session: Session, volunteer: Volunteer, artist_ids: list[uuid.UUID] ) -> None: ids = _resolve_owned_ids( session, Artist, volunteer.edition_id, artist_ids, "artist.unknown" ) replace_links( session, VolunteerArtist, filter_column=VolunteerArtist.volunteer_id, filter_value=volunteer.id, pairs=[ {"volunteer_id": volunteer.id, "artist_id": aid} for aid in ids ], ) def _set_wished_positions( session: Session, volunteer: Volunteer, position_ids: list[uuid.UUID] ) -> None: ids = _resolve_owned_ids( session, Position, volunteer.edition_id, position_ids, "position.unknown", ) replace_links( session, VolunteerPositionWish, filter_column=VolunteerPositionWish.volunteer_id, filter_value=volunteer.id, pairs=[ {"volunteer_id": volunteer.id, "position_id": pid} for pid in ids ], ) @router.post("", response_model=VolunteerRead, status_code=status.HTTP_201_CREATED) def create_volunteer( payload: VolunteerCreate, edition: Edition = Depends(get_edition), _guard: Account = Depends(require_coordinator), session: Session = Depends(get_session), ) -> VolunteerRead: volunteer = Volunteer( edition_id=edition.id, **payload.model_dump( exclude={"skill_codes", "artist_ids", "wished_position_ids"} ), ) 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) _set_artists(session, volunteer, payload.artist_ids) _set_wished_positions(session, volunteer, payload.wished_position_ids) session.commit() session.refresh(volunteer) return _read(session, volunteer) @router.get("", response_model=list[VolunteerRead]) def list_volunteers( edition: Edition = Depends(get_edition), _guard: Account = Depends(require_staff), 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), account: Account = Depends(current_account_in_edition), session: Session = Depends(get_session), ) -> VolunteerRead: _ensure_can_read(account, volunteer_id) 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), _guard: Account = Depends(require_coordinator), 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) artist_ids = changes.pop("artist_ids", None) wished_position_ids = changes.pop("wished_position_ids", 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) if artist_ids is not None: _set_artists(session, volunteer, artist_ids) if wished_position_ids is not None: _set_wished_positions(session, volunteer, wished_position_ids) 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), _guard: Account = Depends(require_coordinator), 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)