Files
AdamThomasandClaude Opus 5.5 16d8fb44a7
ci-cd / build-backend (push) Successful in 29s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 12s
Écrans bénévole et référent redessinés, messages aux bénévoles
Étape 55 : écrans /mobile-benevole et /mobile-referent d'après la maquette
(design/Planning bénévole mobile) — créneau en cours ou prochain mis en
avant, planning par journée de festival avec frise horaire, compteurs et
pointage du référent par créneau.

Étape 56 : message de l'organisation (Edition.volunteer_message, onglet
« Message aux bénévoles ») et message de chaque poste (Position.message,
saisi par le référent), migration 0029, affichés sur l'écran bénévole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:17:14 +01:00

426 lines
15 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, 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,
position_message=p.message,
position_message_updated_at=p.message_updated_at,
)
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)