From 82088f3b0c8351398e4154159a7cef15ca55c5e1 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Mon, 21 Sep 2026 20:24:32 +0200 Subject: [PATCH] =?UTF-8?q?=C3=89tape=2021=20:=20formulaire=20public=20d'i?= =?UTF-8?q?nscription=20des=20b=C3=A9n=C3=A9voles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (), 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 --- .env.example | 6 + .gitea/workflows/ci-cd.yml | 4 + CLAUDE.md | 53 ++- backend/app/api/_common.py | 58 ++- backend/app/api/forms.py | 320 +++++++++++++++++ backend/app/api/schemas.py | 56 +++ backend/app/api/volunteers.py | 97 ++--- backend/app/auth/rate_limit.py | 7 + backend/app/config.py | 6 + backend/app/forms/__init__.py | 3 + backend/app/forms/captcha.py | 46 +++ backend/app/i18n/locales/fr.json | 9 +- backend/app/main.py | 11 + .../migrations/versions/0013_signup_form.py | 94 +++++ backend/app/models/__init__.py | 6 + backend/app/models/domain.py | 64 ++++ backend/app/models/enums.py | 16 + backend/pyproject.toml | 2 +- backend/tests/conftest.py | 7 +- backend/tests/test_forms_api.py | 282 +++++++++++++++ backend/tests/test_forms_captcha.py | 47 +++ backend/tests/test_models.py | 2 + backend/uv.lock | 4 +- deploy/README.md | 8 + deploy/docker-compose.prod.yml | 9 +- docker-compose.yml | 7 + frontend/Dockerfile.prod | 2 + frontend/scripts/smoke.tsx | 77 ++++ frontend/src/api/hooks.ts | 74 ++++ frontend/src/api/http-client.ts | 37 ++ frontend/src/api/mock/dataset.ts | 13 + frontend/src/api/mock/mock-client.ts | 127 +++++++ frontend/src/api/schema.ts | 78 ++++ frontend/src/api/types.ts | 17 + frontend/src/app/router.tsx | 7 + frontend/src/auth/roles.ts | 2 + frontend/src/i18n/locales/fr.json | 48 ++- frontend/src/lib/env.ts | 3 + frontend/src/pages/FormBuilderPage.tsx | 157 ++++++++ frontend/src/pages/PublicSignupPage.tsx | 335 ++++++++++++++++++ .../src/pages/config/ConfigVolunteers.tsx | 77 ++++ 41 files changed, 2197 insertions(+), 81 deletions(-) create mode 100644 backend/app/api/forms.py create mode 100644 backend/app/forms/__init__.py create mode 100644 backend/app/forms/captcha.py create mode 100644 backend/app/migrations/versions/0013_signup_form.py create mode 100644 backend/tests/test_forms_api.py create mode 100644 backend/tests/test_forms_captcha.py create mode 100644 frontend/src/pages/FormBuilderPage.tsx create mode 100644 frontend/src/pages/PublicSignupPage.tsx diff --git a/.env.example b/.env.example index da82b52..8e4be97 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,12 @@ SMTP_FROM=Festival des bénévoles # Alternative OVH si le 587/STARTTLS échoue : SMTP_PORT=465, SMTP_SSL=true, # 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). # 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 diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index be8236a..f5c8bc5 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -19,6 +19,9 @@ # DEPLOY_PATH dossier du projet sur le VPS (ex. /opt/benevoles) # VITE_EDITION_ID UUID de l'édition de production, à renseigner APRÈS # 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 @@ -122,6 +125,7 @@ jobs: docker build \ --build-arg VITE_API_MODE=http \ --build-arg VITE_EDITION_ID="${{ vars.VITE_EDITION_ID }}" \ + --build-arg VITE_HCAPTCHA_SITE_KEY="${{ vars.VITE_HCAPTCHA_SITE_KEY }}" \ -f frontend/Dockerfile.prod \ -t "$IMAGE:${{ gitea.sha }}" -t "$IMAGE:latest" frontend docker push "$IMAGE:${{ gitea.sha }}" diff --git a/CLAUDE.md b/CLAUDE.md index 81c51c3..9674bbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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, exposées par la fiche (`artist_ids`, `wished_position_ids`, `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`, `start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le 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 par planche, de la page de verso correspondante (même image répétée à 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 : @@ -202,6 +233,8 @@ Matrice appliquée par les endpoints : | `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — | | `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — | | `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 HS256 (`Authorization: Bearer `, 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) -SMS · constructeur de formulaire · pointage sur site · -fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)* +SMS · pointage sur site · fonctions d'IA. *(L'envoi d'emails par SMTP est en +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, │ │ │ # day_parts, imports, artists, volunteers, │ │ │ # availability, positions, shifts, -│ │ │ # assignments, solver, matrix, notifications +│ │ │ # assignments, solver, matrix, notifications, +│ │ │ # badges, forms │ │ ├── auth/ # mot de passe + lien magique + JWT + rôles │ │ ├── email/ # SMTP (sender) + rendu des emails (templates) │ │ ├── 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) │ │ ├── solver/ # suggestions OR-Tools (models, engine, loader) │ │ ├── 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 │ │ ├── seed.py # jeu de démonstration (python -m app.seed) │ │ ├── i18n/ @@ -529,10 +566,13 @@ Aucune dépendance locale requise hors Docker. - Secrets/variables Actions requis (voir `deploy/README.md`) : secrets `REGISTRY_TOKEN`, `SSH_KEY` ; variables `REGISTRY_HOST`, `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`, - `POSTGRES_PASSWORD`, `POSTGRES_DB`, `JWT_SECRET`, `PUBLIC_BASE_URL` et les - variables `EMAIL_ENABLED` / `SMTP_*` (voir `deploy/README.md`). + `POSTGRES_PASSWORD`, `POSTGRES_DB`, `JWT_SECRET`, `PUBLIC_BASE_URL`, + `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 `docker-compose.prod.yml`) : `deploy/Caddyfile.benevoles` est le bloc à 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 | | 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 | +| 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 de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/api/_common.py b/backend/app/api/_common.py index 543e30d..aefb678 100644 --- a/backend/app/api/_common.py +++ b/backend/app/api/_common.py @@ -1,10 +1,17 @@ -"""Helpers partagés par les routers CRUD.""" +"""Helpers partagés par les routers CRUD. + +`resolve_owned_ids`/`set_artists`/`set_wished_positions` sont utilisés à la +fois par `volunteers.py` (fiche éditée par la coordination) et `forms.py` +(vœux saisis depuis le formulaire public) — un seul endroit pour la +validation « ces ids appartiennent bien à cette édition ».""" import uuid from fastapi import HTTPException from sqlalchemy import delete -from sqlmodel import Session +from sqlmodel import Session, select + +from app.models import Artist, Position, VolunteerArtist, VolunteerPositionWish def owned_or_404(instance, edition_id: uuid.UUID): @@ -23,3 +30,50 @@ def replace_links( ) for row in pairs: session.add(link_model(**row)) + + +def resolve_owned_ids( + session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID], code: str +) -> list[uuid.UUID]: + """Ordre d'entrée conservé, doublons retirés ; 422 si un id est étranger.""" + wanted = list(dict.fromkeys(ids)) + if wanted: + found = set( + session.exec( + select(model.id).where( + model.edition_id == edition_id, + model.id.in_(wanted), + ) + ) + ) + if len(found) != len(wanted): + raise HTTPException(status_code=422, detail={"code": code}) + return wanted + + +def set_artists(session: Session, volunteer, artist_ids: list[uuid.UUID]) -> None: + ids = resolve_owned_ids( + session, Artist, volunteer.edition_id, artist_ids, "artist.unknown" + ) + replace_links( + session, + VolunteerArtist, + filter_column=VolunteerArtist.volunteer_id, + filter_value=volunteer.id, + pairs=[{"volunteer_id": volunteer.id, "artist_id": aid} for aid in ids], + ) + + +def set_wished_positions( + session: Session, volunteer, position_ids: list[uuid.UUID] +) -> None: + ids = resolve_owned_ids( + session, Position, volunteer.edition_id, position_ids, "position.unknown" + ) + replace_links( + session, + VolunteerPositionWish, + filter_column=VolunteerPositionWish.volunteer_id, + filter_value=volunteer.id, + pairs=[{"volunteer_id": volunteer.id, "position_id": pid} for pid in ids], + ) diff --git a/backend/app/api/forms.py b/backend/app/api/forms.py new file mode 100644 index 0000000..8da8dbc --- /dev/null +++ b/backend/app/api/forms.py @@ -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) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 2e5dd64..9e8b2e0 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -9,6 +9,7 @@ from app.models import ( AssignmentSource, BadgeAlign, BadgeFieldType, + FormFieldType, PositionCategory, Role, ) @@ -69,6 +70,11 @@ class VolunteerUpdate(ApiModel): artist_ids: list[uuid.UUID] | None = None wished_position_ids: list[uuid.UUID] | None = None wished_companion_ids: list[uuid.UUID] | None = None + # Issus du formulaire public (`app/api/forms.py`), éditables ensuite + # depuis la fiche — voir CLAUDE.md § 2 (Volunteer). + companion_wish_note: str | None = None + self_declared_available: bool | None = None + willing_to_pay_fee: bool | None = None class VolunteerRead(ApiModel): @@ -82,6 +88,10 @@ class VolunteerRead(ApiModel): artist_ids: list[uuid.UUID] wished_position_ids: list[uuid.UUID] wished_companion_ids: list[uuid.UUID] + has_photo: bool + companion_wish_note: str | None + self_declared_available: bool + willing_to_pay_fee: bool created_at: dt.datetime updated_at: dt.datetime @@ -457,3 +467,49 @@ class PlanningNotificationResult(ApiModel): queued: int skipped_no_email: int recipients: list[uuid.UUID] = Field(default_factory=list) + + +# --------------------------------------------------------------------------- # +# Formulaire public d'inscription # +# --------------------------------------------------------------------------- # +class FormFieldsSet(ApiModel): + """Remplace l'intégralité des champs activés du formulaire — nom/prénom + toujours collectés, pas dans cette liste (voir `FormFieldType`).""" + + fields: list[FormFieldType] = Field(default_factory=list) + + +class FormTemplateRead(ApiModel): + edition_id: uuid.UUID + is_open: bool + fields: list[FormFieldType] + + +class FormTemplateUpdate(ApiModel): + is_open: bool + + +class PublicPositionOption(ApiModel): + id: uuid.UUID + name: str + + +class PublicArtistOption(ApiModel): + id: uuid.UUID + name: str + performance_date: dt.date + + +class PublicFormConfig(ApiModel): + """Tout ce qu'il faut pour afficher le formulaire public — jamais plus : + ni les bénévoles déjà inscrits, ni les créneaux/affectations de + l'édition (voir `app/api/forms.py:get_public_form`).""" + + is_open: bool + fields: list[FormFieldType] + positions: list[PublicPositionOption] + artists: list[PublicArtistOption] + + +class FormSubmissionResult(ApiModel): + volunteer_id: uuid.UUID diff --git a/backend/app/api/volunteers.py b/backend/app/api/volunteers.py index a84ab6a..d237ea9 100644 --- a/backend/app/api/volunteers.py +++ b/backend/app/api/volunteers.py @@ -9,7 +9,13 @@ from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy.orm import aliased from sqlmodel import Session, select -from app.api._common import owned_or_404, replace_links +from app.api._common import ( + owned_or_404, + replace_links, + resolve_owned_ids, + set_artists, + set_wished_positions, +) from app.api.deps import get_edition from app.api.schemas import ( VolunteerCreate, @@ -93,74 +99,18 @@ def _companion_ids(session: Session, volunteer_id: uuid.UUID) -> list[uuid.UUID] def _read(session: Session, volunteer: Volunteer) -> VolunteerRead: return VolunteerRead( - **volunteer.model_dump(), + **volunteer.model_dump(exclude={"photo", "photo_content_type"}), artist_ids=_artist_ids(session, volunteer.id), wished_position_ids=_wished_position_ids(session, volunteer.id), wished_companion_ids=_companion_ids(session, volunteer.id), - ) - - -def _resolve_owned_ids( - session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID], code: str -) -> list[uuid.UUID]: - """Ordre d'entrée conservé, doublons retirés ; 422 si un id est étranger.""" - wanted = list(dict.fromkeys(ids)) - if wanted: - found = set( - session.exec( - select(model.id).where( - model.edition_id == edition_id, - model.id.in_(wanted), - ) - ) - ) - if len(found) != len(wanted): - raise HTTPException(status_code=422, detail={"code": code}) - return wanted - - -def _set_artists( - session: Session, volunteer: Volunteer, artist_ids: list[uuid.UUID] -) -> None: - ids = _resolve_owned_ids( - session, Artist, volunteer.edition_id, artist_ids, "artist.unknown" - ) - replace_links( - session, - VolunteerArtist, - filter_column=VolunteerArtist.volunteer_id, - filter_value=volunteer.id, - pairs=[ - {"volunteer_id": volunteer.id, "artist_id": aid} for aid in ids - ], - ) - - -def _set_wished_positions( - session: Session, volunteer: Volunteer, position_ids: list[uuid.UUID] -) -> None: - ids = _resolve_owned_ids( - session, - Position, - volunteer.edition_id, - position_ids, - "position.unknown", - ) - replace_links( - session, - VolunteerPositionWish, - filter_column=VolunteerPositionWish.volunteer_id, - filter_value=volunteer.id, - pairs=[ - {"volunteer_id": volunteer.id, "position_id": pid} for pid in ids - ], + has_photo=volunteer.photo is not None, ) def _set_companions( session: Session, volunteer: Volunteer, companion_ids: list[uuid.UUID] ) -> None: - ids = _resolve_owned_ids( + ids = resolve_owned_ids( session, Volunteer, volunteer.edition_id, companion_ids, "volunteer.unknown" ) if volunteer.id in ids: @@ -195,8 +145,8 @@ def create_volunteer( ) session.add(volunteer) session.flush() - _set_artists(session, volunteer, payload.artist_ids) - _set_wished_positions(session, volunteer, payload.wished_position_ids) + set_artists(session, volunteer, payload.artist_ids) + set_wished_positions(session, volunteer, payload.wished_position_ids) _set_companions(session, volunteer, payload.wished_companion_ids) session.commit() session.refresh(volunteer) @@ -272,6 +222,25 @@ def get_volunteer_planning( ] +@router.get("/{volunteer_id}/photo") +def get_volunteer_photo( + volunteer_id: uuid.UUID, + edition: Edition = Depends(get_edition), + _guard: Account = Depends(require_staff), + session: Session = Depends(get_session), +) -> Response: + """Photo transmise par le bénévole via le formulaire public + (`app/api/forms.py`) — réservée à COORDINATION/RÉFÉRENT, pas exposée à + l'écran bénévole.""" + volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id) + if volunteer.photo is None: + raise HTTPException(status_code=404, detail={"code": "error.not_found"}) + return Response( + content=volunteer.photo, + media_type=volunteer.photo_content_type or "application/octet-stream", + ) + + @router.patch("/{volunteer_id}", response_model=VolunteerRead) def update_volunteer( volunteer_id: uuid.UUID, @@ -289,9 +258,9 @@ def update_volunteer( setattr(volunteer, field, value) session.flush() if artist_ids is not None: - _set_artists(session, volunteer, artist_ids) + set_artists(session, volunteer, artist_ids) if wished_position_ids is not None: - _set_wished_positions(session, volunteer, wished_position_ids) + set_wished_positions(session, volunteer, wished_position_ids) if wished_companion_ids is not None: _set_companions(session, volunteer, wished_companion_ids) session.commit() diff --git a/backend/app/auth/rate_limit.py b/backend/app/auth/rate_limit.py index 065cc6b..b430560 100644 --- a/backend/app/auth/rate_limit.py +++ b/backend/app/auth/rate_limit.py @@ -60,3 +60,10 @@ login_limiter = RateLimiter(max_attempts=5, window_seconds=60) # trois fois ; au-delà c'est soit un abus, soit un email qui n'arrivera pas # plus vite en insistant. magic_link_limiter = RateLimiter(max_attempts=3, window_seconds=300) +# Formulaire public d'inscription (`app/api/forms.py`) : pas d'identité +# fiable à clé (n'importe qui peut soumettre des emails différents à chaque +# fois, et l'IP réelle du client n'est pas encore transmise par Caddy — voir +# plus haut) ; hCaptcha est la défense principale contre les robots, ceci +# n'est qu'un filet de sécurité global côté édition contre un emballement +# malgré tout (30 inscriptions/5 min est très large pour un usage réel). +form_submission_limiter = RateLimiter(max_attempts=30, window_seconds=300) diff --git a/backend/app/config.py b/backend/app/config.py index ea1dcc0..69a5072 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -49,6 +49,12 @@ class Settings(BaseSettings): # (ou un alias validé). En cas de rejet, mettre SMTP_FROM = SMTP_USER. smtp_from: str = "Festival des bénévoles " + # Formulaire public d'inscription (`app/api/forms.py`). Secret hCaptcha + # seulement (la clé de site est publique, elle vit côté front-end en + # VITE_HCAPTCHA_SITE_KEY, figée au build). None ⇒ vérification + # désactivée (dev/tests uniquement, voir `app/forms/captcha.py`). + hcaptcha_secret_key: str | None = None + log_level: str = "INFO" cors_origins: list[str] = ["http://localhost:5173"] diff --git a/backend/app/forms/__init__.py b/backend/app/forms/__init__.py new file mode 100644 index 0000000..8ee054d --- /dev/null +++ b/backend/app/forms/__init__.py @@ -0,0 +1,3 @@ +from app.forms.captcha import verify_hcaptcha + +__all__ = ["verify_hcaptcha"] diff --git a/backend/app/forms/captcha.py b/backend/app/forms/captcha.py new file mode 100644 index 0000000..cf457d8 --- /dev/null +++ b/backend/app/forms/captcha.py @@ -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) diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index a476e3a..5b9a68a 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -73,5 +73,12 @@ "position.category.catering": "Catering", "position.category.setup": "Montage", "position.category.stage": "Régie", - "position.category.other": "Autre" + "position.category.other": "Autre", + "form.closed": "Les inscriptions sont actuellement fermées.", + "form.captcha_failed": "Vérification anti-robot invalide, réessayez.", + "form.invalid_payload": "Données du formulaire invalides.", + "form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.", + "form.photo_too_large": "La photo est trop volumineuse.", + "form.too_many_position_wishes": "Deux vœux de poste au maximum.", + "form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival." } diff --git a/backend/app/main.py b/backend/app/main.py index 4e8aff2..fb1c5be 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,6 +11,7 @@ from app.api.availability import router as availability_router from app.api.badges import router as badge_router from app.api.day_parts import router as day_part_router from app.api.editions import router as edition_router +from app.api.forms import router as form_router from app.api.health import router as health_router from app.api.imports import router as import_router from app.api.matrix import router as matrix_router @@ -37,6 +38,15 @@ if settings.is_production and not settings.email_enabled: "le SMTP avant l'ouverture au public." ) +if settings.is_production and not settings.hcaptcha_secret_key: + # Le formulaire public (`app/api/forms.py`) reste accessible sans + # HCAPTCHA_SECRET_KEY, mais sans aucune protection anti-robot au-delà du + # filet de sécurité global par édition (`form_submission_limiter`). + logging.getLogger("app.main").warning( + "HCAPTCHA_SECRET_KEY absente en production : le formulaire public " + "d'inscription n'est pas protégé par captcha." + ) + app = FastAPI(title="Gestion des bénévoles", version="0.1.0") app.add_middleware( @@ -63,3 +73,4 @@ app.include_router(solver_router) app.include_router(matrix_router) app.include_router(notification_router) app.include_router(badge_router) +app.include_router(form_router) diff --git a/backend/app/migrations/versions/0013_signup_form.py b/backend/app/migrations/versions/0013_signup_form.py new file mode 100644 index 0000000..8c1ea0c --- /dev/null +++ b/backend/app/migrations/versions/0013_signup_form.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 98a7db5..c544835 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -14,6 +14,8 @@ from app.models.domain import ( BadgeTemplate, DayPart, Edition, + FormField, + FormTemplate, MagicLinkToken, Position, Shift, @@ -26,6 +28,7 @@ from app.models.enums import ( AssignmentSource, BadgeAlign, BadgeFieldType, + FormFieldType, PositionCategory, Role, ) @@ -42,6 +45,9 @@ __all__ = [ "BadgeTemplate", "DayPart", "Edition", + "FormField", + "FormFieldType", + "FormTemplate", "MagicLinkToken", "Position", "PositionCategory", diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index c6cf107..9de7d36 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -18,6 +18,7 @@ from app.models.enums import ( AssignmentSource, BadgeAlign, BadgeFieldType, + FormFieldType, PositionCategory, Role, ) @@ -170,6 +171,27 @@ class Volunteer(TimestampMixin, table=True): last_name: str phone: str | None = None comment: str | None = None + # Renseigné par le formulaire public d'inscription (`app/api/forms.py`) — + # voir aussi ce module pour `photo`/`photo_content_type`. + photo: bytes | None = Field(default=None, sa_column=Column(LargeBinary)) + photo_content_type: str | None = None + # « Collègue que j'aime bien » saisi en texte libre à l'inscription : la + # personne visée n'existe pas forcément encore en base à ce moment-là, + # donc pas de lien direct vers un autre `Volunteer`. La coordination lit + # cette note sur la fiche et la transforme, si besoin, en un vrai + # `VolunteerCompanionWish` une fois les deux bénévoles inscrits. + companion_wish_note: str | None = None + # Cases à cocher du formulaire public — purement informatives : aucune + # logique n'y est attachée (pas de lien avec `Availability`, pas de + # workflow de paiement). + self_declared_available: bool = Field( + default=False, + sa_column=Column(Boolean, nullable=False, server_default="false"), + ) + willing_to_pay_fee: bool = Field( + default=False, + sa_column=Column(Boolean, nullable=False, server_default="false"), + ) wished_artists: list[Artist] = Relationship( back_populates="fans", @@ -436,3 +458,45 @@ class BadgeField(TimestampMixin, table=True): sa_column=Column(Boolean, nullable=False, server_default="false"), ) color: str = Field(default="#000000") + + +# --------------------------------------------------------------------------- # +# Formulaire public d'inscription (voir app/api/forms.py) # +# --------------------------------------------------------------------------- # +class FormTemplate(TimestampMixin, table=True): + """Configuration du formulaire public d'une édition. Une ligne par + édition (créée à la demande). `is_open` coupe les nouvelles soumissions + sans effacer la configuration (les champs activés restent enregistrés).""" + + __tablename__ = "form_template" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + edition_id: uuid.UUID = Field( + foreign_key="edition.id", unique=True, ondelete="CASCADE" + ) + is_open: bool = Field( + default=True, + sa_column=Column(Boolean, nullable=False, server_default="true"), + ) + + +class FormField(SQLModel, table=True): + """Un champ activé sur le formulaire public (voir `FormFieldType`) — la + présence d'une ligne vaut activation. Pas de champs d'audit : juste une + case à cocher dans l'écran de configuration, remplacée en bloc (`PUT + .../form/fields`), rien d'autre à suivre dans le temps.""" + + __tablename__ = "form_field" + __table_args__ = ( + UniqueConstraint( + "edition_id", "field_type", name="uq_form_field_edition_type" + ), + ) + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + edition_id: uuid.UUID = Field( + foreign_key="edition.id", index=True, ondelete="CASCADE" + ) + field_type: FormFieldType = Field( + sa_column=Column(SAEnum(FormFieldType, native_enum=False), nullable=False) + ) diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py index 7333dc1..46de5c8 100644 --- a/backend/app/models/enums.py +++ b/backend/app/models/enums.py @@ -43,3 +43,19 @@ class BadgeAlign(enum.StrEnum): LEFT = "LEFT" CENTER = "CENTER" RIGHT = "RIGHT" + + +class FormFieldType(enum.StrEnum): + """Champ activable sur le formulaire public d'inscription (voir + `app/api/forms.py`). Nom/prénom sont toujours collectés, pas au choix de + la coordination — ils sont indispensables à la création du bénévole.""" + + EMAIL = "EMAIL" + PHONE = "PHONE" + POSITION_WISHES = "POSITION_WISHES" + ARTIST_WISHES = "ARTIST_WISHES" + COMPANION_WISH = "COMPANION_WISH" + AVAILABLE = "AVAILABLE" + WILLING_TO_PAY_FEE = "WILLING_TO_PAY_FEE" + PHOTO = "PHOTO" + COMMENT = "COMMENT" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8847cec..6125c5f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,13 +17,13 @@ dependencies = [ "ortools>=9.11", "reportlab>=4.2", "pillow>=11.0", + "httpx>=0.27", ] [dependency-groups] dev = [ "pytest>=8.3", "pytest-cov>=6.0", - "httpx>=0.27", "ruff>=0.8", ] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 24075fa..5a5075c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -7,7 +7,11 @@ from sqlmodel import Session, SQLModel, create_engine, select from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata) from app.auth.deps import get_current_account -from app.auth.rate_limit import login_limiter, magic_link_limiter +from app.auth.rate_limit import ( + form_submission_limiter, + login_limiter, + magic_link_limiter, +) from app.config import get_settings from app.db import get_session from app.main import app @@ -31,6 +35,7 @@ def _reset_rate_limiters(): déclenche un ne doit pas polluer les suivants.""" login_limiter.reset() magic_link_limiter.reset() + form_submission_limiter.reset() yield diff --git a/backend/tests/test_forms_api.py b/backend/tests/test_forms_api.py new file mode 100644 index 0000000..daaac05 --- /dev/null +++ b/backend/tests/test_forms_api.py @@ -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 diff --git a/backend/tests/test_forms_captcha.py b/backend/tests/test_forms_captcha.py new file mode 100644 index 0000000..3b0a562 --- /dev/null +++ b/backend/tests/test_forms_captcha.py @@ -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() diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 4757ef6..60c71ea 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -29,6 +29,8 @@ EXPECTED_TABLES = { "assignment", "badge_template", "badge_field", + "form_template", + "form_field", } diff --git a/backend/uv.lock b/backend/uv.lock index 26cc300..8a5a993 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -106,6 +106,7 @@ dependencies = [ { name = "alembic" }, { name = "bcrypt" }, { name = "fastapi" }, + { name = "httpx" }, { name = "openpyxl" }, { name = "ortools" }, { name = "pillow" }, @@ -120,7 +121,6 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "httpx" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -131,6 +131,7 @@ requires-dist = [ { name = "alembic", specifier = ">=1.14" }, { name = "bcrypt", specifier = ">=4.1" }, { name = "fastapi", specifier = ">=0.115" }, + { name = "httpx", specifier = ">=0.27" }, { name = "openpyxl", specifier = ">=3.1" }, { name = "ortools", specifier = ">=9.11" }, { name = "pillow", specifier = ">=11.0" }, @@ -145,7 +146,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "httpx", specifier = ">=0.27" }, { name = "pytest", specifier = ">=8.3" }, { name = "pytest-cov", specifier = ">=6.0" }, { name = "ruff", specifier = ">=0.8" }, diff --git a/deploy/README.md b/deploy/README.md index e5298cd..50868fd 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -70,6 +70,7 @@ Variables (Paramètres → Actions → Variables) : | `SSH_USER` | Utilisateur SSH | | `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_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 `~/.ssh/authorized_keys` de `SSH_USER` sur le VPS. @@ -100,6 +101,13 @@ SMTP_PASSWORD= SMTP_STARTTLS=true SMTP_SSL=false SMTP_FROM=Festival + +# 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 ``` diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index adf6dff..a04eb0b 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -2,9 +2,11 @@ name: benevoles # 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, -# POSTGRES_DB, JWT_SECRET, PUBLIC_BASE_URL et les variables SMTP_* / EMAIL_ENABLED. -# BACKEND_IMAGE / FRONTEND_IMAGE sont exportées par le pipeline de déploiement -# (.gitea/workflows/ci-cd.yml). +# POSTGRES_DB, JWT_SECRET, PUBLIC_BASE_URL, HCAPTCHA_SECRET_KEY et les +# variables SMTP_* / EMAIL_ENABLED. BACKEND_IMAGE / FRONTEND_IMAGE sont +# 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 # (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_SSL: ${SMTP_SSL:-false} SMTP_FROM: ${SMTP_FROM:-} + HCAPTCHA_SECRET_KEY: ${HCAPTCHA_SECRET_KEY:-} TZ: Europe/Paris depends_on: db: diff --git a/docker-compose.yml b/docker-compose.yml index cffb495..626470e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,9 @@ services: SMTP_STARTTLS: ${SMTP_STARTTLS:-true} SMTP_SSL: ${SMTP_SSL:-false} SMTP_FROM: ${SMTP_FROM:-Festival des bénévoles } + # 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 ports: - "8000:8000" @@ -54,6 +57,10 @@ services: # existante, p. ex. après `docker compose run --rm backend uv run python -m app.seed`). VITE_API_MODE: ${VITE_API_MODE:-mock} 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. VITE_API_TARGET: http://backend:8000 ports: diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod index 9601e77..20b107a 100644 --- a/frontend/Dockerfile.prod +++ b/frontend/Dockerfile.prod @@ -16,8 +16,10 @@ COPY . . ARG VITE_API_MODE=http ARG VITE_EDITION_ID +ARG VITE_HCAPTCHA_SITE_KEY ENV VITE_API_MODE=$VITE_API_MODE ENV VITE_EDITION_ID=$VITE_EDITION_ID +ENV VITE_HCAPTCHA_SITE_KEY=$VITE_HCAPTCHA_SITE_KEY RUN npm run build diff --git a/frontend/scripts/smoke.tsx b/frontend/scripts/smoke.tsx index 8237485..a9a2f85 100644 --- a/frontend/scripts/smoke.tsx +++ b/frontend/scripts/smoke.tsx @@ -20,6 +20,7 @@ import { AuthContext, AuthProvider, type AuthContextValue } from '@/auth/auth-co import { routes } from '@/app/router'; import { BadgesPage } from '@/pages/BadgesPage'; import { ConfigPage } from '@/pages/ConfigPage'; +import { FormBuilderPage } from '@/pages/FormBuilderPage'; import { GrillePage } from '@/pages/GrillePage'; import { MobileLeadPage } from '@/pages/MobileLeadPage'; 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) { await Promise.all([ 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 checkPublicForm().catch((err) => fail('checkPublicForm', err)); renderPublic('/connexion'); renderPublic('/connexion/coordination'); +renderPublic('/inscription'); // formulaire public, hors de if (coordinator) { setAuthToken(coordinatorToken); @@ -158,6 +234,7 @@ if (coordinator) { await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage)); await renderProtected('/configuration', coordinator, createElement(ConfigPage)); await renderProtected('/badges', coordinator, createElement(BadgesPage)); + await renderProtected('/inscriptions', coordinator, createElement(FormBuilderPage)); } if (volunteerAccount) { const vid = volunteerAccount.volunteer_id as string; diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index ed12288..c06ca7c 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -23,10 +23,13 @@ import type { DayPartCreate, DayPartUpdate, EditionUpdate, + FormFieldsSet, + FormTemplateUpdate, ImportMapping, PlanningNotificationRequest, PositionCreate, PositionUpdate, + PublicFormSubmission, ShiftCreate, ShiftUpdate, 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(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) { return useQuery({ queryKey: queryKeys.shifts(positionId), @@ -553,3 +580,50 @@ export function useGenerateBadges() { 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), + }); +} diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index 5519d28..479cde1 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -13,12 +13,15 @@ import type { DayPartCreate, DayPartUpdate, EditionUpdate, + FormFieldsSet, + FormTemplateUpdate, ImportMapping, LoginRequest, MagicLinkRequest, PlanningNotificationRequest, PositionCreate, PositionUpdate, + PublicFormSubmission, ShiftCreate, ShiftUpdate, VerifyRequest, @@ -144,6 +147,7 @@ export function createHttpApi(baseUrl: string): PlanningApi { request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }), planning: (editionId, id) => request(`${base(editionId)}/volunteers/${id}/planning`), + photoBlob: (editionId, id) => requestBlob(`${base(editionId)}/volunteers/${id}/photo`), }, accounts: { list: (editionId) => request(`${base(editionId)}/accounts`), @@ -262,6 +266,39 @@ export function createHttpApi(baseUrl: string): PlanningApi { 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: { run: (editionId) => request(`${base(editionId)}/solver/suggestions`, { method: 'POST' }), diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts index 44b029f..eea7bcc 100644 --- a/frontend/src/api/mock/dataset.ts +++ b/frontend/src/api/mock/dataset.ts @@ -8,6 +8,7 @@ import type { BadgeFieldRead, DayPartRead, EditionRead, + FormFieldType, PositionCategory, PositionRead, ShiftRead, @@ -49,6 +50,12 @@ export interface MockState { backImageType: string | null; fields: BadgeFieldRead[]; }; + formTemplate: { + isOpen: boolean; + fields: FormFieldType[]; + }; + /** volunteerId -> photo (formulaire public), data URL. */ + photos: Map; seq: number; } @@ -258,6 +265,10 @@ export function buildMockState(): MockState { artist_ids, wished_position_ids, wished_companion_ids, + has_photo: false, + companion_wish_note: null, + self_declared_available: false, + willing_to_pay_fee: false, created_at: NOW, updated_at: NOW, }); @@ -431,6 +442,8 @@ export function buildMockState(): MockState { backImageType: null, fields: [], }, + formTemplate: { isOpen: true, fields: [] }, + photos: new Map(), seq, }; } diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 37dc6c5..4fb048c 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -19,6 +19,10 @@ import type { DayPartRead, DayPartUpdate, EditionUpdate, + FormFieldsSet, + FormSubmissionResult, + FormTemplateRead, + FormTemplateUpdate, ImportAnalyzeResponse, ImportMapping, ImportPreviewRow, @@ -26,6 +30,8 @@ import type { ImportRowReport, MatrixResponse, PlanningNotificationRequest, + PublicFormConfig, + PublicFormSubmission, ShiftRead, SolverRunResult, SolverSuggestion, @@ -102,6 +108,11 @@ const imageSizeMm = async (file: File): Promise<{ width_mm: number; height_mm: n 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 => new Promise((resolve, reject) => { const reader = new FileReader(); @@ -831,6 +842,10 @@ export function createMockApi(): PlanningApi { 'position.unknown', ), 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, updated_at: now, }; @@ -861,6 +876,15 @@ export function createMockApi(): PlanningApi { ...(body.wished_companion_ids != null ? { 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(), }); return delay(v); @@ -869,6 +893,7 @@ export function createMockApi(): PlanningApi { state.volunteers = state.volunteers.filter((x) => x.id !== id); state.assignments = state.assignments.filter((a) => a.volunteer_id !== id); state.availability.delete(id); + state.photos.delete(id); // RGPD : effacement complet, y compris le compte de connexion éventuel // et les vœux de compagnon dont il était la cible. state.accounts = state.accounts.filter((a) => a.volunteer_id !== id); @@ -877,6 +902,11 @@ export function createMockApi(): PlanningApi { }); 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) => { if (!state.volunteers.some((v) => v.id === id)) return notFound(); const entries = state.assignments @@ -1147,6 +1177,10 @@ export function createMockApi(): PlanningApi { artist_ids: [], wished_position_ids: [], wished_companion_ids: [], + has_photo: false, + companion_wish_note: null, + self_declared_available: false, + willing_to_pay_fee: false, created_at: now, updated_at: now, }; @@ -1430,6 +1464,99 @@ export function createMockApi(): PlanningApi { }, }, + forms: { + getTemplate: (): Promise => + 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({ + 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({ + edition_id: state.edition.id, + is_open: state.formTemplate.isOpen, + fields: state.formTemplate.fields, + }); + }, + getPublicConfig: (): Promise => { + 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({ volunteer_id: volunteer.id }); + }, + }, + solver: { run: () => delay(runSolver()), }, diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 0dea494..6770098 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -108,6 +108,11 @@ export interface VolunteerRead { wished_position_ids: string[]; /** Vœux : bénévoles avec qui il voudrait être affecté (0 à N, à sens unique). */ 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; updated_at: string; } @@ -133,6 +138,9 @@ export interface VolunteerUpdate { artist_ids?: string[] | null; wished_position_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; 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; +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index a56b021..b19e7fb 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -23,6 +23,10 @@ import type { DayPartUpdate, EditionRead, EditionUpdate, + FormFieldsSet, + FormSubmissionResult, + FormTemplateRead, + FormTemplateUpdate, GenerateShiftsResult, ImportAnalyzeResponse, ImportMapping, @@ -35,6 +39,8 @@ import type { PositionCreate, PositionRead, PositionUpdate, + PublicFormConfig, + PublicFormSubmission, ShiftCreate, ShiftRead, ShiftUpdate, @@ -77,6 +83,8 @@ export interface PlanningApi { remove(editionId: string, id: string): Promise; /** Planning enrichi (poste, lieu, référent) — écran bénévole. */ planning(editionId: string, id: string): Promise; + /** Photo transmise via le formulaire public, `null` si absente. */ + photoBlob(editionId: string, id: string): Promise; }; accounts: { /** 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). */ generate(editionId: string, body: BadgeGenerateRequest): Promise; }; + forms: { + /** Configuration (COORDINATION uniquement). */ + getTemplate(editionId: string): Promise; + setFields(editionId: string, body: FormFieldsSet): Promise; + setOpen(editionId: string, body: FormTemplateUpdate): Promise; + /** Publics — aucune authentification, utilisables sans compte connecté. */ + getPublicConfig(editionId: string): Promise; + submitPublic(editionId: string, body: PublicFormSubmission): Promise; + }; solver: { /** Calcule des suggestions (CP-SAT) ; ne persiste jamais d'affectation. */ run(editionId: string): Promise; diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 5421098..2a8f5c1 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -6,14 +6,20 @@ import { AdminLoginPage } from '@/pages/auth/AdminLoginPage'; import { ConnexionPage } from '@/pages/auth/ConnexionPage'; import { BadgesPage } from '@/pages/BadgesPage'; import { ConfigPage } from '@/pages/ConfigPage'; +import { FormBuilderPage } from '@/pages/FormBuilderPage'; import { GrillePage } from '@/pages/GrillePage'; import { MobileLeadPage } from '@/pages/MobileLeadPage'; import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage'; +import { PublicSignupPage } from '@/pages/PublicSignupPage'; import { VolunteerViewPage } from '@/pages/VolunteerViewPage'; export const routes: RouteObject[] = [ { path: '/connexion', element: }, { path: '/connexion/coordination', element: }, + // Formulaire public d'inscription : aucun compte requis, donc hors de + // — voir app/api/forms.py (les seuls endpoints publics de + // toute l'API). + { path: '/inscription', element: }, { element: , children: [ @@ -28,6 +34,7 @@ export const routes: RouteObject[] = [ { path: 'mobile-referent', element: }, { path: 'configuration', element: }, { path: 'badges', element: }, + { path: 'inscriptions', element: }, ], }, ], diff --git a/frontend/src/auth/roles.ts b/frontend/src/auth/roles.ts index 58e2d11..0e5ea7c 100644 --- a/frontend/src/auth/roles.ts +++ b/frontend/src/auth/roles.ts @@ -10,6 +10,7 @@ export const ROUTE_ROLES: Record = { '/benevoles': ['COORDINATOR'], '/configuration': ['COORDINATOR'], '/badges': ['COORDINATOR'], + '/inscriptions': ['COORDINATOR'], '/mobile-benevole': ['VOLUNTEER'], '/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: '/configuration', num: '06', labelKey: 'tab.config' }, { to: '/badges', num: '07', labelKey: 'tab.badges' }, + { to: '/inscriptions', num: '08', labelKey: 'tab.forms' }, ]; export function canAccess(pathname: string, role: Role): boolean { diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 7b163b7..8d9572d 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -300,6 +300,8 @@ "config.vol.noCompanions": "Aucun bénévole choisi", "config.vol.pickCompanion": "Ajouter 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.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éé.", @@ -360,5 +362,49 @@ "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.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." } diff --git a/frontend/src/lib/env.ts b/frontend/src/lib/env.ts index d07fc80..21a4467 100644 --- a/frontend/src/lib/env.ts +++ b/frontend/src/lib/env.ts @@ -7,4 +7,7 @@ export const ENV = { apiBaseUrl: raw.VITE_API_BASE_URL ?? '/api', editionId: raw.VITE_EDITION_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 ?? '', }; diff --git a/frontend/src/pages/FormBuilderPage.tsx b/frontend/src/pages/FormBuilderPage.tsx new file mode 100644 index 0000000..7001b76 --- /dev/null +++ b/frontend/src/pages/FormBuilderPage.tsx @@ -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 = { + 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 = { + 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 | null>(null); + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (template.data) setEnabled(new Set(template.data.fields)); + }, [template.data]); + + if (template.isLoading || !enabled) return ; + if (template.error) return ; + + 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 ( +
+
+ + {t('form.link.title')} +
+

