É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
+6
View File
@@ -29,6 +29,12 @@ SMTP_FROM=Festival des bénévoles <no-reply@example.test>
# Alternative OVH si le 587/STARTTLS échoue : SMTP_PORT=465, SMTP_SSL=true, # Alternative OVH si le 587/STARTTLS échoue : SMTP_PORT=465, SMTP_SSL=true,
# SMTP_STARTTLS=false # SMTP_STARTTLS=false
# Formulaire public d'inscription (/inscription). Vide en dev ⇒ vérification
# hCaptcha désactivée (voir app/forms/captcha.py) — aucun compte requis pour
# développer en local. Couple site/secret d'un même compte https://hcaptcha.com.
HCAPTCHA_SECRET_KEY=
HCAPTCHA_SITE_KEY=
# Frontend (service `frontend` du docker-compose). # Frontend (service `frontend` du docker-compose).
# mock (défaut) : jeu de démonstration en mémoire, aucun backend requis. # mock (défaut) : jeu de démonstration en mémoire, aucun backend requis.
# http : API réelle — renseigner alors VITE_EDITION_ID avec l'UUID # http : API réelle — renseigner alors VITE_EDITION_ID avec l'UUID
+4
View File
@@ -19,6 +19,9 @@
# DEPLOY_PATH dossier du projet sur le VPS (ex. /opt/benevoles) # DEPLOY_PATH dossier du projet sur le VPS (ex. /opt/benevoles)
# VITE_EDITION_ID UUID de l'édition de production, à renseigner APRÈS # VITE_EDITION_ID UUID de l'édition de production, à renseigner APRÈS
# la toute première mise en service (voir deploy/README.md) # la toute première mise en service (voir deploy/README.md)
# VITE_HCAPTCHA_SITE_KEY clé de site hCaptcha (publique) — figée au build
# du frontend, voir app/api/forms.py et deploy/README.md.
# Vide ⇒ formulaire public sans vérification anti-robot.
name: ci-cd name: ci-cd
@@ -122,6 +125,7 @@ jobs:
docker build \ docker build \
--build-arg VITE_API_MODE=http \ --build-arg VITE_API_MODE=http \
--build-arg VITE_EDITION_ID="${{ vars.VITE_EDITION_ID }}" \ --build-arg VITE_EDITION_ID="${{ vars.VITE_EDITION_ID }}" \
--build-arg VITE_HCAPTCHA_SITE_KEY="${{ vars.VITE_HCAPTCHA_SITE_KEY }}" \
-f frontend/Dockerfile.prod \ -f frontend/Dockerfile.prod \
-t "$IMAGE:${{ gitea.sha }}" -t "$IMAGE:latest" frontend -t "$IMAGE:${{ gitea.sha }}" -t "$IMAGE:latest" frontend
docker push "$IMAGE:${{ gitea.sha }}" docker push "$IMAGE:${{ gitea.sha }}"
+47 -6
View File
@@ -73,6 +73,16 @@ les tables sauf les tables d'association.
taille de t-shirt, régime, ni compétences. Porte trois listes de vœux, taille de t-shirt, régime, ni compétences. Porte trois listes de vœux,
exposées par la fiche (`artist_ids`, `wished_position_ids`, exposées par la fiche (`artist_ids`, `wished_position_ids`,
`wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider). `wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
Renseignables aussi via le formulaire public (§ 9) : `photo`/
`photo_content_type` (image, servie séparément par
`GET .../volunteers/{id}/photo`, COORD/LEAD), `companion_wish_note` (« je
voudrais être avec » en texte libre — la personne visée n'existe pas
forcément encore en base au moment de l'inscription, donc pas de lien
direct vers un `VolunteerCompanionWish` ; la coordination la résout à la
main depuis la fiche une fois les deux bénévoles inscrits),
`self_declared_available`/`willing_to_pay_fee` (cases à cocher **purement
informatives**, sans lien avec `Availability` ni aucun workflow de
paiement).
- **`Artist`** : programmation de l'édition (`name`, `performance_date`, - **`Artist`** : programmation de l'édition (`name`, `performance_date`,
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le `start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
lendemain). Unique (`edition_id`, `name`, `performance_date`). La lendemain). Unique (`edition_id`, `name`, `performance_date`). La
@@ -165,6 +175,27 @@ les tables sauf les tables d'association.
pour rien : juste ReportLab) : pages recto personnalisées suivies, planche pour rien : juste ReportLab) : pages recto personnalisées suivies, planche
par planche, de la page de verso correspondante (même image répétée à par planche, de la page de verso correspondante (même image répétée à
chaque emplacement) — impression recto-verso classique. chaque emplacement) — impression recto-verso classique.
- **`FormTemplate`** : configuration du formulaire public d'inscription
d'une édition (une ligne, créée à la demande). `is_open` coupe les
nouvelles soumissions sans effacer la configuration.
- **`FormField`** : un champ activé sur le formulaire (`field_type` ∈
`EMAIL` / `PHONE` / `POSITION_WISHES` / `ARTIST_WISHES` / `COMPANION_WISH`
/ `AVAILABLE` / `WILLING_TO_PAY_FEE` / `PHOTO` / `COMMENT`, au plus un par
édition — `PUT .../form/fields` remplace tout). **Nom et prénom sont
toujours collectés**, indispensables à la création du `Volunteer` : ils ne
sont pas dans cette liste, contrairement aux neuf autres. Deux règles
propres à ce formulaire (pas à la fiche bénévole classique, qui reste sans
limite) : au plus **deux** vœux de poste, et au plus **un** artiste
souhaité par jour de festival (`app/api/forms.py:_check_one_artist_wish_per_day`).
`GET/PUT/PATCH /editions/{id}/form[...]` (COORDINATION) configurent ;
`GET /editions/{id}/form/public` et `POST .../form/public/submit` sont les
**deux seuls endpoints de toute l'API sans authentification** — protégés
par hCaptcha (`app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` ; vérification
désactivée si absente, comme `EMAIL_ENABLED=false`) et un filet de
sécurité global par édition (`app/auth/rate_limit.py:form_submission_limiter`,
hCaptcha reste la défense principale). Le bénévole créé par une soumission
apparaît immédiatement dans la liste de la coordination, comme n'importe
quel autre bénévole.
Conventions techniques : Conventions techniques :
@@ -202,6 +233,8 @@ Matrice appliquée par les endpoints :
| `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — | | `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — |
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — | | `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
| `badges` (config + génération) | ✔ | — | — | | `badges` (config + génération) | ✔ | — | — |
| `form` (config) | ✔ | — | — |
| `form/public`, `form/public/submit` | *(aucune authentification — voir § 2, `FormField`)* |||
**Authentification — deux systèmes selon le compte**, une même session JWT **Authentification — deux systèmes selon le compte**, une même session JWT
HS256 (`Authorization: Bearer <jwt>`, TTL `session_ttl_hours`) à l'arrivée. HS256 (`Authorization: Bearer <jwt>`, TTL `session_ttl_hours`) à l'arrivée.
@@ -417,8 +450,10 @@ défilement horizontal — jamais de colonnes étroites par défaut.
### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte) ### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte)
SMS · constructeur de formulaire · pointage sur site · SMS · pointage sur site · fonctions d'IA. *(L'envoi d'emails par SMTP est en
fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)* place — voir § 2. Le formulaire public d'inscription est en place — voir § 2
et § 9 ; ce n'était pas prévu à l'origine, retiré de cette liste à
l'étape 21.)*
--- ---
@@ -449,7 +484,8 @@ fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)*
│ │ │ # health, auth, accounts, editions, │ │ │ # health, auth, accounts, editions,
│ │ │ # day_parts, imports, artists, volunteers, │ │ │ # day_parts, imports, artists, volunteers,
│ │ │ # availability, positions, shifts, │ │ │ # availability, positions, shifts,
│ │ │ # assignments, solver, matrix, notifications │ │ │ # assignments, solver, matrix, notifications,
│ │ │ # badges, forms
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles │ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
│ │ ├── email/ # SMTP (sender) + rendu des emails (templates) │ │ ├── email/ # SMTP (sender) + rendu des emails (templates)
│ │ ├── importing/ # import CSV/Excel (parser, text, service) │ │ ├── importing/ # import CSV/Excel (parser, text, service)
@@ -457,6 +493,7 @@ fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)*
│ │ ├── validation/ # moteur pur (models, engine) + loader (base) │ │ ├── validation/ # moteur pur (models, engine) + loader (base)
│ │ ├── solver/ # suggestions OR-Tools (models, engine, loader) │ │ ├── solver/ # suggestions OR-Tools (models, engine, loader)
│ │ ├── badges/ # génération PDF des planches (pdf.py, ReportLab) │ │ ├── badges/ # génération PDF des planches (pdf.py, ReportLab)
│ │ ├── forms/ # hCaptcha (captcha.py) — voir api/forms.py
│ │ ├── bootstrap.py # instance vierge : édition + 1er compte │ │ ├── bootstrap.py # instance vierge : édition + 1er compte
│ │ ├── seed.py # jeu de démonstration (python -m app.seed) │ │ ├── seed.py # jeu de démonstration (python -m app.seed)
│ │ ├── i18n/ │ │ ├── i18n/
@@ -529,10 +566,13 @@ Aucune dépendance locale requise hors Docker.
- Secrets/variables Actions requis (voir `deploy/README.md`) : secrets - Secrets/variables Actions requis (voir `deploy/README.md`) : secrets
`REGISTRY_TOKEN`, `SSH_KEY` ; variables `REGISTRY_HOST`, `REGISTRY_TOKEN`, `SSH_KEY` ; variables `REGISTRY_HOST`,
`REGISTRY_NAMESPACE`, `REGISTRY_USER`, `SSH_HOST`, `SSH_USER`, `REGISTRY_NAMESPACE`, `REGISTRY_USER`, `SSH_HOST`, `SSH_USER`,
`DEPLOY_PATH`, `VITE_EDITION_ID`. `DEPLOY_PATH`, `VITE_EDITION_ID`, `VITE_HCAPTCHA_SITE_KEY` (optionnelle —
clé de site hCaptcha, publique, figée au build du frontend comme
`VITE_EDITION_ID` ; vide ⇒ formulaire public sans vérification anti-robot).
- Le VPS porte un fichier `.env` (non versionné) avec `POSTGRES_USER`, - Le VPS porte un fichier `.env` (non versionné) avec `POSTGRES_USER`,
`POSTGRES_PASSWORD`, `POSTGRES_DB`, `JWT_SECRET`, `PUBLIC_BASE_URL` et les `POSTGRES_PASSWORD`, `POSTGRES_DB`, `JWT_SECRET`, `PUBLIC_BASE_URL`,
variables `EMAIL_ENABLED` / `SMTP_*` (voir `deploy/README.md`). `HCAPTCHA_SECRET_KEY` (optionnelle, secret hCaptcha — voir § 2, `FormField`)
et les variables `EMAIL_ENABLED` / `SMTP_*` (voir `deploy/README.md`).
- `backend` et `frontend` n'écoutent que sur `127.0.0.1` (voir - `backend` et `frontend` n'écoutent que sur `127.0.0.1` (voir
`docker-compose.prod.yml`) : `deploy/Caddyfile.benevoles` est le bloc à `docker-compose.prod.yml`) : `deploy/Caddyfile.benevoles` est le bloc à
ajouter au Caddyfile du serveur pour les exposer publiquement (`/api/*` vers ajouter au Caddyfile du serveur pour les exposer publiquement (`/api/*` vers
@@ -564,6 +604,7 @@ Aucune dépendance locale requise hors Docker.
| 18 | Gestion des référents depuis la fiche bénévole : `PATCH /accounts` (rôle LEAD ↔ VOLUNTEER), `lead_account_id` vérifié (doit être un compte référent de l'édition) | à valider | | 18 | Gestion des référents depuis la fiche bénévole : `PATCH /accounts` (rôle LEAD ↔ VOLUNTEER), `lead_account_id` vérifié (doit être un compte référent de l'édition) | à valider |
| 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend, limiteur de tentatives sur `/auth/login` et `/auth/magic-link` | à valider | | 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend, limiteur de tentatives sur `/auth/login` et `/auth/magic-link` | à valider |
| 20 | Planches de badges bénévoles (PDF) : recto personnalisable (fond + champs nom/poste(s)/catégorie/rôle, éditeur glisser-déposer), verso fixe (même image pour tous), écran « Badges » dédié (`/badges`, COORDINATION) | à valider | | 20 | Planches de badges bénévoles (PDF) : recto personnalisable (fond + champs nom/poste(s)/catégorie/rôle, éditeur glisser-déposer), verso fixe (même image pour tous), écran « Badges » dédié (`/badges`, COORDINATION) | à valider |
| 21 | Formulaire public d'inscription (`/inscription`, sans authentification) : champs activables par la coordination (email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir, vœu de compagnon en texte libre, disponibilité/cotisation déclaratives, photo, commentaire), captcha hCaptcha, ouverture/fermeture, lien à partager, écran de configuration dédié (`/inscriptions`, COORDINATION) — les bénévoles créés rejoignent directement la liste | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+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 import uuid
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import delete 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): def owned_or_404(instance, edition_id: uuid.UUID):
@@ -23,3 +30,50 @@ def replace_links(
) )
for row in pairs: for row in pairs:
session.add(link_model(**row)) 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, AssignmentSource,
BadgeAlign, BadgeAlign,
BadgeFieldType, BadgeFieldType,
FormFieldType,
PositionCategory, PositionCategory,
Role, Role,
) )
@@ -69,6 +70,11 @@ class VolunteerUpdate(ApiModel):
artist_ids: list[uuid.UUID] | None = None artist_ids: list[uuid.UUID] | None = None
wished_position_ids: list[uuid.UUID] | None = None wished_position_ids: list[uuid.UUID] | None = None
wished_companion_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): class VolunteerRead(ApiModel):
@@ -82,6 +88,10 @@ class VolunteerRead(ApiModel):
artist_ids: list[uuid.UUID] artist_ids: list[uuid.UUID]
wished_position_ids: list[uuid.UUID] wished_position_ids: list[uuid.UUID]
wished_companion_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 created_at: dt.datetime
updated_at: dt.datetime updated_at: dt.datetime
@@ -457,3 +467,49 @@ class PlanningNotificationResult(ApiModel):
queued: int queued: int
skipped_no_email: int skipped_no_email: int
recipients: list[uuid.UUID] = Field(default_factory=list) 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 sqlalchemy.orm import aliased
from sqlmodel import Session, select 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.deps import get_edition
from app.api.schemas import ( from app.api.schemas import (
VolunteerCreate, VolunteerCreate,
@@ -93,74 +99,18 @@ def _companion_ids(session: Session, volunteer_id: uuid.UUID) -> list[uuid.UUID]
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead: def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
return VolunteerRead( return VolunteerRead(
**volunteer.model_dump(), **volunteer.model_dump(exclude={"photo", "photo_content_type"}),
artist_ids=_artist_ids(session, volunteer.id), artist_ids=_artist_ids(session, volunteer.id),
wished_position_ids=_wished_position_ids(session, volunteer.id), wished_position_ids=_wished_position_ids(session, volunteer.id),
wished_companion_ids=_companion_ids(session, volunteer.id), wished_companion_ids=_companion_ids(session, volunteer.id),
) has_photo=volunteer.photo is not None,
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
],
) )
def _set_companions( def _set_companions(
session: Session, volunteer: Volunteer, companion_ids: list[uuid.UUID] session: Session, volunteer: Volunteer, companion_ids: list[uuid.UUID]
) -> None: ) -> None:
ids = _resolve_owned_ids( ids = resolve_owned_ids(
session, Volunteer, volunteer.edition_id, companion_ids, "volunteer.unknown" session, Volunteer, volunteer.edition_id, companion_ids, "volunteer.unknown"
) )
if volunteer.id in ids: if volunteer.id in ids:
@@ -195,8 +145,8 @@ def create_volunteer(
) )
session.add(volunteer) session.add(volunteer)
session.flush() session.flush()
_set_artists(session, volunteer, payload.artist_ids) set_artists(session, volunteer, payload.artist_ids)
_set_wished_positions(session, volunteer, payload.wished_position_ids) set_wished_positions(session, volunteer, payload.wished_position_ids)
_set_companions(session, volunteer, payload.wished_companion_ids) _set_companions(session, volunteer, payload.wished_companion_ids)
session.commit() session.commit()
session.refresh(volunteer) 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) @router.patch("/{volunteer_id}", response_model=VolunteerRead)
def update_volunteer( def update_volunteer(
volunteer_id: uuid.UUID, volunteer_id: uuid.UUID,
@@ -289,9 +258,9 @@ def update_volunteer(
setattr(volunteer, field, value) setattr(volunteer, field, value)
session.flush() session.flush()
if artist_ids is not None: 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: 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: if wished_companion_ids is not None:
_set_companions(session, volunteer, wished_companion_ids) _set_companions(session, volunteer, wished_companion_ids)
session.commit() 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 # trois fois ; au-delà c'est soit un abus, soit un email qui n'arrivera pas
# plus vite en insistant. # plus vite en insistant.
magic_link_limiter = RateLimiter(max_attempts=3, window_seconds=300) 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. # (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>" 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" log_level: str = "INFO"
cors_origins: list[str] = ["http://localhost:5173"] 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.catering": "Catering",
"position.category.setup": "Montage", "position.category.setup": "Montage",
"position.category.stage": "Régie", "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.badges import router as badge_router
from app.api.day_parts import router as day_part_router from app.api.day_parts import router as day_part_router
from app.api.editions import router as edition_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.health import router as health_router
from app.api.imports import router as import_router from app.api.imports import router as import_router
from app.api.matrix import router as matrix_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." "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 = FastAPI(title="Gestion des bénévoles", version="0.1.0")
app.add_middleware( app.add_middleware(
@@ -63,3 +73,4 @@ app.include_router(solver_router)
app.include_router(matrix_router) app.include_router(matrix_router)
app.include_router(notification_router) app.include_router(notification_router)
app.include_router(badge_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, BadgeTemplate,
DayPart, DayPart,
Edition, Edition,
FormField,
FormTemplate,
MagicLinkToken, MagicLinkToken,
Position, Position,
Shift, Shift,
@@ -26,6 +28,7 @@ from app.models.enums import (
AssignmentSource, AssignmentSource,
BadgeAlign, BadgeAlign,
BadgeFieldType, BadgeFieldType,
FormFieldType,
PositionCategory, PositionCategory,
Role, Role,
) )
@@ -42,6 +45,9 @@ __all__ = [
"BadgeTemplate", "BadgeTemplate",
"DayPart", "DayPart",
"Edition", "Edition",
"FormField",
"FormFieldType",
"FormTemplate",
"MagicLinkToken", "MagicLinkToken",
"Position", "Position",
"PositionCategory", "PositionCategory",
+64
View File
@@ -18,6 +18,7 @@ from app.models.enums import (
AssignmentSource, AssignmentSource,
BadgeAlign, BadgeAlign,
BadgeFieldType, BadgeFieldType,
FormFieldType,
PositionCategory, PositionCategory,
Role, Role,
) )
@@ -170,6 +171,27 @@ class Volunteer(TimestampMixin, table=True):
last_name: str last_name: str
phone: str | None = None phone: str | None = None
comment: 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( wished_artists: list[Artist] = Relationship(
back_populates="fans", back_populates="fans",
@@ -436,3 +458,45 @@ class BadgeField(TimestampMixin, table=True):
sa_column=Column(Boolean, nullable=False, server_default="false"), sa_column=Column(Boolean, nullable=False, server_default="false"),
) )
color: str = Field(default="#000000") 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" LEFT = "LEFT"
CENTER = "CENTER" CENTER = "CENTER"
RIGHT = "RIGHT" 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", "ortools>=9.11",
"reportlab>=4.2", "reportlab>=4.2",
"pillow>=11.0", "pillow>=11.0",
"httpx>=0.27",
] ]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=8.3", "pytest>=8.3",
"pytest-cov>=6.0", "pytest-cov>=6.0",
"httpx>=0.27",
"ruff>=0.8", "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 import models # noqa: F401 (enregistre les tables sur SQLModel.metadata)
from app.auth.deps import get_current_account 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.config import get_settings
from app.db import get_session from app.db import get_session
from app.main import app from app.main import app
@@ -31,6 +35,7 @@ def _reset_rate_limiters():
déclenche un ne doit pas polluer les suivants.""" déclenche un ne doit pas polluer les suivants."""
login_limiter.reset() login_limiter.reset()
magic_link_limiter.reset() magic_link_limiter.reset()
form_submission_limiter.reset()
yield 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", "assignment",
"badge_template", "badge_template",
"badge_field", "badge_field",
"form_template",
"form_field",
} }
+2 -2
View File
@@ -106,6 +106,7 @@ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "bcrypt" }, { name = "bcrypt" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" },
{ name = "openpyxl" }, { name = "openpyxl" },
{ name = "ortools" }, { name = "ortools" },
{ name = "pillow" }, { name = "pillow" },
@@ -120,7 +121,6 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "httpx" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
{ name = "ruff" }, { name = "ruff" },
@@ -131,6 +131,7 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.14" }, { name = "alembic", specifier = ">=1.14" },
{ name = "bcrypt", specifier = ">=4.1" }, { name = "bcrypt", specifier = ">=4.1" },
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "openpyxl", specifier = ">=3.1" }, { name = "openpyxl", specifier = ">=3.1" },
{ name = "ortools", specifier = ">=9.11" }, { name = "ortools", specifier = ">=9.11" },
{ name = "pillow", specifier = ">=11.0" }, { name = "pillow", specifier = ">=11.0" },
@@ -145,7 +146,6 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "httpx", specifier = ">=0.27" },
{ name = "pytest", specifier = ">=8.3" }, { name = "pytest", specifier = ">=8.3" },
{ name = "pytest-cov", specifier = ">=6.0" }, { name = "pytest-cov", specifier = ">=6.0" },
{ name = "ruff", specifier = ">=0.8" }, { name = "ruff", specifier = ">=0.8" },
+8
View File
@@ -70,6 +70,7 @@ Variables (Paramètres → Actions → Variables) :
| `SSH_USER` | Utilisateur SSH | | `SSH_USER` | Utilisateur SSH |
| `DEPLOY_PATH` | Dossier du projet sur le VPS (ex. `/opt/benevoles`) | | `DEPLOY_PATH` | Dossier du projet sur le VPS (ex. `/opt/benevoles`) |
| `VITE_EDITION_ID` | UUID de l'édition de production — **à ajouter après** l'étape 3 | | `VITE_EDITION_ID` | UUID de l'édition de production — **à ajouter après** l'étape 3 |
| `VITE_HCAPTCHA_SITE_KEY` | Clé de site hCaptcha (publique) — voir étape 1, formulaire public d'inscription. Optionnelle : vide ⇒ formulaire sans vérification anti-robot |
La clé publique correspondant à `SSH_KEY` doit être dans le La clé publique correspondant à `SSH_KEY` doit être dans le
`~/.ssh/authorized_keys` de `SSH_USER` sur le VPS. `~/.ssh/authorized_keys` de `SSH_USER` sur le VPS.
@@ -100,6 +101,13 @@ SMTP_PASSWORD=<mot de passe SMTP>
SMTP_STARTTLS=true SMTP_STARTTLS=true
SMTP_SSL=false SMTP_SSL=false
SMTP_FROM=Festival <no-reply@mon-domaine.fr> SMTP_FROM=Festival <no-reply@mon-domaine.fr>
# Formulaire public d'inscription (/inscription). Vide ⇒ aucune vérification
# anti-robot (déconseillé en public) — créer un compte gratuit sur
# https://hcaptcha.com, puis renseigner ici le secret du couple site/secret
# (la clé de site va, elle, dans la variable Gitea VITE_HCAPTCHA_SITE_KEY —
# voir tableau ci-dessus — car figée au build du frontend, pas modifiable ici).
HCAPTCHA_SECRET_KEY=
EOF EOF
``` ```
+6 -3
View File
@@ -2,9 +2,11 @@ name: benevoles
# Déployé sur le VPS. Les variables sont lues depuis un fichier .env situé à # Déployé sur le VPS. Les variables sont lues depuis un fichier .env situé à
# côté de ce fichier (non versionné) : POSTGRES_USER, POSTGRES_PASSWORD, # côté de ce fichier (non versionné) : POSTGRES_USER, POSTGRES_PASSWORD,
# POSTGRES_DB, JWT_SECRET, PUBLIC_BASE_URL et les variables SMTP_* / EMAIL_ENABLED. # POSTGRES_DB, JWT_SECRET, PUBLIC_BASE_URL, HCAPTCHA_SECRET_KEY et les
# BACKEND_IMAGE / FRONTEND_IMAGE sont exportées par le pipeline de déploiement # variables SMTP_* / EMAIL_ENABLED. BACKEND_IMAGE / FRONTEND_IMAGE sont
# (.gitea/workflows/ci-cd.yml). # exportées par le pipeline de déploiement (.gitea/workflows/ci-cd.yml) —
# VITE_HCAPTCHA_SITE_KEY, elle, est figée au build de l'image frontend (voir
# ci-cd.yml et deploy/README.md), pas modifiable ici.
# #
# Les deux services ne publient que sur `127.0.0.1` : c'est le Caddy du VPS # Les deux services ne publient que sur `127.0.0.1` : c'est le Caddy du VPS
# (hors de ce compose, voir deploy/README.md) qui reçoit le trafic public et # (hors de ce compose, voir deploy/README.md) qui reçoit le trafic public et
@@ -41,6 +43,7 @@ services:
SMTP_STARTTLS: ${SMTP_STARTTLS:-true} SMTP_STARTTLS: ${SMTP_STARTTLS:-true}
SMTP_SSL: ${SMTP_SSL:-false} SMTP_SSL: ${SMTP_SSL:-false}
SMTP_FROM: ${SMTP_FROM:-} SMTP_FROM: ${SMTP_FROM:-}
HCAPTCHA_SECRET_KEY: ${HCAPTCHA_SECRET_KEY:-}
TZ: Europe/Paris TZ: Europe/Paris
depends_on: depends_on:
db: db:
+7
View File
@@ -35,6 +35,9 @@ services:
SMTP_STARTTLS: ${SMTP_STARTTLS:-true} SMTP_STARTTLS: ${SMTP_STARTTLS:-true}
SMTP_SSL: ${SMTP_SSL:-false} SMTP_SSL: ${SMTP_SSL:-false}
SMTP_FROM: ${SMTP_FROM:-Festival des bénévoles <no-reply@example.test>} SMTP_FROM: ${SMTP_FROM:-Festival des bénévoles <no-reply@example.test>}
# Formulaire public d'inscription (app/api/forms.py). Vide en dev ⇒
# vérification hCaptcha désactivée (voir app/forms/captcha.py).
HCAPTCHA_SECRET_KEY: ${HCAPTCHA_SECRET_KEY:-}
TZ: Europe/Paris TZ: Europe/Paris
ports: ports:
- "8000:8000" - "8000:8000"
@@ -54,6 +57,10 @@ services:
# existante, p. ex. après `docker compose run --rm backend uv run python -m app.seed`). # existante, p. ex. après `docker compose run --rm backend uv run python -m app.seed`).
VITE_API_MODE: ${VITE_API_MODE:-mock} VITE_API_MODE: ${VITE_API_MODE:-mock}
VITE_EDITION_ID: ${VITE_EDITION_ID:-} VITE_EDITION_ID: ${VITE_EDITION_ID:-}
# Clé de site hCaptcha (publique) : sans elle, le formulaire public
# d'inscription s'affiche sans vérification anti-robot (voir
# PublicSignupPage.tsx).
VITE_HCAPTCHA_SITE_KEY: ${HCAPTCHA_SITE_KEY:-}
# Cible du proxy `/api` de Vite à l'intérieur du réseau compose. # Cible du proxy `/api` de Vite à l'intérieur du réseau compose.
VITE_API_TARGET: http://backend:8000 VITE_API_TARGET: http://backend:8000
ports: ports:
+2
View File
@@ -16,8 +16,10 @@ COPY . .
ARG VITE_API_MODE=http ARG VITE_API_MODE=http
ARG VITE_EDITION_ID ARG VITE_EDITION_ID
ARG VITE_HCAPTCHA_SITE_KEY
ENV VITE_API_MODE=$VITE_API_MODE ENV VITE_API_MODE=$VITE_API_MODE
ENV VITE_EDITION_ID=$VITE_EDITION_ID ENV VITE_EDITION_ID=$VITE_EDITION_ID
ENV VITE_HCAPTCHA_SITE_KEY=$VITE_HCAPTCHA_SITE_KEY
RUN npm run build RUN npm run build
+77
View File
@@ -20,6 +20,7 @@ import { AuthContext, AuthProvider, type AuthContextValue } from '@/auth/auth-co
import { routes } from '@/app/router'; import { routes } from '@/app/router';
import { BadgesPage } from '@/pages/BadgesPage'; import { BadgesPage } from '@/pages/BadgesPage';
import { ConfigPage } from '@/pages/ConfigPage'; import { ConfigPage } from '@/pages/ConfigPage';
import { FormBuilderPage } from '@/pages/FormBuilderPage';
import { GrillePage } from '@/pages/GrillePage'; import { GrillePage } from '@/pages/GrillePage';
import { MobileLeadPage } from '@/pages/MobileLeadPage'; import { MobileLeadPage } from '@/pages/MobileLeadPage';
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage'; import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
@@ -81,6 +82,79 @@ async function checkAuth() {
); );
} }
/** Exerce le formulaire public de bout en bout (mode mock) : configuration,
* lecture publique, soumission, vœux (max 2 postes, un artiste par jour),
* et le bénévole créé doit apparaître dans la liste de la coordination. */
async function checkPublicForm() {
const positions = await api.positions.list(e);
const artists = await api.artists.list(e);
const beforeCount = (await api.volunteers.list(e)).length;
await api.forms.setFields(e, {
fields: ['EMAIL', 'POSITION_WISHES', 'ARTIST_WISHES', 'COMMENT'],
});
const config = await api.forms.getPublicConfig(e);
if (!config.is_open) throw new Error('formulaire public : devrait être ouvert par défaut');
if (config.positions.length !== positions.length) {
throw new Error('formulaire public : postes manquants dans la config publique');
}
const sameDay = artists.filter((a) => a.performance_date === artists[0]?.performance_date);
let tooManyArtistsRejectedWith = 'non';
if (sameDay.length >= 2) {
try {
await api.forms.submitPublic(e, {
first_name: 'Test',
last_name: 'Smoke',
hcaptcha_token: 'dev',
artist_wish_ids: [sameDay[0].id, sameDay[1].id],
});
} catch (err) {
tooManyArtistsRejectedWith = err instanceof ApiError ? err.code : String(err);
}
}
let tooManyPositionsRejectedWith = 'non';
if (positions.length >= 3) {
try {
await api.forms.submitPublic(e, {
first_name: 'Test',
last_name: 'Smoke',
hcaptcha_token: 'dev',
position_wish_ids: positions.slice(0, 3).map((p) => p.id),
});
} catch (err) {
tooManyPositionsRejectedWith = err instanceof ApiError ? err.code : String(err);
}
}
const result = await api.forms.submitPublic(e, {
first_name: 'test',
last_name: 'smoke',
hcaptcha_token: 'dev',
email: 'test.smoke@example.org',
comment: 'Créé par le smoke test',
position_wish_ids: positions.slice(0, 2).map((p) => p.id),
artist_wish_ids: artists[0] ? [artists[0].id] : [],
});
const created = await api.volunteers.get(e, result.volunteer_id);
const afterCount = (await api.volunteers.list(e)).length;
console.log(
JSON.stringify(
{
formOpenByDefault: config.is_open,
tooManyPositionsRejectedWith,
tooManyArtistsRejectedWith,
createdVolunteerName: `${created.first_name} ${created.last_name}`,
createdVolunteerAppearsInList: afterCount === beforeCount + 1,
},
null,
2,
),
);
}
async function prefetchCore(client: QueryClient) { async function prefetchCore(client: QueryClient) {
await Promise.all([ await Promise.all([
client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }), client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }),
@@ -148,9 +222,11 @@ function renderPublic(path: string) {
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
await checkAuth().catch((err) => fail('checkAuth', err)); await checkAuth().catch((err) => fail('checkAuth', err));
await checkPublicForm().catch((err) => fail('checkPublicForm', err));
renderPublic('/connexion'); renderPublic('/connexion');
renderPublic('/connexion/coordination'); renderPublic('/connexion/coordination');
renderPublic('/inscription'); // formulaire public, hors de <RequireAuth/>
if (coordinator) { if (coordinator) {
setAuthToken(coordinatorToken); setAuthToken(coordinatorToken);
@@ -158,6 +234,7 @@ if (coordinator) {
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage)); await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
await renderProtected('/configuration', coordinator, createElement(ConfigPage)); await renderProtected('/configuration', coordinator, createElement(ConfigPage));
await renderProtected('/badges', coordinator, createElement(BadgesPage)); await renderProtected('/badges', coordinator, createElement(BadgesPage));
await renderProtected('/inscriptions', coordinator, createElement(FormBuilderPage));
} }
if (volunteerAccount) { if (volunteerAccount) {
const vid = volunteerAccount.volunteer_id as string; const vid = volunteerAccount.volunteer_id as string;
+74
View File
@@ -23,10 +23,13 @@ import type {
DayPartCreate, DayPartCreate,
DayPartUpdate, DayPartUpdate,
EditionUpdate, EditionUpdate,
FormFieldsSet,
FormTemplateUpdate,
ImportMapping, ImportMapping,
PlanningNotificationRequest, PlanningNotificationRequest,
PositionCreate, PositionCreate,
PositionUpdate, PositionUpdate,
PublicFormSubmission,
ShiftCreate, ShiftCreate,
ShiftUpdate, ShiftUpdate,
VolunteerCreate, VolunteerCreate,
@@ -97,6 +100,30 @@ export function useVolunteerPlanning(volunteerId: string | null | undefined) {
}); });
} }
/** URL objet (aperçu) de la photo d'un bénévole (formulaire public), ou
* `null` si absente. */
export function useVolunteerPhotoUrl(volunteerId: string | null | undefined, hasPhoto: boolean) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let objectUrl: string | null = null;
let cancelled = false;
if (!hasPhoto || !volunteerId) {
setUrl(null);
return;
}
api.volunteers.photoBlob(edition, volunteerId).then((blob) => {
if (cancelled || !blob) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [volunteerId, hasPhoto]);
return url;
}
export function useShifts(positionId?: string) { export function useShifts(positionId?: string) {
return useQuery({ return useQuery({
queryKey: queryKeys.shifts(positionId), queryKey: queryKeys.shifts(positionId),
@@ -553,3 +580,50 @@ export function useGenerateBadges() {
mutationFn: (body: BadgeGenerateRequest) => api.badges.generate(edition, body), mutationFn: (body: BadgeGenerateRequest) => api.badges.generate(edition, body),
}); });
} }
// --------------------------------------------------------------------------- //
// Formulaire public d'inscription //
// --------------------------------------------------------------------------- //
/** Configuration du formulaire (COORDINATION). */
export function useFormTemplate() {
return useQuery({
queryKey: ['formTemplate', edition],
queryFn: () => api.forms.getTemplate(edition),
});
}
function useInvalidateFormTemplate() {
const client = useQueryClient();
return () => client.invalidateQueries({ queryKey: ['formTemplate', edition] });
}
export function useSetFormFields() {
const invalidate = useInvalidateFormTemplate();
return useMutation({
mutationFn: (body: FormFieldsSet) => api.forms.setFields(edition, body),
onSuccess: invalidate,
});
}
export function useSetFormOpen() {
const invalidate = useInvalidateFormTemplate();
return useMutation({
mutationFn: (body: FormTemplateUpdate) => api.forms.setOpen(edition, body),
onSuccess: invalidate,
});
}
/** Configuration publique du formulaire — utilisable sans compte connecté
* (écran `/inscription`, hors de `RequireAuth`). */
export function usePublicFormConfig() {
return useQuery({
queryKey: ['publicFormConfig', edition],
queryFn: () => api.forms.getPublicConfig(edition),
});
}
export function useSubmitPublicForm() {
return useMutation({
mutationFn: (body: PublicFormSubmission) => api.forms.submitPublic(edition, body),
});
}
+37
View File
@@ -13,12 +13,15 @@ import type {
DayPartCreate, DayPartCreate,
DayPartUpdate, DayPartUpdate,
EditionUpdate, EditionUpdate,
FormFieldsSet,
FormTemplateUpdate,
ImportMapping, ImportMapping,
LoginRequest, LoginRequest,
MagicLinkRequest, MagicLinkRequest,
PlanningNotificationRequest, PlanningNotificationRequest,
PositionCreate, PositionCreate,
PositionUpdate, PositionUpdate,
PublicFormSubmission,
ShiftCreate, ShiftCreate,
ShiftUpdate, ShiftUpdate,
VerifyRequest, VerifyRequest,
@@ -144,6 +147,7 @@ export function createHttpApi(baseUrl: string): PlanningApi {
request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }), request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }),
planning: (editionId, id) => planning: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}/planning`), request(`${base(editionId)}/volunteers/${id}/planning`),
photoBlob: (editionId, id) => requestBlob(`${base(editionId)}/volunteers/${id}/photo`),
}, },
accounts: { accounts: {
list: (editionId) => request(`${base(editionId)}/accounts`), list: (editionId) => request(`${base(editionId)}/accounts`),
@@ -262,6 +266,39 @@ export function createHttpApi(baseUrl: string): PlanningApi {
return blob; return blob;
}, },
}, },
forms: {
getTemplate: (editionId) => request(`${base(editionId)}/form`),
setFields: (editionId, body: FormFieldsSet) =>
request(`${base(editionId)}/form/fields`, {
method: 'PUT',
body: JSON.stringify(body),
}),
setOpen: (editionId, body: FormTemplateUpdate) =>
request(`${base(editionId)}/form`, { method: 'PATCH', body: JSON.stringify(body) }),
getPublicConfig: (editionId) => request(`${base(editionId)}/form/public`),
submitPublic: (editionId, body: PublicFormSubmission) => {
const form = new FormData();
form.append('first_name', body.first_name);
form.append('last_name', body.last_name);
form.append('hcaptcha_token', body.hcaptcha_token);
if (body.email) form.append('email', body.email);
if (body.phone) form.append('phone', body.phone);
if (body.comment) form.append('comment', body.comment);
if (body.companion_wish_note) form.append('companion_wish_note', body.companion_wish_note);
if (body.available !== undefined) form.append('available', String(body.available));
if (body.willing_to_pay_fee !== undefined) {
form.append('willing_to_pay_fee', String(body.willing_to_pay_fee));
}
if (body.position_wish_ids) {
form.append('position_wish_ids', JSON.stringify(body.position_wish_ids));
}
if (body.artist_wish_ids) {
form.append('artist_wish_ids', JSON.stringify(body.artist_wish_ids));
}
if (body.photo) form.append('photo', body.photo);
return request(`${base(editionId)}/form/public/submit`, { method: 'POST', body: form });
},
},
solver: { solver: {
run: (editionId) => run: (editionId) =>
request(`${base(editionId)}/solver/suggestions`, { method: 'POST' }), request(`${base(editionId)}/solver/suggestions`, { method: 'POST' }),
+13
View File
@@ -8,6 +8,7 @@ import type {
BadgeFieldRead, BadgeFieldRead,
DayPartRead, DayPartRead,
EditionRead, EditionRead,
FormFieldType,
PositionCategory, PositionCategory,
PositionRead, PositionRead,
ShiftRead, ShiftRead,
@@ -49,6 +50,12 @@ export interface MockState {
backImageType: string | null; backImageType: string | null;
fields: BadgeFieldRead[]; fields: BadgeFieldRead[];
}; };
formTemplate: {
isOpen: boolean;
fields: FormFieldType[];
};
/** volunteerId -> photo (formulaire public), data URL. */
photos: Map<string, string>;
seq: number; seq: number;
} }
@@ -258,6 +265,10 @@ export function buildMockState(): MockState {
artist_ids, artist_ids,
wished_position_ids, wished_position_ids,
wished_companion_ids, wished_companion_ids,
has_photo: false,
companion_wish_note: null,
self_declared_available: false,
willing_to_pay_fee: false,
created_at: NOW, created_at: NOW,
updated_at: NOW, updated_at: NOW,
}); });
@@ -431,6 +442,8 @@ export function buildMockState(): MockState {
backImageType: null, backImageType: null,
fields: [], fields: [],
}, },
formTemplate: { isOpen: true, fields: [] },
photos: new Map(),
seq, seq,
}; };
} }
+127
View File
@@ -19,6 +19,10 @@ import type {
DayPartRead, DayPartRead,
DayPartUpdate, DayPartUpdate,
EditionUpdate, EditionUpdate,
FormFieldsSet,
FormSubmissionResult,
FormTemplateRead,
FormTemplateUpdate,
ImportAnalyzeResponse, ImportAnalyzeResponse,
ImportMapping, ImportMapping,
ImportPreviewRow, ImportPreviewRow,
@@ -26,6 +30,8 @@ import type {
ImportRowReport, ImportRowReport,
MatrixResponse, MatrixResponse,
PlanningNotificationRequest, PlanningNotificationRequest,
PublicFormConfig,
PublicFormSubmission,
ShiftRead, ShiftRead,
SolverRunResult, SolverRunResult,
SolverSuggestion, SolverSuggestion,
@@ -102,6 +108,11 @@ const imageSizeMm = async (file: File): Promise<{ width_mm: number; height_mm: n
return { width_mm, height_mm }; return { width_mm, height_mm };
}; };
// Approche de `str.title()` (Python) utilisé côté back-end à la soumission
// du formulaire public — capitalise chaque mot (« - » compris).
const titleCase = (s: string): string =>
s.toLowerCase().replace(/\b\p{L}/gu, (c) => c.toUpperCase());
const fileToDataUrl = (file: File): Promise<string> => const fileToDataUrl = (file: File): Promise<string> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
@@ -831,6 +842,10 @@ export function createMockApi(): PlanningApi {
'position.unknown', 'position.unknown',
), ),
wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []), wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []),
has_photo: false,
companion_wish_note: null,
self_declared_available: false,
willing_to_pay_fee: false,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
@@ -861,6 +876,15 @@ export function createMockApi(): PlanningApi {
...(body.wished_companion_ids != null ...(body.wished_companion_ids != null
? { wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids, v.id) } ? { wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids, v.id) }
: {}), : {}),
...('companion_wish_note' in body
? { companion_wish_note: body.companion_wish_note ?? null }
: {}),
...('self_declared_available' in body && body.self_declared_available != null
? { self_declared_available: body.self_declared_available }
: {}),
...('willing_to_pay_fee' in body && body.willing_to_pay_fee != null
? { willing_to_pay_fee: body.willing_to_pay_fee }
: {}),
updated_at: touch(), updated_at: touch(),
}); });
return delay(v); return delay(v);
@@ -869,6 +893,7 @@ export function createMockApi(): PlanningApi {
state.volunteers = state.volunteers.filter((x) => x.id !== id); state.volunteers = state.volunteers.filter((x) => x.id !== id);
state.assignments = state.assignments.filter((a) => a.volunteer_id !== id); state.assignments = state.assignments.filter((a) => a.volunteer_id !== id);
state.availability.delete(id); state.availability.delete(id);
state.photos.delete(id);
// RGPD : effacement complet, y compris le compte de connexion éventuel // RGPD : effacement complet, y compris le compte de connexion éventuel
// et les vœux de compagnon dont il était la cible. // et les vœux de compagnon dont il était la cible.
state.accounts = state.accounts.filter((a) => a.volunteer_id !== id); state.accounts = state.accounts.filter((a) => a.volunteer_id !== id);
@@ -877,6 +902,11 @@ export function createMockApi(): PlanningApi {
}); });
return delay(undefined); return delay(undefined);
}, },
photoBlob: async (_edition, id) => {
const dataUrl = state.photos.get(id);
if (!dataUrl) return null;
return (await fetch(dataUrl)).blob();
},
planning: (_edition, id) => { planning: (_edition, id) => {
if (!state.volunteers.some((v) => v.id === id)) return notFound(); if (!state.volunteers.some((v) => v.id === id)) return notFound();
const entries = state.assignments const entries = state.assignments
@@ -1147,6 +1177,10 @@ export function createMockApi(): PlanningApi {
artist_ids: [], artist_ids: [],
wished_position_ids: [], wished_position_ids: [],
wished_companion_ids: [], wished_companion_ids: [],
has_photo: false,
companion_wish_note: null,
self_declared_available: false,
willing_to_pay_fee: false,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
@@ -1430,6 +1464,99 @@ export function createMockApi(): PlanningApi {
}, },
}, },
forms: {
getTemplate: (): Promise<FormTemplateRead> =>
delay({
edition_id: state.edition.id,
is_open: state.formTemplate.isOpen,
fields: state.formTemplate.fields,
}),
setFields: (_edition, body: FormFieldsSet) => {
state.formTemplate.fields = [...new Set(body.fields)];
return delay<FormTemplateRead>({
edition_id: state.edition.id,
is_open: state.formTemplate.isOpen,
fields: state.formTemplate.fields,
});
},
setOpen: (_edition, body: FormTemplateUpdate) => {
state.formTemplate.isOpen = body.is_open;
return delay<FormTemplateRead>({
edition_id: state.edition.id,
is_open: state.formTemplate.isOpen,
fields: state.formTemplate.fields,
});
},
getPublicConfig: (): Promise<PublicFormConfig> => {
const fields = state.formTemplate.fields;
return delay({
is_open: state.formTemplate.isOpen,
fields,
positions: fields.includes('POSITION_WISHES')
? state.positions.map((p) => ({ id: p.id, name: p.name }))
: [],
artists: fields.includes('ARTIST_WISHES')
? state.artists.map((a) => ({
id: a.id,
name: a.name,
performance_date: a.performance_date,
}))
: [],
});
},
submitPublic: async (_edition, body: PublicFormSubmission) => {
if (!state.formTemplate.isOpen) throw new ApiError(422, 'form.closed');
if (!body.hcaptcha_token) throw new ApiError(422, 'form.captcha_failed');
if (!body.first_name.trim() || !body.last_name.trim()) {
throw new ApiError(422, 'import.row.missing_required_field');
}
const fields = new Set(state.formTemplate.fields);
const positionIds = fields.has('POSITION_WISHES') ? (body.position_wish_ids ?? []) : [];
if (positionIds.length > 2) throw new ApiError(422, 'form.too_many_position_wishes');
const artistIds = fields.has('ARTIST_WISHES') ? (body.artist_wish_ids ?? []) : [];
const artistDates = artistIds.map(
(id) => state.artists.find((a) => a.id === id)?.performance_date,
);
if (new Set(artistDates).size !== artistDates.length) {
throw new ApiError(422, 'form.too_many_artists_same_day');
}
const now = touch();
const volunteer: VolunteerRead = {
id: `v${state.seq++}`,
edition_id: state.edition.id,
first_name: titleCase(body.first_name.trim()),
last_name: titleCase(body.last_name.trim()),
email: fields.has('EMAIL') ? body.email?.trim() || null : null,
phone: fields.has('PHONE') ? body.phone?.trim() || null : null,
comment: fields.has('COMMENT') ? body.comment?.trim() || null : null,
artist_ids: resolveWishes(state.artists, artistIds, 'artist.unknown'),
wished_position_ids: resolveWishes(state.positions, positionIds, 'position.unknown'),
wished_companion_ids: [],
has_photo: false,
companion_wish_note: fields.has('COMPANION_WISH')
? body.companion_wish_note?.trim() || null
: null,
self_declared_available: fields.has('AVAILABLE') ? Boolean(body.available) : false,
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE')
? Boolean(body.willing_to_pay_fee)
: false,
created_at: now,
updated_at: now,
};
if (fields.has('PHOTO') && body.photo) {
volunteer.has_photo = true;
state.photos.set(volunteer.id, await fileToDataUrl(body.photo));
}
state.volunteers.push(volunteer);
state.availability.set(volunteer.id, []);
return delay<FormSubmissionResult>({ volunteer_id: volunteer.id });
},
},
solver: { solver: {
run: () => delay(runSolver()), run: () => delay(runSolver()),
}, },
+78
View File
@@ -108,6 +108,11 @@ export interface VolunteerRead {
wished_position_ids: string[]; wished_position_ids: string[];
/** Vœux : bénévoles avec qui il voudrait être affecté (0 à N, à sens unique). */ /** Vœux : bénévoles avec qui il voudrait être affecté (0 à N, à sens unique). */
wished_companion_ids: string[]; wished_companion_ids: string[];
/** Renseignés par le formulaire public d'inscription (voir schema forms). */
has_photo: boolean;
companion_wish_note: string | null;
self_declared_available: boolean;
willing_to_pay_fee: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -133,6 +138,9 @@ export interface VolunteerUpdate {
artist_ids?: string[] | null; artist_ids?: string[] | null;
wished_position_ids?: string[] | null; wished_position_ids?: string[] | null;
wished_companion_ids?: string[] | null; wished_companion_ids?: string[] | null;
companion_wish_note?: string | null;
self_declared_available?: boolean | null;
willing_to_pay_fee?: boolean | null;
} }
/** /**
@@ -532,3 +540,73 @@ export interface PlanningNotificationResult {
skipped_no_email: number; skipped_no_email: number;
recipients: string[]; recipients: string[];
} }
// --------------------------------------------------------------------------- //
// Formulaire public d'inscription //
// --------------------------------------------------------------------------- //
// Nom/prénom toujours collectés, pas dans cette liste (voir app/api/forms.py).
export type FormFieldType =
| 'EMAIL'
| 'PHONE'
| 'POSITION_WISHES'
| 'ARTIST_WISHES'
| 'COMPANION_WISH'
| 'AVAILABLE'
| 'WILLING_TO_PAY_FEE'
| 'PHOTO'
| 'COMMENT';
export interface FormFieldsSet {
fields: FormFieldType[];
}
export interface FormTemplateRead {
edition_id: string;
is_open: boolean;
fields: FormFieldType[];
}
export interface FormTemplateUpdate {
is_open: boolean;
}
export interface PublicPositionOption {
id: string;
name: string;
}
export interface PublicArtistOption {
id: string;
name: string;
performance_date: string;
}
/** Tout ce qu'il faut pour afficher le formulaire public — jamais plus. */
export interface PublicFormConfig {
is_open: boolean;
fields: FormFieldType[];
positions: PublicPositionOption[];
artists: PublicArtistOption[];
}
export interface FormSubmissionResult {
volunteer_id: string;
}
/** Champs du formulaire public — envoyés en `multipart/form-data` (photo
* possible). `position_wish_ids`/`artist_wish_ids` : JSON stringifié
* (`'["id1","id2"]'`), pas de tableau natif possible en multipart. */
export interface PublicFormSubmission {
first_name: string;
last_name: string;
hcaptcha_token: string;
email?: string;
phone?: string;
comment?: string;
companion_wish_note?: string;
available?: boolean;
willing_to_pay_fee?: boolean;
position_wish_ids?: string[];
artist_wish_ids?: string[];
photo?: File | null;
}
+17
View File
@@ -23,6 +23,10 @@ import type {
DayPartUpdate, DayPartUpdate,
EditionRead, EditionRead,
EditionUpdate, EditionUpdate,
FormFieldsSet,
FormSubmissionResult,
FormTemplateRead,
FormTemplateUpdate,
GenerateShiftsResult, GenerateShiftsResult,
ImportAnalyzeResponse, ImportAnalyzeResponse,
ImportMapping, ImportMapping,
@@ -35,6 +39,8 @@ import type {
PositionCreate, PositionCreate,
PositionRead, PositionRead,
PositionUpdate, PositionUpdate,
PublicFormConfig,
PublicFormSubmission,
ShiftCreate, ShiftCreate,
ShiftRead, ShiftRead,
ShiftUpdate, ShiftUpdate,
@@ -77,6 +83,8 @@ export interface PlanningApi {
remove(editionId: string, id: string): Promise<void>; remove(editionId: string, id: string): Promise<void>;
/** Planning enrichi (poste, lieu, référent) — écran bénévole. */ /** Planning enrichi (poste, lieu, référent) — écran bénévole. */
planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>; planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>;
/** Photo transmise via le formulaire public, `null` si absente. */
photoBlob(editionId: string, id: string): Promise<Blob | null>;
}; };
accounts: { accounts: {
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */ /** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
@@ -153,6 +161,15 @@ export interface PlanningApi {
/** Génère la planche PDF (recto personnalisé + verso fixe). */ /** Génère la planche PDF (recto personnalisé + verso fixe). */
generate(editionId: string, body: BadgeGenerateRequest): Promise<Blob>; generate(editionId: string, body: BadgeGenerateRequest): Promise<Blob>;
}; };
forms: {
/** Configuration (COORDINATION uniquement). */
getTemplate(editionId: string): Promise<FormTemplateRead>;
setFields(editionId: string, body: FormFieldsSet): Promise<FormTemplateRead>;
setOpen(editionId: string, body: FormTemplateUpdate): Promise<FormTemplateRead>;
/** Publics — aucune authentification, utilisables sans compte connecté. */
getPublicConfig(editionId: string): Promise<PublicFormConfig>;
submitPublic(editionId: string, body: PublicFormSubmission): Promise<FormSubmissionResult>;
};
solver: { solver: {
/** Calcule des suggestions (CP-SAT) ; ne persiste jamais d'affectation. */ /** Calcule des suggestions (CP-SAT) ; ne persiste jamais d'affectation. */
run(editionId: string): Promise<SolverRunResult>; run(editionId: string): Promise<SolverRunResult>;
+7
View File
@@ -6,14 +6,20 @@ import { AdminLoginPage } from '@/pages/auth/AdminLoginPage';
import { ConnexionPage } from '@/pages/auth/ConnexionPage'; import { ConnexionPage } from '@/pages/auth/ConnexionPage';
import { BadgesPage } from '@/pages/BadgesPage'; import { BadgesPage } from '@/pages/BadgesPage';
import { ConfigPage } from '@/pages/ConfigPage'; import { ConfigPage } from '@/pages/ConfigPage';
import { FormBuilderPage } from '@/pages/FormBuilderPage';
import { GrillePage } from '@/pages/GrillePage'; import { GrillePage } from '@/pages/GrillePage';
import { MobileLeadPage } from '@/pages/MobileLeadPage'; import { MobileLeadPage } from '@/pages/MobileLeadPage';
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage'; import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
import { PublicSignupPage } from '@/pages/PublicSignupPage';
import { VolunteerViewPage } from '@/pages/VolunteerViewPage'; import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
export const routes: RouteObject[] = [ export const routes: RouteObject[] = [
{ path: '/connexion', element: <ConnexionPage /> }, { path: '/connexion', element: <ConnexionPage /> },
{ path: '/connexion/coordination', element: <AdminLoginPage /> }, { path: '/connexion/coordination', element: <AdminLoginPage /> },
// Formulaire public d'inscription : aucun compte requis, donc hors de
// <RequireAuth/> — voir app/api/forms.py (les seuls endpoints publics de
// toute l'API).
{ path: '/inscription', element: <PublicSignupPage /> },
{ {
element: <RequireAuth />, element: <RequireAuth />,
children: [ children: [
@@ -28,6 +34,7 @@ export const routes: RouteObject[] = [
{ path: 'mobile-referent', element: <MobileLeadPage /> }, { path: 'mobile-referent', element: <MobileLeadPage /> },
{ path: 'configuration', element: <ConfigPage /> }, { path: 'configuration', element: <ConfigPage /> },
{ path: 'badges', element: <BadgesPage /> }, { path: 'badges', element: <BadgesPage /> },
{ path: 'inscriptions', element: <FormBuilderPage /> },
], ],
}, },
], ],
+2
View File
@@ -10,6 +10,7 @@ export const ROUTE_ROLES: Record<string, Role[]> = {
'/benevoles': ['COORDINATOR'], '/benevoles': ['COORDINATOR'],
'/configuration': ['COORDINATOR'], '/configuration': ['COORDINATOR'],
'/badges': ['COORDINATOR'], '/badges': ['COORDINATOR'],
'/inscriptions': ['COORDINATOR'],
'/mobile-benevole': ['VOLUNTEER'], '/mobile-benevole': ['VOLUNTEER'],
'/mobile-referent': ['LEAD'], '/mobile-referent': ['LEAD'],
}; };
@@ -21,6 +22,7 @@ export const NAV_TABS: Array<{ to: string; num: string; labelKey: string }> = [
{ to: '/mobile-referent', num: '05', labelKey: 'tab.mobileLead' }, { to: '/mobile-referent', num: '05', labelKey: 'tab.mobileLead' },
{ to: '/configuration', num: '06', labelKey: 'tab.config' }, { to: '/configuration', num: '06', labelKey: 'tab.config' },
{ to: '/badges', num: '07', labelKey: 'tab.badges' }, { to: '/badges', num: '07', labelKey: 'tab.badges' },
{ to: '/inscriptions', num: '08', labelKey: 'tab.forms' },
]; ];
export function canAccess(pathname: string, role: Role): boolean { export function canAccess(pathname: string, role: Role): boolean {
+47 -1
View File
@@ -300,6 +300,8 @@
"config.vol.noCompanions": "Aucun bénévole choisi", "config.vol.noCompanions": "Aucun bénévole choisi",
"config.vol.pickCompanion": "Ajouter un bénévole…", "config.vol.pickCompanion": "Ajouter un bénévole…",
"config.vol.searchCompanion": "Rechercher un bénévole…", "config.vol.searchCompanion": "Rechercher un bénévole…",
"config.vol.publicForm.title": "Formulaire public",
"config.vol.publicForm.hint": "Réponses saisies par le bénévole lui-même via le formulaire public d'inscription.",
"config.vol.colWishes": "VŒUX", "config.vol.colWishes": "VŒUX",
"config.vol.wishesCount": "{count} vœu(x)", "config.vol.wishesCount": "{count} vœu(x)",
"config.vol.addAvailabilityHint": "Les disponibilités se renseignent sur la fiche du bénévole, une fois celui-ci créé.", "config.vol.addAvailabilityHint": "Les disponibilités se renseignent sur la fiche du bénévole, une fois celui-ci créé.",
@@ -360,5 +362,49 @@
"badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois.", "badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois.",
"badge.invalid_size": "La taille du badge doit être strictement positive.", "badge.invalid_size": "La taille du badge doit être strictement positive.",
"badge.no_fields": "Activez au moins un champ sur le recto avant de générer.", "badge.no_fields": "Activez au moins un champ sur le recto avant de générer.",
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection." "badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
"tab.forms": "Inscriptions",
"form.field.email": "Email",
"form.field.phone": "Téléphone",
"form.field.positionWishes": "Vœux de poste",
"form.field.positionWishes.help": "Deux vœux au maximum, pour forcer une vraie priorisation.",
"form.field.artistWishes": "Concert(s) à voir",
"form.field.artistWishes.help": "Un seul artiste souhaité par soir de festival.",
"form.field.companionWish": "Collègue apprécié",
"form.field.companionWish.help": "Texte libre : la personne visée n'est pas forcément déjà inscrite. À résoudre ensuite depuis sa fiche.",
"form.field.available": "Case « je suis disponible »",
"form.field.available.help": "Purement informatif — n'a aucun lien avec les disponibilités détaillées de la fiche bénévole.",
"form.field.willingToPayFee": "Case « je veux bien payer une cotisation »",
"form.field.photo": "Photo",
"form.field.comment": "Commentaire",
"form.link.title": "Lien du formulaire",
"form.link.hint": "À partager aux bénévoles potentiels — n'importe qui muni du lien peut s'inscrire.",
"form.link.copy": "Copier",
"form.link.copied": "Copié ✓",
"form.status.title": "Inscriptions",
"form.status.open": "OUVERTES",
"form.status.closed": "FERMÉES",
"form.fields.title": "Champs du formulaire",
"form.save": "Enregistrer",
"form.saving": "Enregistrement…",
"form.saved": "Enregistré ✓",
"publicForm.title": "Inscription bénévole",
"publicForm.positionWishes.count": "{count} / {max}",
"publicForm.artistWishes.none": "— Aucun —",
"publicForm.captcha.disabled": "(Vérification anti-robot non configurée sur cette instance.)",
"publicForm.submitting": "Envoi…",
"publicForm.submit": "Envoyer mon inscription",
"publicForm.success.title": "Inscription reçue",
"publicForm.success.body": "Merci ! Ton inscription a bien été enregistrée. La coordination du festival prendra contact avec toi.",
"publicForm.closed.title": "Inscriptions fermées",
"publicForm.closed.body": "Les inscriptions bénévoles ne sont pas ouvertes pour le moment. Reviens un peu plus tard !",
"form.closed": "Les inscriptions sont actuellement fermées.",
"form.captcha_failed": "Vérification anti-robot invalide, réessaie.",
"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."
} }
+3
View File
@@ -7,4 +7,7 @@ export const ENV = {
apiBaseUrl: raw.VITE_API_BASE_URL ?? '/api', apiBaseUrl: raw.VITE_API_BASE_URL ?? '/api',
editionId: raw.VITE_EDITION_ID, editionId: raw.VITE_EDITION_ID,
demoVolunteerId: raw.VITE_DEMO_VOLUNTEER_ID ?? '', demoVolunteerId: raw.VITE_DEMO_VOLUNTEER_ID ?? '',
// Clé de site hCaptcha (publique, sans risque à exposer) — le formulaire
// public d'inscription (`/inscription`) ne s'affiche pas sans elle.
hcaptchaSiteKey: raw.VITE_HCAPTCHA_SITE_KEY ?? '',
}; };
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useState } from 'react';
import { useFormTemplate, useSetFormFields, useSetFormOpen } from '@/api/hooks';
import type { FormFieldType } from '@/api/schema';
import { ErrorNote, Loading } from '@/components/state';
import { Btn, HelpTip, Panel, PanelHeader } from '@/components/ui';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
// Nom/prénom toujours collectés (indispensables à la création du bénévole) —
// pas dans cette liste, voir CLAUDE.md § 2 (Volunteer) et app/api/forms.py.
const FIELD_TYPES: FormFieldType[] = [
'EMAIL',
'PHONE',
'POSITION_WISHES',
'ARTIST_WISHES',
'COMPANION_WISH',
'AVAILABLE',
'WILLING_TO_PAY_FEE',
'PHOTO',
'COMMENT',
];
const FIELD_LABEL_KEY: Record<FormFieldType, string> = {
EMAIL: 'form.field.email',
PHONE: 'form.field.phone',
POSITION_WISHES: 'form.field.positionWishes',
ARTIST_WISHES: 'form.field.artistWishes',
COMPANION_WISH: 'form.field.companionWish',
AVAILABLE: 'form.field.available',
WILLING_TO_PAY_FEE: 'form.field.willingToPayFee',
PHOTO: 'form.field.photo',
COMMENT: 'form.field.comment',
};
const FIELD_HELP_KEY: Record<FormFieldType, string | null> = {
EMAIL: null,
PHONE: null,
POSITION_WISHES: 'form.field.positionWishes.help',
ARTIST_WISHES: 'form.field.artistWishes.help',
COMPANION_WISH: 'form.field.companionWish.help',
AVAILABLE: 'form.field.available.help',
WILLING_TO_PAY_FEE: null,
PHOTO: null,
COMMENT: null,
};
export function FormBuilderPage() {
const template = useFormTemplate();
const setFields = useSetFormFields();
const setOpen = useSetFormOpen();
const [enabled, setEnabled] = useState<Set<FormFieldType> | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (template.data) setEnabled(new Set(template.data.fields));
}, [template.data]);
if (template.isLoading || !enabled) return <Loading />;
if (template.error) return <ErrorNote error={template.error} />;
const toggle = (type: FormFieldType) => {
setEnabled((prev) => {
if (!prev) return prev;
const next = new Set(prev);
if (next.has(type)) next.delete(type);
else next.add(type);
return next;
});
};
const link = `${window.location.origin}/inscription`;
const copyLink = () => {
navigator.clipboard.writeText(link).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className="flex min-h-0 flex-1 flex-col overflow-auto p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start">
<Panel className="max-w-[420px] flex-1">
<PanelHeader>{t('form.link.title')}</PanelHeader>
<div className="flex flex-col gap-3 p-3">
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
{t('form.link.hint')}
</p>
<div className="flex items-center gap-2">
<input
readOnly
value={link}
onFocus={(e) => e.target.select()}
className="w-full border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[11px]"
/>
<Btn variant="ghost" onClick={copyLink}>
{copied ? t('form.link.copied') : t('form.link.copy')}
</Btn>
</div>
<div className="mt-2 flex items-center gap-3 border-t-2 border-ink pt-3">
<span className="font-display text-[11px] font-extrabold uppercase tracking-wide">
{t('form.status.title')}
</span>
<button
type="button"
onClick={() => setOpen.mutate({ is_open: !template.data?.is_open })}
disabled={setOpen.isPending}
className={cx(
'cursor-pointer border-2 border-ink px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wide',
template.data?.is_open ? 'bg-green text-white' : 'bg-orange text-ink',
)}
>
{template.data?.is_open ? t('form.status.open') : t('form.status.closed')}
</button>
</div>
</div>
</Panel>
<Panel className="min-w-[320px] flex-1">
<PanelHeader>{t('form.fields.title')}</PanelHeader>
<div className="flex flex-col divide-y-2 divide-ink/10">
{FIELD_TYPES.map((type) => (
<label
key={type}
className="flex cursor-pointer items-center gap-[10px] p-3 hover:bg-yellow-soft"
>
<input
type="checkbox"
checked={enabled.has(type)}
onChange={() => toggle(type)}
className="h-[16px] w-[16px] accent-ink"
/>
<span className="font-display text-[12px] font-extrabold uppercase tracking-wide">
{t(FIELD_LABEL_KEY[type])}
</span>
{FIELD_HELP_KEY[type] ? <HelpTip text={t(FIELD_HELP_KEY[type]!)} /> : null}
</label>
))}
</div>
<div className="flex items-center gap-3 border-t-2 border-ink p-3">
<Btn
onClick={() => setFields.mutate({ fields: [...enabled] })}
disabled={setFields.isPending}
>
{setFields.isPending ? t('form.saving') : t('form.save')}
</Btn>
{setFields.isSuccess ? (
<span className="font-mono text-[9.5px] font-bold text-green">{t('form.saved')}</span>
) : null}
</div>
</Panel>
</div>
</div>
);
}
+335
View File
@@ -0,0 +1,335 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { ApiError } from '@/api';
import { usePublicFormConfig, useSubmitPublicForm } from '@/api/hooks';
import type { PublicArtistOption } from '@/api/schema';
import { ErrorNote, Loading } from '@/components/state';
import { Btn, FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui';
import { t } from '@/i18n';
import { ENV } from '@/lib/env';
import { longDayLabel } from '@/lib/time';
const MAX_POSITION_WISHES = 2;
// Charge le script hCaptcha une seule fois, quel que soit le nombre de
// montages de <HCaptchaWidget> (StrictMode, navigation aller-retour…).
let hcaptchaScriptPromise: Promise<void> | null = null;
function loadHCaptchaScript(): Promise<void> {
if (!hcaptchaScriptPromise) {
hcaptchaScriptPromise = new Promise((resolve) => {
const script = document.createElement('script');
script.src = 'https://js.hcaptcha.com/1/api.js';
script.async = true;
script.defer = true;
script.onload = () => resolve();
document.head.appendChild(script);
});
}
return hcaptchaScriptPromise;
}
/** Widget hCaptcha (rendu implicite) : le script scanne le DOM et rend les
* `.h-captcha` qu'il trouve. `onToken` reçoit le jeton une fois résolu. */
function HCaptchaWidget({ onToken }: { onToken: (token: string) => void }) {
const containerRef = useRef<HTMLDivElement>(null);
const callbackName = useRef(`hcaptchaCallback${Math.random().toString(36).slice(2)}`);
useEffect(() => {
const win = window as unknown as Record<string, unknown>;
const name = callbackName.current;
win[name] = onToken;
loadHCaptchaScript();
return () => {
delete win[name];
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div
ref={containerRef}
className="h-captcha"
data-sitekey={ENV.hcaptchaSiteKey}
data-callback={callbackName.current}
/>
);
}
function errorMessage(error: unknown): string {
if (error instanceof ApiError) return t(error.code);
return t('error.generic');
}
function groupArtistsByDay(artists: PublicArtistOption[]): Array<[string, PublicArtistOption[]]> {
const byDay = new Map<string, PublicArtistOption[]>();
for (const artist of artists) {
const list = byDay.get(artist.performance_date) ?? [];
list.push(artist);
byDay.set(artist.performance_date, list);
}
return [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b));
}
export function PublicSignupPage() {
const config = usePublicFormConfig();
const submit = useSubmitPublicForm();
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [comment, setComment] = useState('');
const [companionNote, setCompanionNote] = useState('');
const [available, setAvailable] = useState(false);
const [willingToPayFee, setWillingToPayFee] = useState(false);
const [positionIds, setPositionIds] = useState<string[]>([]);
const [artistByDay, setArtistByDay] = useState<Record<string, string>>({});
const [photo, setPhoto] = useState<File | null>(null);
const [captchaToken, setCaptchaToken] = useState('');
const [submitError, setSubmitError] = useState<string | null>(null);
const [volunteerId, setVolunteerId] = useState<string | null>(null);
const artistsByDay = useMemo(
() => groupArtistsByDay(config.data?.artists ?? []),
[config.data?.artists],
);
if (config.isLoading) return <Loading />;
if (config.error) return <ErrorNote error={config.error} />;
if (!config.data) return null;
if (volunteerId) {
return (
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
<Panel>
<PanelHeader>{t('publicForm.success.title')}</PanelHeader>
<p className="p-4 font-mono text-[12px] leading-relaxed">{t('publicForm.success.body')}</p>
</Panel>
</div>
);
}
if (!config.data.is_open) {
return (
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
<Panel>
<PanelHeader>{t('publicForm.closed.title')}</PanelHeader>
<p className="p-4 font-mono text-[12px] leading-relaxed">{t('publicForm.closed.body')}</p>
</Panel>
</div>
);
}
const fields = new Set(config.data.fields);
const toggleEnabled = ENV.hcaptchaSiteKey.length > 0;
const togglePosition = (id: string) => {
setPositionIds((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
if (prev.length >= MAX_POSITION_WISHES) return prev;
return [...prev, id];
});
};
const pickArtistForDay = (day: string, artistId: string) => {
setArtistByDay((prev) => {
const next = { ...prev };
if (!artistId) delete next[day];
else next[day] = artistId;
return next;
});
};
const canSubmit =
firstName.trim().length > 0 &&
lastName.trim().length > 0 &&
(toggleEnabled ? captchaToken.length > 0 : true) &&
!submit.isPending;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitError(null);
try {
const result = await submit.mutateAsync({
first_name: firstName,
last_name: lastName,
hcaptcha_token: captchaToken || 'dev',
email: fields.has('EMAIL') ? email : undefined,
phone: fields.has('PHONE') ? phone : undefined,
comment: fields.has('COMMENT') ? comment : undefined,
companion_wish_note: fields.has('COMPANION_WISH') ? companionNote : undefined,
available: fields.has('AVAILABLE') ? available : undefined,
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE') ? willingToPayFee : undefined,
position_wish_ids: fields.has('POSITION_WISHES') ? positionIds : undefined,
artist_wish_ids: fields.has('ARTIST_WISHES') ? Object.values(artistByDay) : undefined,
photo: fields.has('PHOTO') ? photo : undefined,
});
setVolunteerId(result.volunteer_id);
} catch (err) {
setSubmitError(errorMessage(err));
}
};
return (
<div className="mx-auto flex max-w-[560px] flex-col gap-4 p-4">
<Panel>
<PanelHeader>{t('publicForm.title')}</PanelHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 p-4">
<div className="grid grid-cols-2 gap-3">
<label className="block">
<FieldLabel>{t('config.vol.field.firstName')}</FieldLabel>
<TextInput value={firstName} onChange={setFirstName} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.field.lastName')}</FieldLabel>
<TextInput value={lastName} onChange={setLastName} />
</label>
</div>
{fields.has('EMAIL') ? (
<label className="block">
<FieldLabel>{t('form.field.email')}</FieldLabel>
<TextInput mono value={email} onChange={setEmail} />
</label>
) : null}
{fields.has('PHONE') ? (
<label className="block">
<FieldLabel>{t('form.field.phone')}</FieldLabel>
<TextInput mono value={phone} onChange={setPhone} />
</label>
) : null}
{fields.has('POSITION_WISHES') && config.data.positions.length > 0 ? (
<div>
<FieldLabel>
{t('form.field.positionWishes')} — {t('publicForm.positionWishes.count', {
count: positionIds.length,
max: MAX_POSITION_WISHES,
})}
</FieldLabel>
<div className="flex flex-col gap-[6px] border-2 border-ink bg-cream p-[9px]">
{config.data.positions.map((p) => {
const checked = positionIds.includes(p.id);
const disabled = !checked && positionIds.length >= MAX_POSITION_WISHES;
return (
<label
key={p.id}
className={`flex items-center gap-[8px] font-mono text-[12px] ${disabled ? 'opacity-40' : 'cursor-pointer'}`}
>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={() => togglePosition(p.id)}
className="h-[15px] w-[15px] accent-ink"
/>
{p.name}
</label>
);
})}
</div>
</div>
) : null}
{fields.has('ARTIST_WISHES') && artistsByDay.length > 0 ? (
<div>
<FieldLabel>{t('form.field.artistWishes')}</FieldLabel>
<div className="flex flex-col gap-[8px]">
{artistsByDay.map(([day, artists]) => (
<label key={day} className="block">
<span className="mb-[3px] block font-mono text-[10px] opacity-60">
{longDayLabel(`${day}T12:00`)}
</span>
<select
value={artistByDay[day] ?? ''}
onChange={(e) => pickArtistForDay(day, e.target.value)}
className="w-full border-2 border-ink bg-cream p-[8px] font-mono text-[12px]"
>
<option value="">{t('publicForm.artistWishes.none')}</option>
{artists.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</label>
))}
</div>
</div>
) : null}
{fields.has('COMPANION_WISH') ? (
<label className="block">
<FieldLabel>{t('form.field.companionWish')}</FieldLabel>
<TextInput value={companionNote} onChange={setCompanionNote} />
</label>
) : null}
{fields.has('COMMENT') ? (
<label className="block">
<FieldLabel>{t('form.field.comment')}</FieldLabel>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={3}
className="w-full border-2 border-ink bg-cream p-[9px] font-mono text-[12px]"
/>
</label>
) : null}
{fields.has('AVAILABLE') ? (
<label className="flex cursor-pointer items-center gap-[8px]">
<input
type="checkbox"
checked={available}
onChange={(e) => setAvailable(e.target.checked)}
className="h-[16px] w-[16px] accent-ink"
/>
<span className="font-mono text-[12px]">{t('form.field.available')}</span>
</label>
) : null}
{fields.has('WILLING_TO_PAY_FEE') ? (
<label className="flex cursor-pointer items-center gap-[8px]">
<input
type="checkbox"
checked={willingToPayFee}
onChange={(e) => setWillingToPayFee(e.target.checked)}
className="h-[16px] w-[16px] accent-ink"
/>
<span className="font-mono text-[12px]">{t('form.field.willingToPayFee')}</span>
</label>
) : null}
{fields.has('PHOTO') ? (
<label className="block">
<FieldLabel>{t('form.field.photo')}</FieldLabel>
<input
type="file"
accept="image/*"
capture="environment"
onChange={(e) => setPhoto(e.target.files?.[0] ?? null)}
className="w-full border-2 border-ink bg-cream p-[8px] font-mono text-[11px]"
/>
</label>
) : null}
{toggleEnabled ? (
<HCaptchaWidget onToken={setCaptchaToken} />
) : (
<p className="font-mono text-[9.5px] opacity-50">{t('publicForm.captcha.disabled')}</p>
)}
{submitError ? (
<p className="font-mono text-[11px] font-bold text-orange-dark">{submitError}</p>
) : null}
<Btn type="submit" disabled={!canSubmit}>
{submit.isPending ? t('publicForm.submitting') : t('publicForm.submit')}
</Btn>
</form>
</Panel>
</div>
);
}
@@ -18,6 +18,7 @@ import {
useUpdatePosition, useUpdatePosition,
useUpdateVolunteer, useUpdateVolunteer,
useVolunteerAvailability, useVolunteerAvailability,
useVolunteerPhotoUrl,
useVolunteers, useVolunteers,
} from '@/api/hooks'; } from '@/api/hooks';
import type { import type {
@@ -30,6 +31,7 @@ import type {
PositionRead, PositionRead,
VolunteerCreate, VolunteerCreate,
VolunteerRead, VolunteerRead,
VolunteerUpdate,
} from '@/api/schema'; } from '@/api/schema';
import { Chip, FieldLabel, HelpTip, TextInput } from '@/components/ui'; import { Chip, FieldLabel, HelpTip, TextInput } from '@/components/ui';
import { ErrorNote, Loading } from '@/components/state'; import { ErrorNote, Loading } from '@/components/state';
@@ -158,6 +160,79 @@ function VolunteerAccessSection({
); );
} }
/**
* Réponses du formulaire public d'inscription (voir `app/api/forms.py`) —
* note de compagnon en texte libre (à résoudre manuellement en vrai vœu via
* le sélecteur ci-dessus une fois la personne visée elle-même inscrite),
* cases informatives, photo. N'apparaît que si le bénévole vient
* effectivement de ce formulaire (sinon rien à afficher).
*/
function PublicFormAnswers({
volunteer,
patch,
}: {
volunteer: VolunteerRead;
patch: (body: VolunteerUpdate) => void;
}) {
const photoUrl = useVolunteerPhotoUrl(volunteer.id, volunteer.has_photo);
const hasAnything =
volunteer.has_photo ||
Boolean(volunteer.companion_wish_note) ||
volunteer.self_declared_available ||
volunteer.willing_to_pay_fee;
if (!hasAnything) return null;
return (
<div className="flex flex-col gap-2 border-2 border-ink bg-sand p-[9px]">
<FieldLabel>
{t('config.vol.publicForm.title')} <HelpTip text={t('config.vol.publicForm.hint')} />
</FieldLabel>
{photoUrl ? (
<img
src={photoUrl}
alt=""
className="h-[90px] w-[90px] flex-none border-2 border-ink object-cover"
/>
) : null}
{volunteer.companion_wish_note ? (
<label className="block">
<FieldLabel>{t('form.field.companionWish')}</FieldLabel>
<textarea
value={volunteer.companion_wish_note}
onChange={(e) => patch({ companion_wish_note: e.target.value || null })}
rows={2}
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
/>
</label>
) : null}
<div className="flex flex-wrap gap-[14px]">
<label className="flex cursor-pointer items-center gap-[7px]">
<input
type="checkbox"
checked={volunteer.self_declared_available}
onChange={(e) => patch({ self_declared_available: e.target.checked })}
className="h-[15px] w-[15px] accent-ink"
/>
<span className="font-mono text-[11px]">{t('form.field.available')}</span>
</label>
<label className="flex cursor-pointer items-center gap-[7px]">
<input
type="checkbox"
checked={volunteer.willing_to_pay_fee}
onChange={(e) => patch({ willing_to_pay_fee: e.target.checked })}
className="h-[15px] w-[15px] accent-ink"
/>
<span className="font-mono text-[11px]">{t('form.field.willingToPayFee')}</span>
</label>
</div>
</div>
);
}
/** /**
* Postes dont ce bénévole est référent (`Position.lead_account_id`). Cocher * Postes dont ce bénévole est référent (`Position.lead_account_id`). Cocher
* un poste crée ou met à niveau son accès de connexion en RÉFÉRENT (LEAD) — * un poste crée ou met à niveau son accès de connexion en RÉFÉRENT (LEAD) —
@@ -638,6 +713,8 @@ export function ConfigVolunteers() {
searchPlaceholder={t('config.vol.searchCompanion')} searchPlaceholder={t('config.vol.searchCompanion')}
/> />
<PublicFormAnswers volunteer={selected} patch={patch} />
<AvailabilityEditor volunteerId={selected.id} /> <AvailabilityEditor volunteerId={selected.id} />
<button <button