Étape 21 : formulaire public d'inscription des bénévoles

Retire « constructeur de formulaire » des exclusions du CLAUDE.md (§ 5) —
décision explicite prise en conversation.

Un formulaire par édition (COORDINATION choisit les champs actifs parmi
neuf : email, téléphone, vœux de poste — max 2, vœux d'artiste — un par
soir de festival, vœu de compagnon en texte libre, disponibilité/
cotisation déclaratives, photo, commentaire ; nom/prénom toujours
collectés) ouvert/fermé à volonté, avec un lien à partager
(/inscription, aucun compte requis).

Backend : FormTemplate/FormField (migration 0013), quatre nouveaux champs
sur Volunteer (photo, companion_wish_note, self_declared_available,
willing_to_pay_fee), app/api/forms.py — les deux seuls endpoints publics
de toute l'API (GET .../form/public, POST .../form/public/submit),
protégés par hCaptcha (app/forms/captcha.py, désactivé sans
HCAPTCHA_SECRET_KEY) et un filet de sécurité global par édition
(form_submission_limiter). resolve_owned_ids/set_artists/
set_wished_positions déplacés vers app/api/_common.py, partagés avec
volunteers.py.

Frontend : écran de configuration (/inscriptions) avec lien copiable et
bascule ouvert/fermé, formulaire public (/inscription, hors RequireAuth)
avec widget hCaptcha chargé dynamiquement, capture photo native
(<input capture>), vœux de poste/artiste avec les mêmes règles que le
back-end. Réponses du formulaire visibles et éditables depuis la fiche
bénévole existante (ConfigVolunteers.tsx).

Vérifié : pytest (273 passés), ruff, alembic check, tsc --noEmit, eslint,
vite build, smoke (rendu SSR de chaque écran + flux de bout en bout du
formulaire en mode démonstration) — tous verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-21 20:24:32 +02:00
co-authored by Claude Sonnet 5
parent 55c5d539a8
commit 82088f3b0c
41 changed files with 2197 additions and 81 deletions
+33 -64
View File
@@ -9,7 +9,13 @@ 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
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,
@@ -93,74 +99,18 @@ def _companion_ids(session: Session, volunteer_id: uuid.UUID) -> list[uuid.UUID]
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
return VolunteerRead(
**volunteer.model_dump(),
**volunteer.model_dump(exclude={"photo", "photo_content_type"}),
artist_ids=_artist_ids(session, volunteer.id),
wished_position_ids=_wished_position_ids(session, volunteer.id),
wished_companion_ids=_companion_ids(session, volunteer.id),
)
def _resolve_owned_ids(
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID], code: str
) -> list[uuid.UUID]:
"""Ordre d'entrée conservé, doublons retirés ; 422 si un id est étranger."""
wanted = list(dict.fromkeys(ids))
if wanted:
found = set(
session.exec(
select(model.id).where(
model.edition_id == edition_id,
model.id.in_(wanted),
)
)
)
if len(found) != len(wanted):
raise HTTPException(status_code=422, detail={"code": code})
return wanted
def _set_artists(
session: Session, volunteer: Volunteer, artist_ids: list[uuid.UUID]
) -> None:
ids = _resolve_owned_ids(
session, Artist, volunteer.edition_id, artist_ids, "artist.unknown"
)
replace_links(
session,
VolunteerArtist,
filter_column=VolunteerArtist.volunteer_id,
filter_value=volunteer.id,
pairs=[
{"volunteer_id": volunteer.id, "artist_id": aid} for aid in ids
],
)
def _set_wished_positions(
session: Session, volunteer: Volunteer, position_ids: list[uuid.UUID]
) -> None:
ids = _resolve_owned_ids(
session,
Position,
volunteer.edition_id,
position_ids,
"position.unknown",
)
replace_links(
session,
VolunteerPositionWish,
filter_column=VolunteerPositionWish.volunteer_id,
filter_value=volunteer.id,
pairs=[
{"volunteer_id": volunteer.id, "position_id": pid} for pid in ids
],
has_photo=volunteer.photo is not None,
)
def _set_companions(
session: Session, volunteer: Volunteer, companion_ids: list[uuid.UUID]
) -> None:
ids = _resolve_owned_ids(
ids = resolve_owned_ids(
session, Volunteer, volunteer.edition_id, companion_ids, "volunteer.unknown"
)
if volunteer.id in ids:
@@ -195,8 +145,8 @@ def create_volunteer(
)
session.add(volunteer)
session.flush()
_set_artists(session, volunteer, payload.artist_ids)
_set_wished_positions(session, volunteer, payload.wished_position_ids)
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)
@@ -272,6 +222,25 @@ def get_volunteer_planning(
]
@router.get("/{volunteer_id}/photo")
def get_volunteer_photo(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> Response:
"""Photo transmise par le bénévole via le formulaire public
(`app/api/forms.py`) — réservée à COORDINATION/RÉFÉRENT, pas exposée à
l'écran bénévole."""
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,
@@ -289,9 +258,9 @@ def update_volunteer(
setattr(volunteer, field, value)
session.flush()
if artist_ids is not None:
_set_artists(session, volunteer, artist_ids)
set_artists(session, volunteer, artist_ids)
if wished_position_ids is not None:
_set_wished_positions(session, volunteer, wished_position_ids)
set_wished_positions(session, volunteer, wished_position_ids)
if wished_companion_ids is not None:
_set_companions(session, volunteer, wished_companion_ids)
session.commit()