Files
benevoles/backend/app/api/volunteers.py
T
AdamThomasandClaude Sonnet 5 096b7cd26a
ci-cd / build-backend (push) Successful in 1m12s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s
Lien d'inscription protégé par jeton, reçus fiscaux mécénat, infobulles et revue de sécurité
- Lien d'inscription protégé par un jeton (FormTemplate.access_token,
  /inscription/<token>), avec régénération depuis l'écran de configuration.
- Reçus fiscaux mécénat (cerfa 11580*05) : un PDF par bénévole ayant payé en
  mécénat, donateur/montant (chiffres et toutes lettres en français,
  app/mecenat/number_words.py)/date complétés par-dessus le modèle officiel.
- Jeu de démonstration : bénévoles avec cotisation classique ou mécénat payée.
- Infobulles (HelpTip et détail de cellule de la grille des besoins)
  repositionnées en position: fixed via JS, bornées à l'écran.
- Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une
  URL (fuyait le jeton d'inscription), sujet retiré du log de remise des
  emails (pouvait porter un nom de bénévole), photo du formulaire public
  revalidée par décodage réel avant stockage, export Excel protégé contre
  l'injection de formule, sondage du statut de paiement limité en
  fréquence, pyjwt mis à jour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 07:47:17 +02:00

389 lines
13 KiB
Python

"""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.exporting import generate_volunteers_xlsx
from app.mecenat import MecenatReceipt, generate_mecenat_receipts_pdf
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"}
)
receipts = [
MecenatReceipt(
last_name=v.last_name,
first_name=v.first_name,
address=v.mecenat_address or "",
postal_code=v.mecenat_postal_code or "",
city=v.mecenat_city or "",
country=v.mecenat_country or "",
amount=v.fee_amount or 0.0,
payment_date=(v.fee_paid_at or v.created_at).date(),
)
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.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)