Volunteer.mecenat_receipt_number (migration 0020, unique) : année du paiement + rang sur 5 chiffres (202600001…), une seule numérotation pour toutes les éditions, repartant à 1 chaque année. Attribué à la confirmation du paiement, ou au premier export pour les bénévoles payés avant (dans l'ordre des paiements), puis jamais réattribué — un reçu regénéré garde son numéro. Attribution sérialisée par un verrou pg_advisory_xact_lock (webhook et sondage peuvent confirmer deux paiements à la fois). Imprimé centré dans la case « Numéro d'ordre du reçu » en page 1 du cerfa. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
385 lines
13 KiB
Python
385 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 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.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)
|