É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:
co-authored by
Claude Sonnet 5
parent
55c5d539a8
commit
82088f3b0c
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user