É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
+56 -2
View File
@@ -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],
)
+320
View File
@@ -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)
+56
View File
@@ -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
+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()
+7
View File
@@ -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)
+6
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
from app.forms.captcha import verify_hcaptcha
__all__ = ["verify_hcaptcha"]
+46
View File
@@ -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)
+8 -1
View File
@@ -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
View File
@@ -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")
+6
View File
@@ -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",
+64
View File
@@ -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)
)
+16
View File
@@ -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"
+1 -1
View File
@@ -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",
]
+6 -1
View File
@@ -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
+282
View File
@@ -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
+47
View File
@@ -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()
+2
View File
@@ -29,6 +29,8 @@ EXPECTED_TABLES = {
"assignment",
"badge_template",
"badge_field",
"form_template",
"form_field",
}
+2 -2
View File
@@ -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" },