É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
@@ -1,10 +1,17 @@
|
||||
"""Helpers partagés par les routers CRUD."""
|
||||
"""Helpers partagés par les routers CRUD.
|
||||
|
||||
`resolve_owned_ids`/`set_artists`/`set_wished_positions` sont utilisés à la
|
||||
fois par `volunteers.py` (fiche éditée par la coordination) et `forms.py`
|
||||
(vœux saisis depuis le formulaire public) — un seul endroit pour la
|
||||
validation « ces ids appartiennent bien à cette édition »."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import delete
|
||||
from sqlmodel import Session
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.models import Artist, Position, VolunteerArtist, VolunteerPositionWish
|
||||
|
||||
|
||||
def owned_or_404(instance, edition_id: uuid.UUID):
|
||||
@@ -23,3 +30,50 @@ def replace_links(
|
||||
)
|
||||
for row in pairs:
|
||||
session.add(link_model(**row))
|
||||
|
||||
|
||||
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, 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, 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],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Formulaire public d'inscription des bénévoles.
|
||||
|
||||
Deux familles d'endpoints sous le même routeur :
|
||||
- configuration (COORDINATION) : quels champs sont activés, formulaire
|
||||
ouvert ou fermé ;
|
||||
- publics (**aucune authentification** — les seuls endpoints de toute
|
||||
l'API dans ce cas) : consultation de la configuration et soumission,
|
||||
protégés par hCaptcha (`app/forms/captcha.py`) et un filet de sécurité
|
||||
global (`app/auth/rate_limit.py:form_submission_limiter`).
|
||||
|
||||
Nom et prénom sont toujours collectés (indispensables à la création du
|
||||
bénévole) : ils ne font pas partie des champs activables, contrairement aux
|
||||
neuf autres (`FormFieldType`). Les vœux de postes/artistes réutilisent la
|
||||
validation de `app/api/_common.py` (partagée avec `volunteers.py`), avec
|
||||
deux règles propres au formulaire : au plus `_MAX_POSITION_WISHES` postes,
|
||||
et au plus un artiste souhaité par jour de festival (`performance_date`,
|
||||
voir `_check_one_artist_wish_per_day`). Le vœu de compagnon est un texte
|
||||
libre (`companion_wish_note`) résolu ensuite manuellement par la
|
||||
coordination, faute de savoir si la personne visée est déjà inscrite (voir
|
||||
CLAUDE.md § 2, `Volunteer`)."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
File,
|
||||
Form,
|
||||
HTTPException,
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
from sqlalchemy import delete
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api._common import set_artists, set_wished_positions
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import (
|
||||
FormFieldsSet,
|
||||
FormSubmissionResult,
|
||||
FormTemplateRead,
|
||||
FormTemplateUpdate,
|
||||
PublicArtistOption,
|
||||
PublicFormConfig,
|
||||
PublicPositionOption,
|
||||
)
|
||||
from app.auth.deps import require_coordinator
|
||||
from app.auth.rate_limit import form_submission_limiter
|
||||
from app.db import get_session
|
||||
from app.forms import verify_hcaptcha
|
||||
from app.models import (
|
||||
Account,
|
||||
Artist,
|
||||
Edition,
|
||||
FormField,
|
||||
FormFieldType,
|
||||
FormTemplate,
|
||||
Position,
|
||||
Volunteer,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/editions/{edition_id}/form", tags=["form"])
|
||||
|
||||
_MAX_PHOTO_BYTES = 8 * 1024 * 1024
|
||||
_ALLOWED_PHOTO_TYPES = {"image/png", "image/jpeg"}
|
||||
# Vœux de poste : au plus deux, pour éviter que tout le monde coche tous les
|
||||
# postes « au cas où » — force une vraie priorisation dès le formulaire.
|
||||
_MAX_POSITION_WISHES = 2
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Configuration (COORDINATION) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _get_or_create_template(session: Session, edition_id: uuid.UUID) -> FormTemplate:
|
||||
template = session.exec(
|
||||
select(FormTemplate).where(FormTemplate.edition_id == edition_id)
|
||||
).first()
|
||||
if template is None:
|
||||
template = FormTemplate(edition_id=edition_id)
|
||||
session.add(template)
|
||||
session.commit()
|
||||
session.refresh(template)
|
||||
return template
|
||||
|
||||
|
||||
def _enabled_fields(session: Session, edition_id: uuid.UUID) -> list[FormFieldType]:
|
||||
return list(
|
||||
session.exec(
|
||||
select(FormField.field_type)
|
||||
.where(FormField.edition_id == edition_id)
|
||||
.order_by(FormField.field_type)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormTemplateRead:
|
||||
return FormTemplateRead(
|
||||
edition_id=template.edition_id, is_open=template.is_open, fields=fields
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=FormTemplateRead)
|
||||
def get_form(
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> FormTemplateRead:
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
return _template_read(template, _enabled_fields(session, edition.id))
|
||||
|
||||
|
||||
@router.put("/fields", response_model=FormTemplateRead)
|
||||
def set_fields(
|
||||
payload: FormFieldsSet,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> FormTemplateRead:
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
session.execute(delete(FormField).where(FormField.edition_id == edition.id))
|
||||
for field_type in dict.fromkeys(payload.fields): # dé-doublonne, garde l'ordre
|
||||
session.add(FormField(edition_id=edition.id, field_type=field_type))
|
||||
session.commit()
|
||||
return _template_read(template, _enabled_fields(session, edition.id))
|
||||
|
||||
|
||||
@router.patch("", response_model=FormTemplateRead)
|
||||
def set_open(
|
||||
payload: FormTemplateUpdate,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> FormTemplateRead:
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
template.is_open = payload.is_open
|
||||
session.add(template)
|
||||
session.commit()
|
||||
return _template_read(template, _enabled_fields(session, edition.id))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Formulaire public (aucune authentification) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@router.get("/public", response_model=PublicFormConfig)
|
||||
def get_public_form(
|
||||
edition: Edition = Depends(get_edition),
|
||||
session: Session = Depends(get_session),
|
||||
) -> PublicFormConfig:
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
fields = _enabled_fields(session, edition.id)
|
||||
|
||||
positions: list[PublicPositionOption] = []
|
||||
if FormFieldType.POSITION_WISHES in fields:
|
||||
positions = [
|
||||
PublicPositionOption(id=p.id, name=p.name)
|
||||
for p in session.exec(
|
||||
select(Position)
|
||||
.where(Position.edition_id == edition.id)
|
||||
.order_by(Position.name)
|
||||
)
|
||||
]
|
||||
|
||||
artists: list[PublicArtistOption] = []
|
||||
if FormFieldType.ARTIST_WISHES in fields:
|
||||
artists = [
|
||||
PublicArtistOption(
|
||||
id=a.id, name=a.name, performance_date=a.performance_date
|
||||
)
|
||||
for a in session.exec(
|
||||
select(Artist)
|
||||
.where(Artist.edition_id == edition.id)
|
||||
.order_by(Artist.performance_date, Artist.name)
|
||||
)
|
||||
]
|
||||
|
||||
return PublicFormConfig(
|
||||
is_open=template.is_open, fields=fields, positions=positions, artists=artists
|
||||
)
|
||||
|
||||
|
||||
def _parse_id_list(raw: str | None) -> list[uuid.UUID]:
|
||||
"""`position_wish_ids`/`artist_wish_ids` arrivent en JSON dans un champ
|
||||
`multipart/form-data` (impossible d'y envoyer un tableau natif)."""
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
values = json.loads(raw)
|
||||
if not isinstance(values, list):
|
||||
raise ValueError
|
||||
return [uuid.UUID(str(v)) for v in values]
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "form.invalid_payload"}
|
||||
) from exc
|
||||
|
||||
|
||||
def _check_one_artist_wish_per_day(
|
||||
session: Session, edition_id: uuid.UUID, artist_ids: list[uuid.UUID]
|
||||
) -> None:
|
||||
"""Un seul vœu d'artiste par soir de festival — `set_artists` (validation
|
||||
d'appartenance à l'édition) s'occupe du reste."""
|
||||
dates = list(
|
||||
session.exec(
|
||||
select(Artist.performance_date).where(
|
||||
Artist.edition_id == edition_id, Artist.id.in_(artist_ids)
|
||||
)
|
||||
)
|
||||
)
|
||||
if len(dates) != len(set(dates)):
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "form.too_many_artists_same_day"}
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/public/submit",
|
||||
response_model=FormSubmissionResult,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def submit_public_form(
|
||||
edition: Edition = Depends(get_edition),
|
||||
session: Session = Depends(get_session),
|
||||
first_name: str = Form(...),
|
||||
last_name: str = Form(...),
|
||||
hcaptcha_token: str = Form(...),
|
||||
email: str | None = Form(None),
|
||||
phone: str | None = Form(None),
|
||||
comment: str | None = Form(None),
|
||||
companion_wish_note: str | None = Form(None),
|
||||
available: bool = Form(False),
|
||||
willing_to_pay_fee: bool = Form(False),
|
||||
position_wish_ids: str | None = Form(None),
|
||||
artist_wish_ids: str | None = Form(None),
|
||||
photo: UploadFile | None = File(None),
|
||||
) -> FormSubmissionResult:
|
||||
# Filet de sécurité global (par édition) : hCaptcha est la défense
|
||||
# principale, voir `app/auth/rate_limit.py`.
|
||||
form_submission_limiter.hit(str(edition.id))
|
||||
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
if not template.is_open:
|
||||
raise HTTPException(status_code=422, detail={"code": "form.closed"})
|
||||
|
||||
if not verify_hcaptcha(hcaptcha_token):
|
||||
raise HTTPException(status_code=422, detail={"code": "form.captcha_failed"})
|
||||
|
||||
if not first_name.strip() or not last_name.strip():
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"code": "import.row.missing_required_field",
|
||||
"params": {"field": "first_name"},
|
||||
},
|
||||
)
|
||||
|
||||
fields = set(_enabled_fields(session, edition.id))
|
||||
|
||||
position_ids = (
|
||||
_parse_id_list(position_wish_ids) if FormFieldType.POSITION_WISHES in fields else []
|
||||
)
|
||||
if len(position_ids) > _MAX_POSITION_WISHES:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "form.too_many_position_wishes"}
|
||||
)
|
||||
|
||||
artist_ids = (
|
||||
_parse_id_list(artist_wish_ids) if FormFieldType.ARTIST_WISHES in fields else []
|
||||
)
|
||||
if artist_ids:
|
||||
_check_one_artist_wish_per_day(session, edition.id, artist_ids)
|
||||
|
||||
volunteer = Volunteer(
|
||||
edition_id=edition.id,
|
||||
first_name=first_name.strip().title(),
|
||||
last_name=last_name.strip().title(),
|
||||
email=((email or "").strip() or None) if FormFieldType.EMAIL in fields else None,
|
||||
phone=((phone or "").strip() or None) if FormFieldType.PHONE in fields else None,
|
||||
comment=(
|
||||
(comment or "").strip() or None if FormFieldType.COMMENT in fields else None
|
||||
),
|
||||
companion_wish_note=(
|
||||
(companion_wish_note or "").strip() or None
|
||||
if FormFieldType.COMPANION_WISH in fields
|
||||
else None
|
||||
),
|
||||
self_declared_available=(
|
||||
available if FormFieldType.AVAILABLE in fields else False
|
||||
),
|
||||
willing_to_pay_fee=(
|
||||
willing_to_pay_fee if FormFieldType.WILLING_TO_PAY_FEE in fields else False
|
||||
),
|
||||
)
|
||||
|
||||
if FormFieldType.PHOTO in fields and photo is not None and photo.filename:
|
||||
content_type = photo.content_type or ""
|
||||
if content_type not in _ALLOWED_PHOTO_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "form.photo_type_not_supported"}
|
||||
)
|
||||
content = await photo.read()
|
||||
if len(content) > _MAX_PHOTO_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413, detail={"code": "form.photo_too_large"}
|
||||
)
|
||||
if content:
|
||||
volunteer.photo = content
|
||||
volunteer.photo_content_type = content_type
|
||||
|
||||
session.add(volunteer)
|
||||
session.flush()
|
||||
|
||||
if FormFieldType.POSITION_WISHES in fields:
|
||||
set_wished_positions(session, volunteer, position_ids)
|
||||
if FormFieldType.ARTIST_WISHES in fields:
|
||||
set_artists(session, volunteer, artist_ids)
|
||||
|
||||
session.commit()
|
||||
session.refresh(volunteer)
|
||||
return FormSubmissionResult(volunteer_id=volunteer.id)
|
||||
@@ -9,6 +9,7 @@ from app.models import (
|
||||
AssignmentSource,
|
||||
BadgeAlign,
|
||||
BadgeFieldType,
|
||||
FormFieldType,
|
||||
PositionCategory,
|
||||
Role,
|
||||
)
|
||||
@@ -69,6 +70,11 @@ class VolunteerUpdate(ApiModel):
|
||||
artist_ids: list[uuid.UUID] | None = None
|
||||
wished_position_ids: list[uuid.UUID] | None = None
|
||||
wished_companion_ids: list[uuid.UUID] | None = None
|
||||
# Issus du formulaire public (`app/api/forms.py`), éditables ensuite
|
||||
# depuis la fiche — voir CLAUDE.md § 2 (Volunteer).
|
||||
companion_wish_note: str | None = None
|
||||
self_declared_available: bool | None = None
|
||||
willing_to_pay_fee: bool | None = None
|
||||
|
||||
|
||||
class VolunteerRead(ApiModel):
|
||||
@@ -82,6 +88,10 @@ class VolunteerRead(ApiModel):
|
||||
artist_ids: list[uuid.UUID]
|
||||
wished_position_ids: list[uuid.UUID]
|
||||
wished_companion_ids: list[uuid.UUID]
|
||||
has_photo: bool
|
||||
companion_wish_note: str | None
|
||||
self_declared_available: bool
|
||||
willing_to_pay_fee: bool
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
@@ -457,3 +467,49 @@ class PlanningNotificationResult(ApiModel):
|
||||
queued: int
|
||||
skipped_no_email: int
|
||||
recipients: list[uuid.UUID] = Field(default_factory=list)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Formulaire public d'inscription #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class FormFieldsSet(ApiModel):
|
||||
"""Remplace l'intégralité des champs activés du formulaire — nom/prénom
|
||||
toujours collectés, pas dans cette liste (voir `FormFieldType`)."""
|
||||
|
||||
fields: list[FormFieldType] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FormTemplateRead(ApiModel):
|
||||
edition_id: uuid.UUID
|
||||
is_open: bool
|
||||
fields: list[FormFieldType]
|
||||
|
||||
|
||||
class FormTemplateUpdate(ApiModel):
|
||||
is_open: bool
|
||||
|
||||
|
||||
class PublicPositionOption(ApiModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
|
||||
|
||||
class PublicArtistOption(ApiModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
performance_date: dt.date
|
||||
|
||||
|
||||
class PublicFormConfig(ApiModel):
|
||||
"""Tout ce qu'il faut pour afficher le formulaire public — jamais plus :
|
||||
ni les bénévoles déjà inscrits, ni les créneaux/affectations de
|
||||
l'édition (voir `app/api/forms.py:get_public_form`)."""
|
||||
|
||||
is_open: bool
|
||||
fields: list[FormFieldType]
|
||||
positions: list[PublicPositionOption]
|
||||
artists: list[PublicArtistOption]
|
||||
|
||||
|
||||
class FormSubmissionResult(ApiModel):
|
||||
volunteer_id: uuid.UUID
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -60,3 +60,10 @@ login_limiter = RateLimiter(max_attempts=5, window_seconds=60)
|
||||
# trois fois ; au-delà c'est soit un abus, soit un email qui n'arrivera pas
|
||||
# plus vite en insistant.
|
||||
magic_link_limiter = RateLimiter(max_attempts=3, window_seconds=300)
|
||||
# Formulaire public d'inscription (`app/api/forms.py`) : pas d'identité
|
||||
# fiable à clé (n'importe qui peut soumettre des emails différents à chaque
|
||||
# fois, et l'IP réelle du client n'est pas encore transmise par Caddy — voir
|
||||
# plus haut) ; hCaptcha est la défense principale contre les robots, ceci
|
||||
# n'est qu'un filet de sécurité global côté édition contre un emballement
|
||||
# malgré tout (30 inscriptions/5 min est très large pour un usage réel).
|
||||
form_submission_limiter = RateLimiter(max_attempts=30, window_seconds=300)
|
||||
|
||||
@@ -49,6 +49,12 @@ class Settings(BaseSettings):
|
||||
# (ou un alias validé). En cas de rejet, mettre SMTP_FROM = SMTP_USER.
|
||||
smtp_from: str = "Festival des bénévoles <no-reply@example.test>"
|
||||
|
||||
# Formulaire public d'inscription (`app/api/forms.py`). Secret hCaptcha
|
||||
# seulement (la clé de site est publique, elle vit côté front-end en
|
||||
# VITE_HCAPTCHA_SITE_KEY, figée au build). None ⇒ vérification
|
||||
# désactivée (dev/tests uniquement, voir `app/forms/captcha.py`).
|
||||
hcaptcha_secret_key: str | None = None
|
||||
|
||||
log_level: str = "INFO"
|
||||
cors_origins: list[str] = ["http://localhost:5173"]
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.forms.captcha import verify_hcaptcha
|
||||
|
||||
__all__ = ["verify_hcaptcha"]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Vérification hCaptcha du formulaire public d'inscription.
|
||||
|
||||
Point d'injection unique : `_verify_with_hcaptcha` (remplacé dans les tests,
|
||||
même principe que `app.email.sender.deliver_many`). Sans `HCAPTCHA_SECRET_KEY`
|
||||
configurée (dev/tests), la vérification est désactivée — voir
|
||||
`app/config.py:Settings.hcaptcha_secret_key`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger("app.forms")
|
||||
|
||||
_VERIFY_URL = "https://hcaptcha.com/siteverify"
|
||||
_TIMEOUT_SECONDS = 8.0
|
||||
|
||||
|
||||
def _verify_with_hcaptcha(token: str, secret: str) -> bool:
|
||||
"""Appel réel à l'API hCaptcha. Toute erreur réseau/serveur est traitée
|
||||
comme un échec de vérification (on ne laisse jamais passer par défaut)."""
|
||||
try:
|
||||
resp = httpx.post(
|
||||
_VERIFY_URL,
|
||||
data={"secret": secret, "response": token},
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return bool(resp.json().get("success"))
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("Échec de la vérification hCaptcha : %s", exc.__class__.__name__)
|
||||
return False
|
||||
|
||||
|
||||
def verify_hcaptcha(token: str | None) -> bool:
|
||||
"""`False` si `token` est vide, ou si la vérification hCaptcha échoue.
|
||||
Toujours `True` quand `HCAPTCHA_SECRET_KEY` n'est pas configurée (dev,
|
||||
tests) — voir l'avertissement au démarrage dans `app/main.py`."""
|
||||
secret = get_settings().hcaptcha_secret_key
|
||||
if not secret:
|
||||
return True
|
||||
if not token:
|
||||
return False
|
||||
return _verify_with_hcaptcha(token, secret)
|
||||
@@ -73,5 +73,12 @@
|
||||
"position.category.catering": "Catering",
|
||||
"position.category.setup": "Montage",
|
||||
"position.category.stage": "Régie",
|
||||
"position.category.other": "Autre"
|
||||
"position.category.other": "Autre",
|
||||
"form.closed": "Les inscriptions sont actuellement fermées.",
|
||||
"form.captcha_failed": "Vérification anti-robot invalide, réessayez.",
|
||||
"form.invalid_payload": "Données du formulaire invalides.",
|
||||
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
|
||||
"form.photo_too_large": "La photo est trop volumineuse.",
|
||||
"form.too_many_position_wishes": "Deux vœux de poste au maximum.",
|
||||
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival."
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.api.availability import router as availability_router
|
||||
from app.api.badges import router as badge_router
|
||||
from app.api.day_parts import router as day_part_router
|
||||
from app.api.editions import router as edition_router
|
||||
from app.api.forms import router as form_router
|
||||
from app.api.health import router as health_router
|
||||
from app.api.imports import router as import_router
|
||||
from app.api.matrix import router as matrix_router
|
||||
@@ -37,6 +38,15 @@ if settings.is_production and not settings.email_enabled:
|
||||
"le SMTP avant l'ouverture au public."
|
||||
)
|
||||
|
||||
if settings.is_production and not settings.hcaptcha_secret_key:
|
||||
# Le formulaire public (`app/api/forms.py`) reste accessible sans
|
||||
# HCAPTCHA_SECRET_KEY, mais sans aucune protection anti-robot au-delà du
|
||||
# filet de sécurité global par édition (`form_submission_limiter`).
|
||||
logging.getLogger("app.main").warning(
|
||||
"HCAPTCHA_SECRET_KEY absente en production : le formulaire public "
|
||||
"d'inscription n'est pas protégé par captcha."
|
||||
)
|
||||
|
||||
app = FastAPI(title="Gestion des bénévoles", version="0.1.0")
|
||||
|
||||
app.add_middleware(
|
||||
@@ -63,3 +73,4 @@ app.include_router(solver_router)
|
||||
app.include_router(matrix_router)
|
||||
app.include_router(notification_router)
|
||||
app.include_router(badge_router)
|
||||
app.include_router(form_router)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Formulaire public d'inscription + champs bénévole associés
|
||||
|
||||
Revision ID: 0013_signup_form
|
||||
Revises: 0012_badge_template_size
|
||||
Create Date: 2026-09-21
|
||||
|
||||
Deux tables : `form_template` (configuration par édition — ouvert/fermé) et
|
||||
`form_field` (champs activés, présence = activation). Sur `volunteer` :
|
||||
`photo`/`photo_content_type` (formulaire public), `companion_wish_note`
|
||||
(vœu de compagnon en texte libre, résolu ensuite par la coordination),
|
||||
`self_declared_available`/`willing_to_pay_fee` (cases à cocher purement
|
||||
informatives). Voir `app/api/forms.py`.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0013_signup_form"
|
||||
down_revision: str | None = "0012_badge_template_size"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("volunteer", sa.Column("photo", sa.LargeBinary(), nullable=True))
|
||||
op.add_column(
|
||||
"volunteer", sa.Column("photo_content_type", sa.String(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"volunteer", sa.Column("companion_wish_note", sa.String(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"volunteer",
|
||||
sa.Column(
|
||||
"self_declared_available", sa.Boolean(),
|
||||
server_default="false", nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"volunteer",
|
||||
sa.Column(
|
||||
"willing_to_pay_fee", sa.Boolean(),
|
||||
server_default="false", nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"form_template",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"is_open", sa.Boolean(), server_default="true", nullable=False
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("edition_id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"form_field",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"field_type",
|
||||
sa.Enum(
|
||||
"EMAIL", "PHONE", "POSITION_WISHES", "ARTIST_WISHES",
|
||||
"COMPANION_WISH", "AVAILABLE", "WILLING_TO_PAY_FEE", "PHOTO",
|
||||
"COMMENT",
|
||||
name="formfieldtype", native_enum=False,
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"edition_id", "field_type", name="uq_form_field_edition_type"
|
||||
),
|
||||
)
|
||||
op.create_index("ix_form_field_edition_id", "form_field", ["edition_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_form_field_edition_id", table_name="form_field")
|
||||
op.drop_table("form_field")
|
||||
op.drop_table("form_template")
|
||||
op.drop_column("volunteer", "willing_to_pay_fee")
|
||||
op.drop_column("volunteer", "self_declared_available")
|
||||
op.drop_column("volunteer", "companion_wish_note")
|
||||
op.drop_column("volunteer", "photo_content_type")
|
||||
op.drop_column("volunteer", "photo")
|
||||
@@ -14,6 +14,8 @@ from app.models.domain import (
|
||||
BadgeTemplate,
|
||||
DayPart,
|
||||
Edition,
|
||||
FormField,
|
||||
FormTemplate,
|
||||
MagicLinkToken,
|
||||
Position,
|
||||
Shift,
|
||||
@@ -26,6 +28,7 @@ from app.models.enums import (
|
||||
AssignmentSource,
|
||||
BadgeAlign,
|
||||
BadgeFieldType,
|
||||
FormFieldType,
|
||||
PositionCategory,
|
||||
Role,
|
||||
)
|
||||
@@ -42,6 +45,9 @@ __all__ = [
|
||||
"BadgeTemplate",
|
||||
"DayPart",
|
||||
"Edition",
|
||||
"FormField",
|
||||
"FormFieldType",
|
||||
"FormTemplate",
|
||||
"MagicLinkToken",
|
||||
"Position",
|
||||
"PositionCategory",
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.models.enums import (
|
||||
AssignmentSource,
|
||||
BadgeAlign,
|
||||
BadgeFieldType,
|
||||
FormFieldType,
|
||||
PositionCategory,
|
||||
Role,
|
||||
)
|
||||
@@ -170,6 +171,27 @@ class Volunteer(TimestampMixin, table=True):
|
||||
last_name: str
|
||||
phone: str | None = None
|
||||
comment: str | None = None
|
||||
# Renseigné par le formulaire public d'inscription (`app/api/forms.py`) —
|
||||
# voir aussi ce module pour `photo`/`photo_content_type`.
|
||||
photo: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
||||
photo_content_type: str | None = None
|
||||
# « Collègue que j'aime bien » saisi en texte libre à l'inscription : la
|
||||
# personne visée n'existe pas forcément encore en base à ce moment-là,
|
||||
# donc pas de lien direct vers un autre `Volunteer`. La coordination lit
|
||||
# cette note sur la fiche et la transforme, si besoin, en un vrai
|
||||
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
|
||||
companion_wish_note: str | None = None
|
||||
# Cases à cocher du formulaire public — purement informatives : aucune
|
||||
# logique n'y est attachée (pas de lien avec `Availability`, pas de
|
||||
# workflow de paiement).
|
||||
self_declared_available: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
willing_to_pay_fee: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
|
||||
wished_artists: list[Artist] = Relationship(
|
||||
back_populates="fans",
|
||||
@@ -436,3 +458,45 @@ class BadgeField(TimestampMixin, table=True):
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
color: str = Field(default="#000000")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Formulaire public d'inscription (voir app/api/forms.py) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class FormTemplate(TimestampMixin, table=True):
|
||||
"""Configuration du formulaire public d'une édition. Une ligne par
|
||||
édition (créée à la demande). `is_open` coupe les nouvelles soumissions
|
||||
sans effacer la configuration (les champs activés restent enregistrés)."""
|
||||
|
||||
__tablename__ = "form_template"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", unique=True, ondelete="CASCADE"
|
||||
)
|
||||
is_open: bool = Field(
|
||||
default=True,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="true"),
|
||||
)
|
||||
|
||||
|
||||
class FormField(SQLModel, table=True):
|
||||
"""Un champ activé sur le formulaire public (voir `FormFieldType`) — la
|
||||
présence d'une ligne vaut activation. Pas de champs d'audit : juste une
|
||||
case à cocher dans l'écran de configuration, remplacée en bloc (`PUT
|
||||
.../form/fields`), rien d'autre à suivre dans le temps."""
|
||||
|
||||
__tablename__ = "form_field"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"edition_id", "field_type", name="uq_form_field_edition_type"
|
||||
),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
field_type: FormFieldType = Field(
|
||||
sa_column=Column(SAEnum(FormFieldType, native_enum=False), nullable=False)
|
||||
)
|
||||
|
||||
@@ -43,3 +43,19 @@ class BadgeAlign(enum.StrEnum):
|
||||
LEFT = "LEFT"
|
||||
CENTER = "CENTER"
|
||||
RIGHT = "RIGHT"
|
||||
|
||||
|
||||
class FormFieldType(enum.StrEnum):
|
||||
"""Champ activable sur le formulaire public d'inscription (voir
|
||||
`app/api/forms.py`). Nom/prénom sont toujours collectés, pas au choix de
|
||||
la coordination — ils sont indispensables à la création du bénévole."""
|
||||
|
||||
EMAIL = "EMAIL"
|
||||
PHONE = "PHONE"
|
||||
POSITION_WISHES = "POSITION_WISHES"
|
||||
ARTIST_WISHES = "ARTIST_WISHES"
|
||||
COMPANION_WISH = "COMPANION_WISH"
|
||||
AVAILABLE = "AVAILABLE"
|
||||
WILLING_TO_PAY_FEE = "WILLING_TO_PAY_FEE"
|
||||
PHOTO = "PHOTO"
|
||||
COMMENT = "COMMENT"
|
||||
|
||||
@@ -17,13 +17,13 @@ dependencies = [
|
||||
"ortools>=9.11",
|
||||
"reportlab>=4.2",
|
||||
"pillow>=11.0",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.3",
|
||||
"pytest-cov>=6.0",
|
||||
"httpx>=0.27",
|
||||
"ruff>=0.8",
|
||||
]
|
||||
|
||||
|
||||
@@ -7,7 +7,11 @@ from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata)
|
||||
from app.auth.deps import get_current_account
|
||||
from app.auth.rate_limit import login_limiter, magic_link_limiter
|
||||
from app.auth.rate_limit import (
|
||||
form_submission_limiter,
|
||||
login_limiter,
|
||||
magic_link_limiter,
|
||||
)
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.main import app
|
||||
@@ -31,6 +35,7 @@ def _reset_rate_limiters():
|
||||
déclenche un ne doit pas polluer les suivants."""
|
||||
login_limiter.reset()
|
||||
magic_link_limiter.reset()
|
||||
form_submission_limiter.reset()
|
||||
yield
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Formulaire public d'inscription : configuration (COORDINATION) et
|
||||
soumission publique (aucune authentification)."""
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import io
|
||||
|
||||
from app.auth.rate_limit import form_submission_limiter
|
||||
from app.models import Role
|
||||
from tests.factories import make_artist, make_position
|
||||
|
||||
# PNG 1×1 valide (transparent), le plus petit possible.
|
||||
_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
|
||||
"42YAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
def _form_url(edition, suffix=""):
|
||||
return f"/editions/{edition.id}/form{suffix}"
|
||||
|
||||
|
||||
def _submit(client, edition, **fields):
|
||||
return client.post(_form_url(edition, "/public/submit"), data=fields)
|
||||
|
||||
|
||||
def test_config_starts_open_with_no_fields(client, edition):
|
||||
resp = client.get(_form_url(edition))
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["is_open"] is True
|
||||
assert body["fields"] == []
|
||||
|
||||
|
||||
def test_config_is_coordinator_only(edition, make_account, as_account):
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
lead_client = as_account(lead)
|
||||
assert lead_client.get(_form_url(edition)).status_code == 403
|
||||
resp = lead_client.put(_form_url(edition, "/fields"), json={"fields": []})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_setting_fields_replaces_them_entirely(client, edition):
|
||||
resp = client.put(
|
||||
_form_url(edition, "/fields"),
|
||||
json={"fields": ["EMAIL", "PHONE", "PHOTO"]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert sorted(resp.json()["fields"]) == ["EMAIL", "PHONE", "PHOTO"]
|
||||
|
||||
# Un second PUT remplace, ne cumule pas.
|
||||
resp = client.put(_form_url(edition, "/fields"), json={"fields": ["EMAIL"]})
|
||||
assert resp.json()["fields"] == ["EMAIL"]
|
||||
|
||||
|
||||
def test_closing_the_form(client, edition):
|
||||
resp = client.patch(_form_url(edition), json={"is_open": False})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_open"] is False
|
||||
|
||||
|
||||
def test_public_form_hides_positions_and_artists_unless_enabled(
|
||||
client, raw_client, edition, session
|
||||
):
|
||||
make_position(session, edition, name="Bar central")
|
||||
make_artist(session, edition, name="Chilla")
|
||||
|
||||
resp = raw_client.get(_form_url(edition, "/public"))
|
||||
body = resp.json()
|
||||
assert body["positions"] == []
|
||||
assert body["artists"] == []
|
||||
|
||||
client.put(
|
||||
_form_url(edition, "/fields"),
|
||||
json={"fields": ["POSITION_WISHES", "ARTIST_WISHES"]},
|
||||
)
|
||||
resp = raw_client.get(_form_url(edition, "/public"))
|
||||
body = resp.json()
|
||||
assert [p["name"] for p in body["positions"]] == ["Bar central"]
|
||||
assert [a["name"] for a in body["artists"]] == ["Chilla"]
|
||||
|
||||
|
||||
def test_submitting_creates_a_volunteer(raw_client, client, edition, session):
|
||||
client.put(
|
||||
_form_url(edition, "/fields"),
|
||||
json={"fields": ["EMAIL", "PHONE", "AVAILABLE", "WILLING_TO_PAY_FEE"]},
|
||||
)
|
||||
resp = _submit(
|
||||
raw_client, edition,
|
||||
first_name="alex", last_name="martin", hcaptcha_token="x",
|
||||
email="alex@example.org", phone="0600000000",
|
||||
available="true", willing_to_pay_fee="true",
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
volunteer_id = resp.json()["volunteer_id"]
|
||||
|
||||
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||
assert fiche["first_name"] == "Alex"
|
||||
assert fiche["last_name"] == "Martin"
|
||||
assert fiche["email"] == "alex@example.org"
|
||||
assert fiche["self_declared_available"] is True
|
||||
assert fiche["willing_to_pay_fee"] is True
|
||||
|
||||
# Le bénévole créé apparaît dans la liste de la coordination.
|
||||
volunteers = client.get(f"/editions/{edition.id}/volunteers").json()
|
||||
assert any(v["id"] == volunteer_id for v in volunteers)
|
||||
|
||||
|
||||
def test_submitting_ignores_fields_not_enabled(raw_client, client, edition):
|
||||
# Aucun champ activé : email/téléphone envoyés quand même, ignorés.
|
||||
resp = _submit(
|
||||
raw_client, edition,
|
||||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||
email="alex@example.org", phone="0600000000",
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
volunteer_id = resp.json()["volunteer_id"]
|
||||
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||
assert fiche["email"] is None
|
||||
assert fiche["phone"] is None
|
||||
|
||||
|
||||
def test_submitting_resolves_position_and_artist_wishes(
|
||||
raw_client, client, edition, session
|
||||
):
|
||||
position = make_position(session, edition, name="Bar central")
|
||||
artist = make_artist(session, edition, name="Chilla")
|
||||
client.put(
|
||||
_form_url(edition, "/fields"),
|
||||
json={"fields": ["POSITION_WISHES", "ARTIST_WISHES", "COMPANION_WISH"]},
|
||||
)
|
||||
resp = _submit(
|
||||
raw_client, edition,
|
||||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||
position_wish_ids=f'["{position.id}"]',
|
||||
artist_wish_ids=f'["{artist.id}"]',
|
||||
companion_wish_note="Sam, il/elle s'inscrira bientôt",
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
volunteer_id = resp.json()["volunteer_id"]
|
||||
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||
assert fiche["wished_position_ids"] == [str(position.id)]
|
||||
assert fiche["artist_ids"] == [str(artist.id)]
|
||||
assert fiche["companion_wish_note"] == "Sam, il/elle s'inscrira bientôt"
|
||||
|
||||
|
||||
def test_submitting_with_a_photo(raw_client, client, edition):
|
||||
client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]})
|
||||
resp = raw_client.post(
|
||||
_form_url(edition, "/public/submit"),
|
||||
data={"first_name": "Alex", "last_name": "Martin", "hcaptcha_token": "x"},
|
||||
files={"photo": ("selfie.png", io.BytesIO(_PNG), "image/png")},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
volunteer_id = resp.json()["volunteer_id"]
|
||||
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||
assert fiche["has_photo"] is True
|
||||
|
||||
photo = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}/photo")
|
||||
assert photo.status_code == 200
|
||||
assert photo.headers["content-type"] == "image/png"
|
||||
assert photo.content == _PNG
|
||||
|
||||
|
||||
def test_submitting_an_unsupported_photo_type_is_rejected(raw_client, client, edition):
|
||||
client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]})
|
||||
resp = raw_client.post(
|
||||
_form_url(edition, "/public/submit"),
|
||||
data={"first_name": "Alex", "last_name": "Martin", "hcaptcha_token": "x"},
|
||||
files={"photo": ("selfie.gif", b"not-a-real-gif", "image/gif")},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "form.photo_type_not_supported"
|
||||
|
||||
|
||||
def test_submitting_more_than_two_position_wishes_is_rejected(
|
||||
raw_client, client, edition, session
|
||||
):
|
||||
positions = [
|
||||
make_position(session, edition, name=f"Poste {i}") for i in range(3)
|
||||
]
|
||||
ids = "[" + ",".join(f'"{p.id}"' for p in positions) + "]"
|
||||
client.put(_form_url(edition, "/fields"), json={"fields": ["POSITION_WISHES"]})
|
||||
resp = _submit(
|
||||
raw_client, edition,
|
||||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||
position_wish_ids=ids,
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "form.too_many_position_wishes"
|
||||
|
||||
|
||||
def test_submitting_two_position_wishes_is_accepted(raw_client, client, edition, session):
|
||||
positions = [make_position(session, edition, name=f"Poste {i}") for i in range(2)]
|
||||
client.put(_form_url(edition, "/fields"), json={"fields": ["POSITION_WISHES"]})
|
||||
ids = "[" + ",".join(f'"{p.id}"' for p in positions) + "]"
|
||||
resp = _submit(
|
||||
raw_client, edition,
|
||||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||
position_wish_ids=ids,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
def test_submitting_two_artist_wishes_on_the_same_day_is_rejected(
|
||||
raw_client, client, edition, session
|
||||
):
|
||||
a1 = make_artist(session, edition, name="Chilla")
|
||||
a2 = make_artist(session, edition, name="Kntongo") # même jour par défaut
|
||||
client.put(_form_url(edition, "/fields"), json={"fields": ["ARTIST_WISHES"]})
|
||||
resp = _submit(
|
||||
raw_client, edition,
|
||||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||
artist_wish_ids=f'["{a1.id}", "{a2.id}"]',
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "form.too_many_artists_same_day"
|
||||
|
||||
|
||||
def test_submitting_artist_wishes_on_different_days_is_accepted(
|
||||
raw_client, client, edition, session
|
||||
):
|
||||
a1 = make_artist(session, edition, name="Chilla", performance_date=dt.date(2026, 7, 9))
|
||||
a2 = make_artist(session, edition, name="Kntongo", performance_date=dt.date(2026, 7, 10))
|
||||
client.put(_form_url(edition, "/fields"), json={"fields": ["ARTIST_WISHES"]})
|
||||
resp = _submit(
|
||||
raw_client, edition,
|
||||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||
artist_wish_ids=f'["{a1.id}", "{a2.id}"]',
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
def test_submitting_a_comment(raw_client, client, edition):
|
||||
client.put(_form_url(edition, "/fields"), json={"fields": ["COMMENT"]})
|
||||
resp = _submit(
|
||||
raw_client, edition,
|
||||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||
comment="Dispo seulement le samedi",
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
volunteer_id = resp.json()["volunteer_id"]
|
||||
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||
assert fiche["comment"] == "Dispo seulement le samedi"
|
||||
|
||||
|
||||
def test_submitting_with_an_unknown_position_id_is_rejected(raw_client, client, edition):
|
||||
client.put(_form_url(edition, "/fields"), json={"fields": ["POSITION_WISHES"]})
|
||||
resp = _submit(
|
||||
raw_client, edition,
|
||||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||
position_wish_ids='["00000000-0000-0000-0000-000000000000"]',
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "position.unknown"
|
||||
|
||||
|
||||
def test_submitting_when_the_form_is_closed_is_rejected(raw_client, client, edition):
|
||||
client.patch(_form_url(edition), json={"is_open": False})
|
||||
resp = _submit(
|
||||
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "form.closed"
|
||||
|
||||
|
||||
def test_submitting_with_a_rejected_captcha(raw_client, edition, monkeypatch):
|
||||
monkeypatch.setattr("app.api.forms.verify_hcaptcha", lambda token: False)
|
||||
resp = _submit(
|
||||
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "form.captcha_failed"
|
||||
|
||||
|
||||
def test_submitting_is_rate_limited_per_edition(raw_client, edition):
|
||||
for _ in range(form_submission_limiter.max_attempts):
|
||||
form_submission_limiter.hit(str(edition.id))
|
||||
resp = _submit(
|
||||
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Vérification hCaptcha (module pur, pas de base) — voir `app/forms/captcha.py`."""
|
||||
|
||||
from app.config import get_settings
|
||||
from app.forms.captcha import verify_hcaptcha
|
||||
|
||||
|
||||
def test_verification_is_skipped_without_a_configured_secret(monkeypatch):
|
||||
monkeypatch.delenv("HCAPTCHA_SECRET_KEY", raising=False)
|
||||
get_settings.cache_clear()
|
||||
assert verify_hcaptcha(None) is True
|
||||
assert verify_hcaptcha("") is True
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_empty_token_is_rejected_when_a_secret_is_configured(monkeypatch):
|
||||
monkeypatch.setenv("HCAPTCHA_SECRET_KEY", "secret")
|
||||
get_settings.cache_clear()
|
||||
assert verify_hcaptcha(None) is False
|
||||
assert verify_hcaptcha("") is False
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_delegates_to_the_hcaptcha_api_when_a_secret_is_configured(monkeypatch):
|
||||
monkeypatch.setenv("HCAPTCHA_SECRET_KEY", "secret")
|
||||
get_settings.cache_clear()
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"app.forms.captcha._verify_with_hcaptcha",
|
||||
lambda token, secret: calls.append((token, secret)) or True,
|
||||
)
|
||||
assert verify_hcaptcha("a-token") is True
|
||||
assert calls == [("a-token", "secret")]
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_network_failure_is_treated_as_a_failed_verification(monkeypatch):
|
||||
monkeypatch.setenv("HCAPTCHA_SECRET_KEY", "secret")
|
||||
get_settings.cache_clear()
|
||||
|
||||
def _raise(*args, **kwargs):
|
||||
import httpx
|
||||
|
||||
raise httpx.ConnectError("boom")
|
||||
|
||||
monkeypatch.setattr("httpx.post", _raise)
|
||||
assert verify_hcaptcha("a-token") is False
|
||||
get_settings.cache_clear()
|
||||
@@ -29,6 +29,8 @@ EXPECTED_TABLES = {
|
||||
"assignment",
|
||||
"badge_template",
|
||||
"badge_field",
|
||||
"form_template",
|
||||
"form_field",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -106,6 +106,7 @@ dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "bcrypt" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "openpyxl" },
|
||||
{ name = "ortools" },
|
||||
{ name = "pillow" },
|
||||
@@ -120,7 +121,6 @@ dependencies = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
@@ -131,6 +131,7 @@ requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.14" },
|
||||
{ name = "bcrypt", specifier = ">=4.1" },
|
||||
{ name = "fastapi", specifier = ">=0.115" },
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "openpyxl", specifier = ">=3.1" },
|
||||
{ name = "ortools", specifier = ">=9.11" },
|
||||
{ name = "pillow", specifier = ">=11.0" },
|
||||
@@ -145,7 +146,6 @@ requires-dist = [
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "pytest", specifier = ">=8.3" },
|
||||
{ name = "pytest-cov", specifier = ">=6.0" },
|
||||
{ name = "ruff", specifier = ">=0.8" },
|
||||
|
||||
Reference in New Issue
Block a user