"""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, File, HTTPException, Response, UploadFile, status from sqlalchemy.orm import aliased from sqlmodel import Session, select from app.api._common import ( owned_or_404, read_photo, 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.exporting import generate_volunteers_xlsx from app.mecenat import assign_receipt_number, generate_mecenat_receipts_pdf, receipt_for from app.models import ( Account, Artist, Assignment, Edition, FeePaymentStatus, 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("/export") def export_volunteers( edition: Edition = Depends(get_edition), _guard: Account = Depends(require_coordinator), session: Session = Depends(get_session), ) -> Response: """Export Excel de la liste des bénévoles — vœux (postes, artistes, compagnons) résolus en noms lisibles, pas en UUID. Déclaré avant `/{volunteer_id}` : un chemin statique doit être enregistré avant le chemin dynamique qui le suivrait sinon FastAPI essaierait de parser « export » comme un UUID.""" volunteers_orm = list( session.exec( select(Volunteer) .where(Volunteer.edition_id == edition.id) .order_by(Volunteer.last_name, Volunteer.first_name) ) ) volunteers = [_read(session, v) for v in volunteers_orm] position_names = { p.id: p.name for p in session.exec( select(Position).where(Position.edition_id == edition.id) ) } artist_names = { a.id: a.name for a in session.exec(select(Artist).where(Artist.edition_id == edition.id)) } volunteer_names = { v.id: f"{v.first_name} {v.last_name}" for v in volunteers_orm } content = generate_volunteers_xlsx( volunteers, position_names=position_names, artist_names=artist_names, volunteer_names=volunteer_names, ) return Response( content=content, media_type=( "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ), headers={ "Content-Disposition": f'attachment; filename="benevoles-{edition.year}.xlsx"' }, ) @router.get("/mecenat-receipts") def export_mecenat_receipts( edition: Edition = Depends(get_edition), _guard: Account = Depends(require_coordinator), session: Session = Depends(get_session), ) -> Response: """Reçus fiscaux (cerfa 11580*05, `app/mecenat/pdf.py`) des bénévoles ayant payé un type de paiement « mécénat » — repérés par `mecenat_address` renseigné (voir CLAUDE.md § 2, Paiement de la cotisation : ce champ n'est rempli que pour ce chemin-là). Déclaré avant `/{volunteer_id}`, comme `/export` juste au-dessus.""" volunteers = list( session.exec( select(Volunteer) .where(Volunteer.edition_id == edition.id) .where(Volunteer.fee_payment_status == FeePaymentStatus.PAID) .where(Volunteer.mecenat_address.is_not(None)) .order_by(Volunteer.last_name, Volunteer.first_name) ) ) if not volunteers: raise HTTPException( status_code=422, detail={"code": "mecenat.no_receipts"} ) # Bénévoles payés avant la numérotation (ou jamais exportés) : numéro # attribué maintenant, dans l'ordre des paiements, puis figé. unnumbered = [v for v in volunteers if not v.mecenat_receipt_number] if unnumbered: for v in sorted(unnumbered, key=lambda v: v.fee_paid_at or v.created_at): assign_receipt_number(session, v) session.commit() receipts = [receipt_for(v) for v in volunteers] content = generate_mecenat_receipts_pdf(receipts) return Response( content=content, media_type="application/pdf", headers={ "Content-Disposition": f'attachment; filename="recus-mecenat-{edition.year}.pdf"' }, ) @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.put("/{volunteer_id}/photo", response_model=VolunteerRead) async def set_volunteer_photo( volunteer_id: uuid.UUID, file: UploadFile = File(...), edition: Edition = Depends(get_edition), _guard: Account = Depends(require_coordinator), session: Session = Depends(get_session), ) -> VolunteerRead: """Ajout (ou remplacement) de la photo par la coordination, depuis la fiche — pour un bénévole qui n'en a pas envoyé au formulaire public ou ajouté à la main. Même validation que le formulaire (`read_photo`).""" volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id) photo = await read_photo(file) if photo is None: raise HTTPException(status_code=400, detail={"code": "volunteer.empty_photo"}) volunteer.photo, volunteer.photo_content_type = photo session.add(volunteer) session.commit() session.refresh(volunteer) return _read(session, volunteer) @router.delete("/{volunteer_id}/photo", response_model=VolunteerRead) def delete_volunteer_photo( volunteer_id: uuid.UUID, 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) volunteer.photo = None volunteer.photo_content_type = None session.add(volunteer) session.commit() session.refresh(volunteer) 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) 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)