+ {t('form.link.hint')} +

+
+ e.target.select()} + className="w-full border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[11px]" + /> + + {copied ? t('form.link.copied') : t('form.link.copy')} + +
+ +
+ + {t('form.status.title')} + + +
+
+
+ + + {t('form.fields.title')} +
+ {FIELD_TYPES.map((type) => ( + + ))} +
+
+ setFields.mutate({ fields: [...enabled] })} + disabled={setFields.isPending} + > + {setFields.isPending ? t('form.saving') : t('form.save')} + + {setFields.isSuccess ? ( + {t('form.saved')} + ) : null} +
+
+
+
+ ); +} diff --git a/frontend/src/pages/PublicSignupPage.tsx b/frontend/src/pages/PublicSignupPage.tsx new file mode 100644 index 0000000..8c6d308 --- /dev/null +++ b/frontend/src/pages/PublicSignupPage.tsx @@ -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 (StrictMode, navigation aller-retour…). +let hcaptchaScriptPromise: Promise | null = null; +function loadHCaptchaScript(): Promise { + 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(null); + const callbackName = useRef(`hcaptchaCallback${Math.random().toString(36).slice(2)}`); + + useEffect(() => { + const win = window as unknown as Record; + const name = callbackName.current; + win[name] = onToken; + loadHCaptchaScript(); + return () => { + delete win[name]; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+ ); +} + +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(); + 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([]); + const [artistByDay, setArtistByDay] = useState>({}); + const [photo, setPhoto] = useState(null); + const [captchaToken, setCaptchaToken] = useState(''); + const [submitError, setSubmitError] = useState(null); + const [volunteerId, setVolunteerId] = useState(null); + + const artistsByDay = useMemo( + () => groupArtistsByDay(config.data?.artists ?? []), + [config.data?.artists], + ); + + if (config.isLoading) return ; + if (config.error) return ; + if (!config.data) return null; + + if (volunteerId) { + return ( +
+ + {t('publicForm.success.title')} +

{t('publicForm.success.body')}

+
+
+ ); + } + + if (!config.data.is_open) { + return ( +
+ + {t('publicForm.closed.title')} +

{t('publicForm.closed.body')}

+
+
+ ); + } + + 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 ( +
+ + {t('publicForm.title')} +
+
+ + +
+ + {fields.has('EMAIL') ? ( + + ) : null} + + {fields.has('PHONE') ? ( + + ) : null} + + {fields.has('POSITION_WISHES') && config.data.positions.length > 0 ? ( +
+ + {t('form.field.positionWishes')} — {t('publicForm.positionWishes.count', { + count: positionIds.length, + max: MAX_POSITION_WISHES, + })} + +
+ {config.data.positions.map((p) => { + const checked = positionIds.includes(p.id); + const disabled = !checked && positionIds.length >= MAX_POSITION_WISHES; + return ( + + ); + })} +
+
+ ) : null} + + {fields.has('ARTIST_WISHES') && artistsByDay.length > 0 ? ( +
+ {t('form.field.artistWishes')} +
+ {artistsByDay.map(([day, artists]) => ( + + ))} +
+
+ ) : null} + + {fields.has('COMPANION_WISH') ? ( + + ) : null} + + {fields.has('COMMENT') ? ( +