"""CRUD des bénévoles. La suppression est un effacement complet (RGPD). L'email n'est pas unique : plusieurs bénévoles peuvent en partager un même (couple, famille) — voir `app/models/domain.py:Volunteer`.""" 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 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, Volunteer, VolunteerArtist, VolunteerPositionWish, ) 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 _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(), artist_ids=_artist_ids(session, volunteer.id), wished_position_ids=_wished_position_ids(session, volunteer.id), ) 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={"artist_ids", "wished_position_ids"}), ) session.add(volunteer) session.flush() _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) 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) session.flush() 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, vœux et compte éventuel. session.delete(volunteer) session.commit() return Response(status_code=status.HTTP_204_NO_CONTENT)