"""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 sqlalchemy.orm import aliased from sqlmodel import Session, select from app.api._common import ( owned_or_404, replace_links, resolve_owned_ids, set_artists, set_wished_positions, ) from app.api.deps import get_edition from app.api.schemas import ( VolunteerCreate, VolunteerPlanningEntry, 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, Assignment, Edition, Position, Role, Shift, Volunteer, VolunteerArtist, VolunteerCompanionWish, 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 _companion_ids(session: Session, volunteer_id: uuid.UUID) -> list[uuid.UUID]: return list( session.exec( select(Volunteer.id) .join( VolunteerCompanionWish, VolunteerCompanionWish.companion_id == Volunteer.id, ) .where(VolunteerCompanionWish.volunteer_id == volunteer_id) .order_by(Volunteer.last_name, Volunteer.first_name) ) ) def _read(session: Session, volunteer: Volunteer) -> VolunteerRead: return VolunteerRead( **volunteer.model_dump( exclude={"photo", "photo_content_type", "fee_checkout_id"} ), artist_ids=_artist_ids(session, volunteer.id), wished_position_ids=_wished_position_ids(session, volunteer.id), wished_companion_ids=_companion_ids(session, volunteer.id), has_photo=volunteer.photo is not None, ) def _set_companions( session: Session, volunteer: Volunteer, companion_ids: list[uuid.UUID] ) -> None: ids = resolve_owned_ids( session, Volunteer, volunteer.edition_id, companion_ids, "volunteer.unknown" ) if volunteer.id in ids: raise HTTPException( status_code=422, detail={"code": "volunteer.cannot_wish_self"} ) replace_links( session, VolunteerCompanionWish, filter_column=VolunteerCompanionWish.volunteer_id, filter_value=volunteer.id, pairs=[ {"volunteer_id": volunteer.id, "companion_id": cid} for cid 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: payload.first_name = payload.first_name.title() payload.last_name = payload.last_name.title() volunteer = Volunteer( edition_id=edition.id, **payload.model_dump( exclude={"artist_ids", "wished_position_ids", "wished_companion_ids"} ), ) session.add(volunteer) session.flush() set_artists(session, volunteer, payload.artist_ids) set_wished_positions(session, volunteer, payload.wished_position_ids) _set_companions(session, volunteer, payload.wished_companion_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.get("/{volunteer_id}/planning", response_model=list[VolunteerPlanningEntry]) def get_volunteer_planning( volunteer_id: uuid.UUID, edition: Edition = Depends(get_edition), account: Account = Depends(current_account_in_edition), session: Session = Depends(get_session), ) -> list[VolunteerPlanningEntry]: """Planning enrichi (poste, lieu, référent + téléphone à contacter) d'un bénévole — permet à l'écran bénévole (`/mobile-benevole`) d'afficher ces infos sans jamais donner à un compte VOLUNTEER un accès en lecture aux postes/créneaux de toute l'édition (réservé à COORDINATOR/LEAD).""" _ensure_can_read(account, volunteer_id) owned_or_404(session.get(Volunteer, volunteer_id), edition.id) Referent = aliased(Volunteer) rows = session.exec( select(Assignment, Shift, Position, Referent) .join(Shift, Shift.id == Assignment.shift_id) .join(Position, Position.id == Shift.position_id) .join(Account, Account.id == Position.lead_account_id, isouter=True) .join(Referent, Referent.id == Account.volunteer_id, isouter=True) .where( Assignment.volunteer_id == volunteer_id, Assignment.edition_id == edition.id, ) .order_by(Shift.starts_at) ).all() return [ VolunteerPlanningEntry( assignment_id=a.id, shift_id=s.id, starts_at=s.starts_at, ends_at=s.ends_at, position_name=p.name, position_location=p.location, referent_name=f"{r.first_name} {r.last_name}" if r else None, referent_phone=r.phone if r else None, ) for a, s, p, r in rows ] @router.get("/{volunteer_id}/photo") def get_volunteer_photo( volunteer_id: uuid.UUID, edition: Edition = Depends(get_edition), account: Account = Depends(current_account_in_edition), session: Session = Depends(get_session), ) -> Response: """Photo transmise par le bénévole via le formulaire public (`app/api/forms.py`) — COORDINATION/RÉFÉRENT (ex. écran référent, en face du nom de chaque membre de l'équipe) ou le bénévole lui-même (écran bénévole).""" _ensure_can_read(account, volunteer_id) volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id) if volunteer.photo is None: raise HTTPException(status_code=404, detail={"code": "error.not_found"}) return Response( content=volunteer.photo, media_type=volunteer.photo_content_type or "application/octet-stream", ) @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) wished_companion_ids = changes.pop("wished_companion_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) if wished_companion_ids is not None: _set_companions(session, volunteer, wished_companion_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)