Étape 21 : formulaire public d'inscription des bénévoles
Retire « constructeur de formulaire » des exclusions du CLAUDE.md (§ 5) — décision explicite prise en conversation. Un formulaire par édition (COORDINATION choisit les champs actifs parmi neuf : email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir de festival, vœu de compagnon en texte libre, disponibilité/ cotisation déclaratives, photo, commentaire ; nom/prénom toujours collectés) ouvert/fermé à volonté, avec un lien à partager (/inscription, aucun compte requis). Backend : FormTemplate/FormField (migration 0013), quatre nouveaux champs sur Volunteer (photo, companion_wish_note, self_declared_available, willing_to_pay_fee), app/api/forms.py — les deux seuls endpoints publics de toute l'API (GET .../form/public, POST .../form/public/submit), protégés par hCaptcha (app/forms/captcha.py, désactivé sans HCAPTCHA_SECRET_KEY) et un filet de sécurité global par édition (form_submission_limiter). resolve_owned_ids/set_artists/ set_wished_positions déplacés vers app/api/_common.py, partagés avec volunteers.py. Frontend : écran de configuration (/inscriptions) avec lien copiable et bascule ouvert/fermé, formulaire public (/inscription, hors RequireAuth) avec widget hCaptcha chargé dynamiquement, capture photo native (<input capture>), vœux de poste/artiste avec les mêmes règles que le back-end. Réponses du formulaire visibles et éditables depuis la fiche bénévole existante (ConfigVolunteers.tsx). Vérifié : pytest (273 passés), ruff, alembic check, tsc --noEmit, eslint, vite build, smoke (rendu SSR de chaque écran + flux de bout en bout du formulaire en mode démonstration) — tous verts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
55c5d539a8
commit
82088f3b0c
@@ -29,6 +29,12 @@ SMTP_FROM=Festival des bénévoles <no-reply@example.test>
|
|||||||
# Alternative OVH si le 587/STARTTLS échoue : SMTP_PORT=465, SMTP_SSL=true,
|
# Alternative OVH si le 587/STARTTLS échoue : SMTP_PORT=465, SMTP_SSL=true,
|
||||||
# SMTP_STARTTLS=false
|
# SMTP_STARTTLS=false
|
||||||
|
|
||||||
|
# Formulaire public d'inscription (/inscription). Vide en dev ⇒ vérification
|
||||||
|
# hCaptcha désactivée (voir app/forms/captcha.py) — aucun compte requis pour
|
||||||
|
# développer en local. Couple site/secret d'un même compte https://hcaptcha.com.
|
||||||
|
HCAPTCHA_SECRET_KEY=
|
||||||
|
HCAPTCHA_SITE_KEY=
|
||||||
|
|
||||||
# Frontend (service `frontend` du docker-compose).
|
# Frontend (service `frontend` du docker-compose).
|
||||||
# mock (défaut) : jeu de démonstration en mémoire, aucun backend requis.
|
# mock (défaut) : jeu de démonstration en mémoire, aucun backend requis.
|
||||||
# http : API réelle — renseigner alors VITE_EDITION_ID avec l'UUID
|
# http : API réelle — renseigner alors VITE_EDITION_ID avec l'UUID
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
# DEPLOY_PATH dossier du projet sur le VPS (ex. /opt/benevoles)
|
# DEPLOY_PATH dossier du projet sur le VPS (ex. /opt/benevoles)
|
||||||
# VITE_EDITION_ID UUID de l'édition de production, à renseigner APRÈS
|
# VITE_EDITION_ID UUID de l'édition de production, à renseigner APRÈS
|
||||||
# la toute première mise en service (voir deploy/README.md)
|
# la toute première mise en service (voir deploy/README.md)
|
||||||
|
# VITE_HCAPTCHA_SITE_KEY clé de site hCaptcha (publique) — figée au build
|
||||||
|
# du frontend, voir app/api/forms.py et deploy/README.md.
|
||||||
|
# Vide ⇒ formulaire public sans vérification anti-robot.
|
||||||
|
|
||||||
name: ci-cd
|
name: ci-cd
|
||||||
|
|
||||||
@@ -122,6 +125,7 @@ jobs:
|
|||||||
docker build \
|
docker build \
|
||||||
--build-arg VITE_API_MODE=http \
|
--build-arg VITE_API_MODE=http \
|
||||||
--build-arg VITE_EDITION_ID="${{ vars.VITE_EDITION_ID }}" \
|
--build-arg VITE_EDITION_ID="${{ vars.VITE_EDITION_ID }}" \
|
||||||
|
--build-arg VITE_HCAPTCHA_SITE_KEY="${{ vars.VITE_HCAPTCHA_SITE_KEY }}" \
|
||||||
-f frontend/Dockerfile.prod \
|
-f frontend/Dockerfile.prod \
|
||||||
-t "$IMAGE:${{ gitea.sha }}" -t "$IMAGE:latest" frontend
|
-t "$IMAGE:${{ gitea.sha }}" -t "$IMAGE:latest" frontend
|
||||||
docker push "$IMAGE:${{ gitea.sha }}"
|
docker push "$IMAGE:${{ gitea.sha }}"
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ les tables sauf les tables d'association.
|
|||||||
taille de t-shirt, régime, ni compétences. Porte trois listes de vœux,
|
taille de t-shirt, régime, ni compétences. Porte trois listes de vœux,
|
||||||
exposées par la fiche (`artist_ids`, `wished_position_ids`,
|
exposées par la fiche (`artist_ids`, `wished_position_ids`,
|
||||||
`wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
|
`wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
|
||||||
|
Renseignables aussi via le formulaire public (§ 9) : `photo`/
|
||||||
|
`photo_content_type` (image, servie séparément par
|
||||||
|
`GET .../volunteers/{id}/photo`, COORD/LEAD), `companion_wish_note` (« je
|
||||||
|
voudrais être avec » en texte libre — la personne visée n'existe pas
|
||||||
|
forcément encore en base au moment de l'inscription, donc pas de lien
|
||||||
|
direct vers un `VolunteerCompanionWish` ; la coordination la résout à la
|
||||||
|
main depuis la fiche une fois les deux bénévoles inscrits),
|
||||||
|
`self_declared_available`/`willing_to_pay_fee` (cases à cocher **purement
|
||||||
|
informatives**, sans lien avec `Availability` ni aucun workflow de
|
||||||
|
paiement).
|
||||||
- **`Artist`** : programmation de l'édition (`name`, `performance_date`,
|
- **`Artist`** : programmation de l'édition (`name`, `performance_date`,
|
||||||
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
|
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
|
||||||
lendemain). Unique (`edition_id`, `name`, `performance_date`). La
|
lendemain). Unique (`edition_id`, `name`, `performance_date`). La
|
||||||
@@ -165,6 +175,27 @@ les tables sauf les tables d'association.
|
|||||||
pour rien : juste ReportLab) : pages recto personnalisées suivies, planche
|
pour rien : juste ReportLab) : pages recto personnalisées suivies, planche
|
||||||
par planche, de la page de verso correspondante (même image répétée à
|
par planche, de la page de verso correspondante (même image répétée à
|
||||||
chaque emplacement) — impression recto-verso classique.
|
chaque emplacement) — impression recto-verso classique.
|
||||||
|
- **`FormTemplate`** : configuration du formulaire public d'inscription
|
||||||
|
d'une édition (une ligne, créée à la demande). `is_open` coupe les
|
||||||
|
nouvelles soumissions sans effacer la configuration.
|
||||||
|
- **`FormField`** : un champ activé sur le formulaire (`field_type` ∈
|
||||||
|
`EMAIL` / `PHONE` / `POSITION_WISHES` / `ARTIST_WISHES` / `COMPANION_WISH`
|
||||||
|
/ `AVAILABLE` / `WILLING_TO_PAY_FEE` / `PHOTO` / `COMMENT`, au plus un par
|
||||||
|
édition — `PUT .../form/fields` remplace tout). **Nom et prénom sont
|
||||||
|
toujours collectés**, indispensables à la création du `Volunteer` : ils ne
|
||||||
|
sont pas dans cette liste, contrairement aux neuf autres. Deux règles
|
||||||
|
propres à ce formulaire (pas à la fiche bénévole classique, qui reste sans
|
||||||
|
limite) : au plus **deux** vœux de poste, et au plus **un** artiste
|
||||||
|
souhaité par jour de festival (`app/api/forms.py:_check_one_artist_wish_per_day`).
|
||||||
|
`GET/PUT/PATCH /editions/{id}/form[...]` (COORDINATION) configurent ;
|
||||||
|
`GET /editions/{id}/form/public` et `POST .../form/public/submit` sont les
|
||||||
|
**deux seuls endpoints de toute l'API sans authentification** — protégés
|
||||||
|
par hCaptcha (`app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` ; vérification
|
||||||
|
désactivée si absente, comme `EMAIL_ENABLED=false`) et un filet de
|
||||||
|
sécurité global par édition (`app/auth/rate_limit.py:form_submission_limiter`,
|
||||||
|
hCaptcha reste la défense principale). Le bénévole créé par une soumission
|
||||||
|
apparaît immédiatement dans la liste de la coordination, comme n'importe
|
||||||
|
quel autre bénévole.
|
||||||
|
|
||||||
Conventions techniques :
|
Conventions techniques :
|
||||||
|
|
||||||
@@ -202,6 +233,8 @@ Matrice appliquée par les endpoints :
|
|||||||
| `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — |
|
| `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — |
|
||||||
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
|
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
|
||||||
| `badges` (config + génération) | ✔ | — | — |
|
| `badges` (config + génération) | ✔ | — | — |
|
||||||
|
| `form` (config) | ✔ | — | — |
|
||||||
|
| `form/public`, `form/public/submit` | *(aucune authentification — voir § 2, `FormField`)* |||
|
||||||
|
|
||||||
**Authentification — deux systèmes selon le compte**, une même session JWT
|
**Authentification — deux systèmes selon le compte**, une même session JWT
|
||||||
HS256 (`Authorization: Bearer <jwt>`, TTL `session_ttl_hours`) à l'arrivée.
|
HS256 (`Authorization: Bearer <jwt>`, TTL `session_ttl_hours`) à l'arrivée.
|
||||||
@@ -417,8 +450,10 @@ défilement horizontal — jamais de colonnes étroites par défaut.
|
|||||||
|
|
||||||
### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte)
|
### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte)
|
||||||
|
|
||||||
SMS · constructeur de formulaire · pointage sur site ·
|
SMS · pointage sur site · fonctions d'IA. *(L'envoi d'emails par SMTP est en
|
||||||
fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)*
|
place — voir § 2. Le formulaire public d'inscription est en place — voir § 2
|
||||||
|
et § 9 ; ce n'était pas prévu à l'origine, retiré de cette liste à
|
||||||
|
l'étape 21.)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -449,7 +484,8 @@ fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)*
|
|||||||
│ │ │ # health, auth, accounts, editions,
|
│ │ │ # health, auth, accounts, editions,
|
||||||
│ │ │ # day_parts, imports, artists, volunteers,
|
│ │ │ # day_parts, imports, artists, volunteers,
|
||||||
│ │ │ # availability, positions, shifts,
|
│ │ │ # availability, positions, shifts,
|
||||||
│ │ │ # assignments, solver, matrix, notifications
|
│ │ │ # assignments, solver, matrix, notifications,
|
||||||
|
│ │ │ # badges, forms
|
||||||
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
|
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
|
||||||
│ │ ├── email/ # SMTP (sender) + rendu des emails (templates)
|
│ │ ├── email/ # SMTP (sender) + rendu des emails (templates)
|
||||||
│ │ ├── importing/ # import CSV/Excel (parser, text, service)
|
│ │ ├── importing/ # import CSV/Excel (parser, text, service)
|
||||||
@@ -457,6 +493,7 @@ fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)*
|
|||||||
│ │ ├── validation/ # moteur pur (models, engine) + loader (base)
|
│ │ ├── validation/ # moteur pur (models, engine) + loader (base)
|
||||||
│ │ ├── solver/ # suggestions OR-Tools (models, engine, loader)
|
│ │ ├── solver/ # suggestions OR-Tools (models, engine, loader)
|
||||||
│ │ ├── badges/ # génération PDF des planches (pdf.py, ReportLab)
|
│ │ ├── badges/ # génération PDF des planches (pdf.py, ReportLab)
|
||||||
|
│ │ ├── forms/ # hCaptcha (captcha.py) — voir api/forms.py
|
||||||
│ │ ├── bootstrap.py # instance vierge : édition + 1er compte
|
│ │ ├── bootstrap.py # instance vierge : édition + 1er compte
|
||||||
│ │ ├── seed.py # jeu de démonstration (python -m app.seed)
|
│ │ ├── seed.py # jeu de démonstration (python -m app.seed)
|
||||||
│ │ ├── i18n/
|
│ │ ├── i18n/
|
||||||
@@ -529,10 +566,13 @@ Aucune dépendance locale requise hors Docker.
|
|||||||
- Secrets/variables Actions requis (voir `deploy/README.md`) : secrets
|
- Secrets/variables Actions requis (voir `deploy/README.md`) : secrets
|
||||||
`REGISTRY_TOKEN`, `SSH_KEY` ; variables `REGISTRY_HOST`,
|
`REGISTRY_TOKEN`, `SSH_KEY` ; variables `REGISTRY_HOST`,
|
||||||
`REGISTRY_NAMESPACE`, `REGISTRY_USER`, `SSH_HOST`, `SSH_USER`,
|
`REGISTRY_NAMESPACE`, `REGISTRY_USER`, `SSH_HOST`, `SSH_USER`,
|
||||||
`DEPLOY_PATH`, `VITE_EDITION_ID`.
|
`DEPLOY_PATH`, `VITE_EDITION_ID`, `VITE_HCAPTCHA_SITE_KEY` (optionnelle —
|
||||||
|
clé de site hCaptcha, publique, figée au build du frontend comme
|
||||||
|
`VITE_EDITION_ID` ; vide ⇒ formulaire public sans vérification anti-robot).
|
||||||
- Le VPS porte un fichier `.env` (non versionné) avec `POSTGRES_USER`,
|
- Le VPS porte un fichier `.env` (non versionné) avec `POSTGRES_USER`,
|
||||||
`POSTGRES_PASSWORD`, `POSTGRES_DB`, `JWT_SECRET`, `PUBLIC_BASE_URL` et les
|
`POSTGRES_PASSWORD`, `POSTGRES_DB`, `JWT_SECRET`, `PUBLIC_BASE_URL`,
|
||||||
variables `EMAIL_ENABLED` / `SMTP_*` (voir `deploy/README.md`).
|
`HCAPTCHA_SECRET_KEY` (optionnelle, secret hCaptcha — voir § 2, `FormField`)
|
||||||
|
et les variables `EMAIL_ENABLED` / `SMTP_*` (voir `deploy/README.md`).
|
||||||
- `backend` et `frontend` n'écoutent que sur `127.0.0.1` (voir
|
- `backend` et `frontend` n'écoutent que sur `127.0.0.1` (voir
|
||||||
`docker-compose.prod.yml`) : `deploy/Caddyfile.benevoles` est le bloc à
|
`docker-compose.prod.yml`) : `deploy/Caddyfile.benevoles` est le bloc à
|
||||||
ajouter au Caddyfile du serveur pour les exposer publiquement (`/api/*` vers
|
ajouter au Caddyfile du serveur pour les exposer publiquement (`/api/*` vers
|
||||||
@@ -564,6 +604,7 @@ Aucune dépendance locale requise hors Docker.
|
|||||||
| 18 | Gestion des référents depuis la fiche bénévole : `PATCH /accounts` (rôle LEAD ↔ VOLUNTEER), `lead_account_id` vérifié (doit être un compte référent de l'édition) | à valider |
|
| 18 | Gestion des référents depuis la fiche bénévole : `PATCH /accounts` (rôle LEAD ↔ VOLUNTEER), `lead_account_id` vérifié (doit être un compte référent de l'édition) | à valider |
|
||||||
| 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend, limiteur de tentatives sur `/auth/login` et `/auth/magic-link` | à valider |
|
| 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend, limiteur de tentatives sur `/auth/login` et `/auth/magic-link` | à valider |
|
||||||
| 20 | Planches de badges bénévoles (PDF) : recto personnalisable (fond + champs nom/poste(s)/catégorie/rôle, éditeur glisser-déposer), verso fixe (même image pour tous), écran « Badges » dédié (`/badges`, COORDINATION) | à valider |
|
| 20 | Planches de badges bénévoles (PDF) : recto personnalisable (fond + champs nom/poste(s)/catégorie/rôle, éditeur glisser-déposer), verso fixe (même image pour tous), écran « Badges » dédié (`/badges`, COORDINATION) | à valider |
|
||||||
|
| 21 | Formulaire public d'inscription (`/inscription`, sans authentification) : champs activables par la coordination (email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir, vœu de compagnon en texte libre, disponibilité/cotisation déclaratives, photo, commentaire), captcha hCaptcha, ouverture/fermeture, lien à partager, écran de configuration dédié (`/inscriptions`, COORDINATION) — les bénévoles créés rejoignent directement la liste | à valider |
|
||||||
|
|
||||||
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
||||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
"""Helpers partagés par les routers CRUD."""
|
"""Helpers partagés par les routers CRUD.
|
||||||
|
|
||||||
|
`resolve_owned_ids`/`set_artists`/`set_wished_positions` sont utilisés à la
|
||||||
|
fois par `volunteers.py` (fiche éditée par la coordination) et `forms.py`
|
||||||
|
(vœux saisis depuis le formulaire public) — un seul endroit pour la
|
||||||
|
validation « ces ids appartiennent bien à cette édition »."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models import Artist, Position, VolunteerArtist, VolunteerPositionWish
|
||||||
|
|
||||||
|
|
||||||
def owned_or_404(instance, edition_id: uuid.UUID):
|
def owned_or_404(instance, edition_id: uuid.UUID):
|
||||||
@@ -23,3 +30,50 @@ def replace_links(
|
|||||||
)
|
)
|
||||||
for row in pairs:
|
for row in pairs:
|
||||||
session.add(link_model(**row))
|
session.add(link_model(**row))
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_owned_ids(
|
||||||
|
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID], code: str
|
||||||
|
) -> list[uuid.UUID]:
|
||||||
|
"""Ordre d'entrée conservé, doublons retirés ; 422 si un id est étranger."""
|
||||||
|
wanted = list(dict.fromkeys(ids))
|
||||||
|
if wanted:
|
||||||
|
found = set(
|
||||||
|
session.exec(
|
||||||
|
select(model.id).where(
|
||||||
|
model.edition_id == edition_id,
|
||||||
|
model.id.in_(wanted),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(found) != len(wanted):
|
||||||
|
raise HTTPException(status_code=422, detail={"code": code})
|
||||||
|
return wanted
|
||||||
|
|
||||||
|
|
||||||
|
def set_artists(session: Session, volunteer, artist_ids: list[uuid.UUID]) -> None:
|
||||||
|
ids = resolve_owned_ids(
|
||||||
|
session, Artist, volunteer.edition_id, artist_ids, "artist.unknown"
|
||||||
|
)
|
||||||
|
replace_links(
|
||||||
|
session,
|
||||||
|
VolunteerArtist,
|
||||||
|
filter_column=VolunteerArtist.volunteer_id,
|
||||||
|
filter_value=volunteer.id,
|
||||||
|
pairs=[{"volunteer_id": volunteer.id, "artist_id": aid} for aid in ids],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_wished_positions(
|
||||||
|
session: Session, volunteer, position_ids: list[uuid.UUID]
|
||||||
|
) -> None:
|
||||||
|
ids = resolve_owned_ids(
|
||||||
|
session, Position, volunteer.edition_id, position_ids, "position.unknown"
|
||||||
|
)
|
||||||
|
replace_links(
|
||||||
|
session,
|
||||||
|
VolunteerPositionWish,
|
||||||
|
filter_column=VolunteerPositionWish.volunteer_id,
|
||||||
|
filter_value=volunteer.id,
|
||||||
|
pairs=[{"volunteer_id": volunteer.id, "position_id": pid} for pid in ids],
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
"""Formulaire public d'inscription des bénévoles.
|
||||||
|
|
||||||
|
Deux familles d'endpoints sous le même routeur :
|
||||||
|
- configuration (COORDINATION) : quels champs sont activés, formulaire
|
||||||
|
ouvert ou fermé ;
|
||||||
|
- publics (**aucune authentification** — les seuls endpoints de toute
|
||||||
|
l'API dans ce cas) : consultation de la configuration et soumission,
|
||||||
|
protégés par hCaptcha (`app/forms/captcha.py`) et un filet de sécurité
|
||||||
|
global (`app/auth/rate_limit.py:form_submission_limiter`).
|
||||||
|
|
||||||
|
Nom et prénom sont toujours collectés (indispensables à la création du
|
||||||
|
bénévole) : ils ne font pas partie des champs activables, contrairement aux
|
||||||
|
neuf autres (`FormFieldType`). Les vœux de postes/artistes réutilisent la
|
||||||
|
validation de `app/api/_common.py` (partagée avec `volunteers.py`), avec
|
||||||
|
deux règles propres au formulaire : au plus `_MAX_POSITION_WISHES` postes,
|
||||||
|
et au plus un artiste souhaité par jour de festival (`performance_date`,
|
||||||
|
voir `_check_one_artist_wish_per_day`). Le vœu de compagnon est un texte
|
||||||
|
libre (`companion_wish_note`) résolu ensuite manuellement par la
|
||||||
|
coordination, faute de savoir si la personne visée est déjà inscrite (voir
|
||||||
|
CLAUDE.md § 2, `Volunteer`)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
|
Depends,
|
||||||
|
File,
|
||||||
|
Form,
|
||||||
|
HTTPException,
|
||||||
|
UploadFile,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
from sqlalchemy import delete
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.api._common import set_artists, set_wished_positions
|
||||||
|
from app.api.deps import get_edition
|
||||||
|
from app.api.schemas import (
|
||||||
|
FormFieldsSet,
|
||||||
|
FormSubmissionResult,
|
||||||
|
FormTemplateRead,
|
||||||
|
FormTemplateUpdate,
|
||||||
|
PublicArtistOption,
|
||||||
|
PublicFormConfig,
|
||||||
|
PublicPositionOption,
|
||||||
|
)
|
||||||
|
from app.auth.deps import require_coordinator
|
||||||
|
from app.auth.rate_limit import form_submission_limiter
|
||||||
|
from app.db import get_session
|
||||||
|
from app.forms import verify_hcaptcha
|
||||||
|
from app.models import (
|
||||||
|
Account,
|
||||||
|
Artist,
|
||||||
|
Edition,
|
||||||
|
FormField,
|
||||||
|
FormFieldType,
|
||||||
|
FormTemplate,
|
||||||
|
Position,
|
||||||
|
Volunteer,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/editions/{edition_id}/form", tags=["form"])
|
||||||
|
|
||||||
|
_MAX_PHOTO_BYTES = 8 * 1024 * 1024
|
||||||
|
_ALLOWED_PHOTO_TYPES = {"image/png", "image/jpeg"}
|
||||||
|
# Vœux de poste : au plus deux, pour éviter que tout le monde coche tous les
|
||||||
|
# postes « au cas où » — force une vraie priorisation dès le formulaire.
|
||||||
|
_MAX_POSITION_WISHES = 2
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Configuration (COORDINATION) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _get_or_create_template(session: Session, edition_id: uuid.UUID) -> FormTemplate:
|
||||||
|
template = session.exec(
|
||||||
|
select(FormTemplate).where(FormTemplate.edition_id == edition_id)
|
||||||
|
).first()
|
||||||
|
if template is None:
|
||||||
|
template = FormTemplate(edition_id=edition_id)
|
||||||
|
session.add(template)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(template)
|
||||||
|
return template
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled_fields(session: Session, edition_id: uuid.UUID) -> list[FormFieldType]:
|
||||||
|
return list(
|
||||||
|
session.exec(
|
||||||
|
select(FormField.field_type)
|
||||||
|
.where(FormField.edition_id == edition_id)
|
||||||
|
.order_by(FormField.field_type)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormTemplateRead:
|
||||||
|
return FormTemplateRead(
|
||||||
|
edition_id=template.edition_id, is_open=template.is_open, fields=fields
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=FormTemplateRead)
|
||||||
|
def get_form(
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_coordinator),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> FormTemplateRead:
|
||||||
|
template = _get_or_create_template(session, edition.id)
|
||||||
|
return _template_read(template, _enabled_fields(session, edition.id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/fields", response_model=FormTemplateRead)
|
||||||
|
def set_fields(
|
||||||
|
payload: FormFieldsSet,
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_coordinator),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> FormTemplateRead:
|
||||||
|
template = _get_or_create_template(session, edition.id)
|
||||||
|
session.execute(delete(FormField).where(FormField.edition_id == edition.id))
|
||||||
|
for field_type in dict.fromkeys(payload.fields): # dé-doublonne, garde l'ordre
|
||||||
|
session.add(FormField(edition_id=edition.id, field_type=field_type))
|
||||||
|
session.commit()
|
||||||
|
return _template_read(template, _enabled_fields(session, edition.id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("", response_model=FormTemplateRead)
|
||||||
|
def set_open(
|
||||||
|
payload: FormTemplateUpdate,
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_coordinator),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> FormTemplateRead:
|
||||||
|
template = _get_or_create_template(session, edition.id)
|
||||||
|
template.is_open = payload.is_open
|
||||||
|
session.add(template)
|
||||||
|
session.commit()
|
||||||
|
return _template_read(template, _enabled_fields(session, edition.id))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Formulaire public (aucune authentification) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.get("/public", response_model=PublicFormConfig)
|
||||||
|
def get_public_form(
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> PublicFormConfig:
|
||||||
|
template = _get_or_create_template(session, edition.id)
|
||||||
|
fields = _enabled_fields(session, edition.id)
|
||||||
|
|
||||||
|
positions: list[PublicPositionOption] = []
|
||||||
|
if FormFieldType.POSITION_WISHES in fields:
|
||||||
|
positions = [
|
||||||
|
PublicPositionOption(id=p.id, name=p.name)
|
||||||
|
for p in session.exec(
|
||||||
|
select(Position)
|
||||||
|
.where(Position.edition_id == edition.id)
|
||||||
|
.order_by(Position.name)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
artists: list[PublicArtistOption] = []
|
||||||
|
if FormFieldType.ARTIST_WISHES in fields:
|
||||||
|
artists = [
|
||||||
|
PublicArtistOption(
|
||||||
|
id=a.id, name=a.name, performance_date=a.performance_date
|
||||||
|
)
|
||||||
|
for a in session.exec(
|
||||||
|
select(Artist)
|
||||||
|
.where(Artist.edition_id == edition.id)
|
||||||
|
.order_by(Artist.performance_date, Artist.name)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
return PublicFormConfig(
|
||||||
|
is_open=template.is_open, fields=fields, positions=positions, artists=artists
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_id_list(raw: str | None) -> list[uuid.UUID]:
|
||||||
|
"""`position_wish_ids`/`artist_wish_ids` arrivent en JSON dans un champ
|
||||||
|
`multipart/form-data` (impossible d'y envoyer un tableau natif)."""
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
values = json.loads(raw)
|
||||||
|
if not isinstance(values, list):
|
||||||
|
raise ValueError
|
||||||
|
return [uuid.UUID(str(v)) for v in values]
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail={"code": "form.invalid_payload"}
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _check_one_artist_wish_per_day(
|
||||||
|
session: Session, edition_id: uuid.UUID, artist_ids: list[uuid.UUID]
|
||||||
|
) -> None:
|
||||||
|
"""Un seul vœu d'artiste par soir de festival — `set_artists` (validation
|
||||||
|
d'appartenance à l'édition) s'occupe du reste."""
|
||||||
|
dates = list(
|
||||||
|
session.exec(
|
||||||
|
select(Artist.performance_date).where(
|
||||||
|
Artist.edition_id == edition_id, Artist.id.in_(artist_ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(dates) != len(set(dates)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail={"code": "form.too_many_artists_same_day"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/public/submit",
|
||||||
|
response_model=FormSubmissionResult,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def submit_public_form(
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
first_name: str = Form(...),
|
||||||
|
last_name: str = Form(...),
|
||||||
|
hcaptcha_token: str = Form(...),
|
||||||
|
email: str | None = Form(None),
|
||||||
|
phone: str | None = Form(None),
|
||||||
|
comment: str | None = Form(None),
|
||||||
|
companion_wish_note: str | None = Form(None),
|
||||||
|
available: bool = Form(False),
|
||||||
|
willing_to_pay_fee: bool = Form(False),
|
||||||
|
position_wish_ids: str | None = Form(None),
|
||||||
|
artist_wish_ids: str | None = Form(None),
|
||||||
|
photo: UploadFile | None = File(None),
|
||||||
|
) -> FormSubmissionResult:
|
||||||
|
# Filet de sécurité global (par édition) : hCaptcha est la défense
|
||||||
|
# principale, voir `app/auth/rate_limit.py`.
|
||||||
|
form_submission_limiter.hit(str(edition.id))
|
||||||
|
|
||||||
|
template = _get_or_create_template(session, edition.id)
|
||||||
|
if not template.is_open:
|
||||||
|
raise HTTPException(status_code=422, detail={"code": "form.closed"})
|
||||||
|
|
||||||
|
if not verify_hcaptcha(hcaptcha_token):
|
||||||
|
raise HTTPException(status_code=422, detail={"code": "form.captcha_failed"})
|
||||||
|
|
||||||
|
if not first_name.strip() or not last_name.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"code": "import.row.missing_required_field",
|
||||||
|
"params": {"field": "first_name"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fields = set(_enabled_fields(session, edition.id))
|
||||||
|
|
||||||
|
position_ids = (
|
||||||
|
_parse_id_list(position_wish_ids) if FormFieldType.POSITION_WISHES in fields else []
|
||||||
|
)
|
||||||
|
if len(position_ids) > _MAX_POSITION_WISHES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail={"code": "form.too_many_position_wishes"}
|
||||||
|
)
|
||||||
|
|
||||||
|
artist_ids = (
|
||||||
|
_parse_id_list(artist_wish_ids) if FormFieldType.ARTIST_WISHES in fields else []
|
||||||
|
)
|
||||||
|
if artist_ids:
|
||||||
|
_check_one_artist_wish_per_day(session, edition.id, artist_ids)
|
||||||
|
|
||||||
|
volunteer = Volunteer(
|
||||||
|
edition_id=edition.id,
|
||||||
|
first_name=first_name.strip().title(),
|
||||||
|
last_name=last_name.strip().title(),
|
||||||
|
email=((email or "").strip() or None) if FormFieldType.EMAIL in fields else None,
|
||||||
|
phone=((phone or "").strip() or None) if FormFieldType.PHONE in fields else None,
|
||||||
|
comment=(
|
||||||
|
(comment or "").strip() or None if FormFieldType.COMMENT in fields else None
|
||||||
|
),
|
||||||
|
companion_wish_note=(
|
||||||
|
(companion_wish_note or "").strip() or None
|
||||||
|
if FormFieldType.COMPANION_WISH in fields
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
self_declared_available=(
|
||||||
|
available if FormFieldType.AVAILABLE in fields else False
|
||||||
|
),
|
||||||
|
willing_to_pay_fee=(
|
||||||
|
willing_to_pay_fee if FormFieldType.WILLING_TO_PAY_FEE in fields else False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if FormFieldType.PHOTO in fields and photo is not None and photo.filename:
|
||||||
|
content_type = photo.content_type or ""
|
||||||
|
if content_type not in _ALLOWED_PHOTO_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail={"code": "form.photo_type_not_supported"}
|
||||||
|
)
|
||||||
|
content = await photo.read()
|
||||||
|
if len(content) > _MAX_PHOTO_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413, detail={"code": "form.photo_too_large"}
|
||||||
|
)
|
||||||
|
if content:
|
||||||
|
volunteer.photo = content
|
||||||
|
volunteer.photo_content_type = content_type
|
||||||
|
|
||||||
|
session.add(volunteer)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
if FormFieldType.POSITION_WISHES in fields:
|
||||||
|
set_wished_positions(session, volunteer, position_ids)
|
||||||
|
if FormFieldType.ARTIST_WISHES in fields:
|
||||||
|
set_artists(session, volunteer, artist_ids)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
session.refresh(volunteer)
|
||||||
|
return FormSubmissionResult(volunteer_id=volunteer.id)
|
||||||
@@ -9,6 +9,7 @@ from app.models import (
|
|||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
BadgeAlign,
|
BadgeAlign,
|
||||||
BadgeFieldType,
|
BadgeFieldType,
|
||||||
|
FormFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Role,
|
Role,
|
||||||
)
|
)
|
||||||
@@ -69,6 +70,11 @@ class VolunteerUpdate(ApiModel):
|
|||||||
artist_ids: list[uuid.UUID] | None = None
|
artist_ids: list[uuid.UUID] | None = None
|
||||||
wished_position_ids: list[uuid.UUID] | None = None
|
wished_position_ids: list[uuid.UUID] | None = None
|
||||||
wished_companion_ids: list[uuid.UUID] | None = None
|
wished_companion_ids: list[uuid.UUID] | None = None
|
||||||
|
# Issus du formulaire public (`app/api/forms.py`), éditables ensuite
|
||||||
|
# depuis la fiche — voir CLAUDE.md § 2 (Volunteer).
|
||||||
|
companion_wish_note: str | None = None
|
||||||
|
self_declared_available: bool | None = None
|
||||||
|
willing_to_pay_fee: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class VolunteerRead(ApiModel):
|
class VolunteerRead(ApiModel):
|
||||||
@@ -82,6 +88,10 @@ class VolunteerRead(ApiModel):
|
|||||||
artist_ids: list[uuid.UUID]
|
artist_ids: list[uuid.UUID]
|
||||||
wished_position_ids: list[uuid.UUID]
|
wished_position_ids: list[uuid.UUID]
|
||||||
wished_companion_ids: list[uuid.UUID]
|
wished_companion_ids: list[uuid.UUID]
|
||||||
|
has_photo: bool
|
||||||
|
companion_wish_note: str | None
|
||||||
|
self_declared_available: bool
|
||||||
|
willing_to_pay_fee: bool
|
||||||
created_at: dt.datetime
|
created_at: dt.datetime
|
||||||
updated_at: dt.datetime
|
updated_at: dt.datetime
|
||||||
|
|
||||||
@@ -457,3 +467,49 @@ class PlanningNotificationResult(ApiModel):
|
|||||||
queued: int
|
queued: int
|
||||||
skipped_no_email: int
|
skipped_no_email: int
|
||||||
recipients: list[uuid.UUID] = Field(default_factory=list)
|
recipients: list[uuid.UUID] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Formulaire public d'inscription #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class FormFieldsSet(ApiModel):
|
||||||
|
"""Remplace l'intégralité des champs activés du formulaire — nom/prénom
|
||||||
|
toujours collectés, pas dans cette liste (voir `FormFieldType`)."""
|
||||||
|
|
||||||
|
fields: list[FormFieldType] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class FormTemplateRead(ApiModel):
|
||||||
|
edition_id: uuid.UUID
|
||||||
|
is_open: bool
|
||||||
|
fields: list[FormFieldType]
|
||||||
|
|
||||||
|
|
||||||
|
class FormTemplateUpdate(ApiModel):
|
||||||
|
is_open: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PublicPositionOption(ApiModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class PublicArtistOption(ApiModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
performance_date: dt.date
|
||||||
|
|
||||||
|
|
||||||
|
class PublicFormConfig(ApiModel):
|
||||||
|
"""Tout ce qu'il faut pour afficher le formulaire public — jamais plus :
|
||||||
|
ni les bénévoles déjà inscrits, ni les créneaux/affectations de
|
||||||
|
l'édition (voir `app/api/forms.py:get_public_form`)."""
|
||||||
|
|
||||||
|
is_open: bool
|
||||||
|
fields: list[FormFieldType]
|
||||||
|
positions: list[PublicPositionOption]
|
||||||
|
artists: list[PublicArtistOption]
|
||||||
|
|
||||||
|
|
||||||
|
class FormSubmissionResult(ApiModel):
|
||||||
|
volunteer_id: uuid.UUID
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ from fastapi import APIRouter, Depends, HTTPException, Response, status
|
|||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.api._common import owned_or_404, replace_links
|
from app.api._common import (
|
||||||
|
owned_or_404,
|
||||||
|
replace_links,
|
||||||
|
resolve_owned_ids,
|
||||||
|
set_artists,
|
||||||
|
set_wished_positions,
|
||||||
|
)
|
||||||
from app.api.deps import get_edition
|
from app.api.deps import get_edition
|
||||||
from app.api.schemas import (
|
from app.api.schemas import (
|
||||||
VolunteerCreate,
|
VolunteerCreate,
|
||||||
@@ -93,74 +99,18 @@ def _companion_ids(session: Session, volunteer_id: uuid.UUID) -> list[uuid.UUID]
|
|||||||
|
|
||||||
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
|
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
|
||||||
return VolunteerRead(
|
return VolunteerRead(
|
||||||
**volunteer.model_dump(),
|
**volunteer.model_dump(exclude={"photo", "photo_content_type"}),
|
||||||
artist_ids=_artist_ids(session, volunteer.id),
|
artist_ids=_artist_ids(session, volunteer.id),
|
||||||
wished_position_ids=_wished_position_ids(session, volunteer.id),
|
wished_position_ids=_wished_position_ids(session, volunteer.id),
|
||||||
wished_companion_ids=_companion_ids(session, volunteer.id),
|
wished_companion_ids=_companion_ids(session, volunteer.id),
|
||||||
)
|
has_photo=volunteer.photo is not None,
|
||||||
|
|
||||||
|
|
||||||
def _resolve_owned_ids(
|
|
||||||
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID], code: str
|
|
||||||
) -> list[uuid.UUID]:
|
|
||||||
"""Ordre d'entrée conservé, doublons retirés ; 422 si un id est étranger."""
|
|
||||||
wanted = list(dict.fromkeys(ids))
|
|
||||||
if wanted:
|
|
||||||
found = set(
|
|
||||||
session.exec(
|
|
||||||
select(model.id).where(
|
|
||||||
model.edition_id == edition_id,
|
|
||||||
model.id.in_(wanted),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if len(found) != len(wanted):
|
|
||||||
raise HTTPException(status_code=422, detail={"code": code})
|
|
||||||
return wanted
|
|
||||||
|
|
||||||
|
|
||||||
def _set_artists(
|
|
||||||
session: Session, volunteer: Volunteer, artist_ids: list[uuid.UUID]
|
|
||||||
) -> None:
|
|
||||||
ids = _resolve_owned_ids(
|
|
||||||
session, Artist, volunteer.edition_id, artist_ids, "artist.unknown"
|
|
||||||
)
|
|
||||||
replace_links(
|
|
||||||
session,
|
|
||||||
VolunteerArtist,
|
|
||||||
filter_column=VolunteerArtist.volunteer_id,
|
|
||||||
filter_value=volunteer.id,
|
|
||||||
pairs=[
|
|
||||||
{"volunteer_id": volunteer.id, "artist_id": aid} for aid in ids
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _set_wished_positions(
|
|
||||||
session: Session, volunteer: Volunteer, position_ids: list[uuid.UUID]
|
|
||||||
) -> None:
|
|
||||||
ids = _resolve_owned_ids(
|
|
||||||
session,
|
|
||||||
Position,
|
|
||||||
volunteer.edition_id,
|
|
||||||
position_ids,
|
|
||||||
"position.unknown",
|
|
||||||
)
|
|
||||||
replace_links(
|
|
||||||
session,
|
|
||||||
VolunteerPositionWish,
|
|
||||||
filter_column=VolunteerPositionWish.volunteer_id,
|
|
||||||
filter_value=volunteer.id,
|
|
||||||
pairs=[
|
|
||||||
{"volunteer_id": volunteer.id, "position_id": pid} for pid in ids
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _set_companions(
|
def _set_companions(
|
||||||
session: Session, volunteer: Volunteer, companion_ids: list[uuid.UUID]
|
session: Session, volunteer: Volunteer, companion_ids: list[uuid.UUID]
|
||||||
) -> None:
|
) -> None:
|
||||||
ids = _resolve_owned_ids(
|
ids = resolve_owned_ids(
|
||||||
session, Volunteer, volunteer.edition_id, companion_ids, "volunteer.unknown"
|
session, Volunteer, volunteer.edition_id, companion_ids, "volunteer.unknown"
|
||||||
)
|
)
|
||||||
if volunteer.id in ids:
|
if volunteer.id in ids:
|
||||||
@@ -195,8 +145,8 @@ def create_volunteer(
|
|||||||
)
|
)
|
||||||
session.add(volunteer)
|
session.add(volunteer)
|
||||||
session.flush()
|
session.flush()
|
||||||
_set_artists(session, volunteer, payload.artist_ids)
|
set_artists(session, volunteer, payload.artist_ids)
|
||||||
_set_wished_positions(session, volunteer, payload.wished_position_ids)
|
set_wished_positions(session, volunteer, payload.wished_position_ids)
|
||||||
_set_companions(session, volunteer, payload.wished_companion_ids)
|
_set_companions(session, volunteer, payload.wished_companion_ids)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(volunteer)
|
session.refresh(volunteer)
|
||||||
@@ -272,6 +222,25 @@ def get_volunteer_planning(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{volunteer_id}/photo")
|
||||||
|
def get_volunteer_photo(
|
||||||
|
volunteer_id: uuid.UUID,
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_staff),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> Response:
|
||||||
|
"""Photo transmise par le bénévole via le formulaire public
|
||||||
|
(`app/api/forms.py`) — réservée à COORDINATION/RÉFÉRENT, pas exposée à
|
||||||
|
l'écran bénévole."""
|
||||||
|
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||||
|
if volunteer.photo is None:
|
||||||
|
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
|
||||||
|
return Response(
|
||||||
|
content=volunteer.photo,
|
||||||
|
media_type=volunteer.photo_content_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{volunteer_id}", response_model=VolunteerRead)
|
@router.patch("/{volunteer_id}", response_model=VolunteerRead)
|
||||||
def update_volunteer(
|
def update_volunteer(
|
||||||
volunteer_id: uuid.UUID,
|
volunteer_id: uuid.UUID,
|
||||||
@@ -289,9 +258,9 @@ def update_volunteer(
|
|||||||
setattr(volunteer, field, value)
|
setattr(volunteer, field, value)
|
||||||
session.flush()
|
session.flush()
|
||||||
if artist_ids is not None:
|
if artist_ids is not None:
|
||||||
_set_artists(session, volunteer, artist_ids)
|
set_artists(session, volunteer, artist_ids)
|
||||||
if wished_position_ids is not None:
|
if wished_position_ids is not None:
|
||||||
_set_wished_positions(session, volunteer, wished_position_ids)
|
set_wished_positions(session, volunteer, wished_position_ids)
|
||||||
if wished_companion_ids is not None:
|
if wished_companion_ids is not None:
|
||||||
_set_companions(session, volunteer, wished_companion_ids)
|
_set_companions(session, volunteer, wished_companion_ids)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -60,3 +60,10 @@ login_limiter = RateLimiter(max_attempts=5, window_seconds=60)
|
|||||||
# trois fois ; au-delà c'est soit un abus, soit un email qui n'arrivera pas
|
# trois fois ; au-delà c'est soit un abus, soit un email qui n'arrivera pas
|
||||||
# plus vite en insistant.
|
# plus vite en insistant.
|
||||||
magic_link_limiter = RateLimiter(max_attempts=3, window_seconds=300)
|
magic_link_limiter = RateLimiter(max_attempts=3, window_seconds=300)
|
||||||
|
# Formulaire public d'inscription (`app/api/forms.py`) : pas d'identité
|
||||||
|
# fiable à clé (n'importe qui peut soumettre des emails différents à chaque
|
||||||
|
# fois, et l'IP réelle du client n'est pas encore transmise par Caddy — voir
|
||||||
|
# plus haut) ; hCaptcha est la défense principale contre les robots, ceci
|
||||||
|
# n'est qu'un filet de sécurité global côté édition contre un emballement
|
||||||
|
# malgré tout (30 inscriptions/5 min est très large pour un usage réel).
|
||||||
|
form_submission_limiter = RateLimiter(max_attempts=30, window_seconds=300)
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ class Settings(BaseSettings):
|
|||||||
# (ou un alias validé). En cas de rejet, mettre SMTP_FROM = SMTP_USER.
|
# (ou un alias validé). En cas de rejet, mettre SMTP_FROM = SMTP_USER.
|
||||||
smtp_from: str = "Festival des bénévoles <no-reply@example.test>"
|
smtp_from: str = "Festival des bénévoles <no-reply@example.test>"
|
||||||
|
|
||||||
|
# Formulaire public d'inscription (`app/api/forms.py`). Secret hCaptcha
|
||||||
|
# seulement (la clé de site est publique, elle vit côté front-end en
|
||||||
|
# VITE_HCAPTCHA_SITE_KEY, figée au build). None ⇒ vérification
|
||||||
|
# désactivée (dev/tests uniquement, voir `app/forms/captcha.py`).
|
||||||
|
hcaptcha_secret_key: str | None = None
|
||||||
|
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
cors_origins: list[str] = ["http://localhost:5173"]
|
cors_origins: list[str] = ["http://localhost:5173"]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.forms.captcha import verify_hcaptcha
|
||||||
|
|
||||||
|
__all__ = ["verify_hcaptcha"]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Vérification hCaptcha du formulaire public d'inscription.
|
||||||
|
|
||||||
|
Point d'injection unique : `_verify_with_hcaptcha` (remplacé dans les tests,
|
||||||
|
même principe que `app.email.sender.deliver_many`). Sans `HCAPTCHA_SECRET_KEY`
|
||||||
|
configurée (dev/tests), la vérification est désactivée — voir
|
||||||
|
`app/config.py:Settings.hcaptcha_secret_key`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger("app.forms")
|
||||||
|
|
||||||
|
_VERIFY_URL = "https://hcaptcha.com/siteverify"
|
||||||
|
_TIMEOUT_SECONDS = 8.0
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_with_hcaptcha(token: str, secret: str) -> bool:
|
||||||
|
"""Appel réel à l'API hCaptcha. Toute erreur réseau/serveur est traitée
|
||||||
|
comme un échec de vérification (on ne laisse jamais passer par défaut)."""
|
||||||
|
try:
|
||||||
|
resp = httpx.post(
|
||||||
|
_VERIFY_URL,
|
||||||
|
data={"secret": secret, "response": token},
|
||||||
|
timeout=_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return bool(resp.json().get("success"))
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.warning("Échec de la vérification hCaptcha : %s", exc.__class__.__name__)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify_hcaptcha(token: str | None) -> bool:
|
||||||
|
"""`False` si `token` est vide, ou si la vérification hCaptcha échoue.
|
||||||
|
Toujours `True` quand `HCAPTCHA_SECRET_KEY` n'est pas configurée (dev,
|
||||||
|
tests) — voir l'avertissement au démarrage dans `app/main.py`."""
|
||||||
|
secret = get_settings().hcaptcha_secret_key
|
||||||
|
if not secret:
|
||||||
|
return True
|
||||||
|
if not token:
|
||||||
|
return False
|
||||||
|
return _verify_with_hcaptcha(token, secret)
|
||||||
@@ -73,5 +73,12 @@
|
|||||||
"position.category.catering": "Catering",
|
"position.category.catering": "Catering",
|
||||||
"position.category.setup": "Montage",
|
"position.category.setup": "Montage",
|
||||||
"position.category.stage": "Régie",
|
"position.category.stage": "Régie",
|
||||||
"position.category.other": "Autre"
|
"position.category.other": "Autre",
|
||||||
|
"form.closed": "Les inscriptions sont actuellement fermées.",
|
||||||
|
"form.captcha_failed": "Vérification anti-robot invalide, réessayez.",
|
||||||
|
"form.invalid_payload": "Données du formulaire invalides.",
|
||||||
|
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
|
||||||
|
"form.photo_too_large": "La photo est trop volumineuse.",
|
||||||
|
"form.too_many_position_wishes": "Deux vœux de poste au maximum.",
|
||||||
|
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.api.availability import router as availability_router
|
|||||||
from app.api.badges import router as badge_router
|
from app.api.badges import router as badge_router
|
||||||
from app.api.day_parts import router as day_part_router
|
from app.api.day_parts import router as day_part_router
|
||||||
from app.api.editions import router as edition_router
|
from app.api.editions import router as edition_router
|
||||||
|
from app.api.forms import router as form_router
|
||||||
from app.api.health import router as health_router
|
from app.api.health import router as health_router
|
||||||
from app.api.imports import router as import_router
|
from app.api.imports import router as import_router
|
||||||
from app.api.matrix import router as matrix_router
|
from app.api.matrix import router as matrix_router
|
||||||
@@ -37,6 +38,15 @@ if settings.is_production and not settings.email_enabled:
|
|||||||
"le SMTP avant l'ouverture au public."
|
"le SMTP avant l'ouverture au public."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if settings.is_production and not settings.hcaptcha_secret_key:
|
||||||
|
# Le formulaire public (`app/api/forms.py`) reste accessible sans
|
||||||
|
# HCAPTCHA_SECRET_KEY, mais sans aucune protection anti-robot au-delà du
|
||||||
|
# filet de sécurité global par édition (`form_submission_limiter`).
|
||||||
|
logging.getLogger("app.main").warning(
|
||||||
|
"HCAPTCHA_SECRET_KEY absente en production : le formulaire public "
|
||||||
|
"d'inscription n'est pas protégé par captcha."
|
||||||
|
)
|
||||||
|
|
||||||
app = FastAPI(title="Gestion des bénévoles", version="0.1.0")
|
app = FastAPI(title="Gestion des bénévoles", version="0.1.0")
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -63,3 +73,4 @@ app.include_router(solver_router)
|
|||||||
app.include_router(matrix_router)
|
app.include_router(matrix_router)
|
||||||
app.include_router(notification_router)
|
app.include_router(notification_router)
|
||||||
app.include_router(badge_router)
|
app.include_router(badge_router)
|
||||||
|
app.include_router(form_router)
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Formulaire public d'inscription + champs bénévole associés
|
||||||
|
|
||||||
|
Revision ID: 0013_signup_form
|
||||||
|
Revises: 0012_badge_template_size
|
||||||
|
Create Date: 2026-09-21
|
||||||
|
|
||||||
|
Deux tables : `form_template` (configuration par édition — ouvert/fermé) et
|
||||||
|
`form_field` (champs activés, présence = activation). Sur `volunteer` :
|
||||||
|
`photo`/`photo_content_type` (formulaire public), `companion_wish_note`
|
||||||
|
(vœu de compagnon en texte libre, résolu ensuite par la coordination),
|
||||||
|
`self_declared_available`/`willing_to_pay_fee` (cases à cocher purement
|
||||||
|
informatives). Voir `app/api/forms.py`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0013_signup_form"
|
||||||
|
down_revision: str | None = "0012_badge_template_size"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("volunteer", sa.Column("photo", sa.LargeBinary(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"volunteer", sa.Column("photo_content_type", sa.String(), nullable=True)
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"volunteer", sa.Column("companion_wish_note", sa.String(), nullable=True)
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"volunteer",
|
||||||
|
sa.Column(
|
||||||
|
"self_declared_available", sa.Boolean(),
|
||||||
|
server_default="false", nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"volunteer",
|
||||||
|
sa.Column(
|
||||||
|
"willing_to_pay_fee", sa.Boolean(),
|
||||||
|
server_default="false", nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"form_template",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"is_open", sa.Boolean(), server_default="true", nullable=False
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("edition_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"form_field",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"field_type",
|
||||||
|
sa.Enum(
|
||||||
|
"EMAIL", "PHONE", "POSITION_WISHES", "ARTIST_WISHES",
|
||||||
|
"COMPANION_WISH", "AVAILABLE", "WILLING_TO_PAY_FEE", "PHOTO",
|
||||||
|
"COMMENT",
|
||||||
|
name="formfieldtype", native_enum=False,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"edition_id", "field_type", name="uq_form_field_edition_type"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_form_field_edition_id", "form_field", ["edition_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_form_field_edition_id", table_name="form_field")
|
||||||
|
op.drop_table("form_field")
|
||||||
|
op.drop_table("form_template")
|
||||||
|
op.drop_column("volunteer", "willing_to_pay_fee")
|
||||||
|
op.drop_column("volunteer", "self_declared_available")
|
||||||
|
op.drop_column("volunteer", "companion_wish_note")
|
||||||
|
op.drop_column("volunteer", "photo_content_type")
|
||||||
|
op.drop_column("volunteer", "photo")
|
||||||
@@ -14,6 +14,8 @@ from app.models.domain import (
|
|||||||
BadgeTemplate,
|
BadgeTemplate,
|
||||||
DayPart,
|
DayPart,
|
||||||
Edition,
|
Edition,
|
||||||
|
FormField,
|
||||||
|
FormTemplate,
|
||||||
MagicLinkToken,
|
MagicLinkToken,
|
||||||
Position,
|
Position,
|
||||||
Shift,
|
Shift,
|
||||||
@@ -26,6 +28,7 @@ from app.models.enums import (
|
|||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
BadgeAlign,
|
BadgeAlign,
|
||||||
BadgeFieldType,
|
BadgeFieldType,
|
||||||
|
FormFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Role,
|
Role,
|
||||||
)
|
)
|
||||||
@@ -42,6 +45,9 @@ __all__ = [
|
|||||||
"BadgeTemplate",
|
"BadgeTemplate",
|
||||||
"DayPart",
|
"DayPart",
|
||||||
"Edition",
|
"Edition",
|
||||||
|
"FormField",
|
||||||
|
"FormFieldType",
|
||||||
|
"FormTemplate",
|
||||||
"MagicLinkToken",
|
"MagicLinkToken",
|
||||||
"Position",
|
"Position",
|
||||||
"PositionCategory",
|
"PositionCategory",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from app.models.enums import (
|
|||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
BadgeAlign,
|
BadgeAlign,
|
||||||
BadgeFieldType,
|
BadgeFieldType,
|
||||||
|
FormFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Role,
|
Role,
|
||||||
)
|
)
|
||||||
@@ -170,6 +171,27 @@ class Volunteer(TimestampMixin, table=True):
|
|||||||
last_name: str
|
last_name: str
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
|
# Renseigné par le formulaire public d'inscription (`app/api/forms.py`) —
|
||||||
|
# voir aussi ce module pour `photo`/`photo_content_type`.
|
||||||
|
photo: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
||||||
|
photo_content_type: str | None = None
|
||||||
|
# « Collègue que j'aime bien » saisi en texte libre à l'inscription : la
|
||||||
|
# personne visée n'existe pas forcément encore en base à ce moment-là,
|
||||||
|
# donc pas de lien direct vers un autre `Volunteer`. La coordination lit
|
||||||
|
# cette note sur la fiche et la transforme, si besoin, en un vrai
|
||||||
|
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
|
||||||
|
companion_wish_note: str | None = None
|
||||||
|
# Cases à cocher du formulaire public — purement informatives : aucune
|
||||||
|
# logique n'y est attachée (pas de lien avec `Availability`, pas de
|
||||||
|
# workflow de paiement).
|
||||||
|
self_declared_available: bool = Field(
|
||||||
|
default=False,
|
||||||
|
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
willing_to_pay_fee: bool = Field(
|
||||||
|
default=False,
|
||||||
|
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
|
||||||
wished_artists: list[Artist] = Relationship(
|
wished_artists: list[Artist] = Relationship(
|
||||||
back_populates="fans",
|
back_populates="fans",
|
||||||
@@ -436,3 +458,45 @@ class BadgeField(TimestampMixin, table=True):
|
|||||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||||
)
|
)
|
||||||
color: str = Field(default="#000000")
|
color: str = Field(default="#000000")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Formulaire public d'inscription (voir app/api/forms.py) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class FormTemplate(TimestampMixin, table=True):
|
||||||
|
"""Configuration du formulaire public d'une édition. Une ligne par
|
||||||
|
édition (créée à la demande). `is_open` coupe les nouvelles soumissions
|
||||||
|
sans effacer la configuration (les champs activés restent enregistrés)."""
|
||||||
|
|
||||||
|
__tablename__ = "form_template"
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
edition_id: uuid.UUID = Field(
|
||||||
|
foreign_key="edition.id", unique=True, ondelete="CASCADE"
|
||||||
|
)
|
||||||
|
is_open: bool = Field(
|
||||||
|
default=True,
|
||||||
|
sa_column=Column(Boolean, nullable=False, server_default="true"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FormField(SQLModel, table=True):
|
||||||
|
"""Un champ activé sur le formulaire public (voir `FormFieldType`) — la
|
||||||
|
présence d'une ligne vaut activation. Pas de champs d'audit : juste une
|
||||||
|
case à cocher dans l'écran de configuration, remplacée en bloc (`PUT
|
||||||
|
.../form/fields`), rien d'autre à suivre dans le temps."""
|
||||||
|
|
||||||
|
__tablename__ = "form_field"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"edition_id", "field_type", name="uq_form_field_edition_type"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
edition_id: uuid.UUID = Field(
|
||||||
|
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||||
|
)
|
||||||
|
field_type: FormFieldType = Field(
|
||||||
|
sa_column=Column(SAEnum(FormFieldType, native_enum=False), nullable=False)
|
||||||
|
)
|
||||||
|
|||||||
@@ -43,3 +43,19 @@ class BadgeAlign(enum.StrEnum):
|
|||||||
LEFT = "LEFT"
|
LEFT = "LEFT"
|
||||||
CENTER = "CENTER"
|
CENTER = "CENTER"
|
||||||
RIGHT = "RIGHT"
|
RIGHT = "RIGHT"
|
||||||
|
|
||||||
|
|
||||||
|
class FormFieldType(enum.StrEnum):
|
||||||
|
"""Champ activable sur le formulaire public d'inscription (voir
|
||||||
|
`app/api/forms.py`). Nom/prénom sont toujours collectés, pas au choix de
|
||||||
|
la coordination — ils sont indispensables à la création du bénévole."""
|
||||||
|
|
||||||
|
EMAIL = "EMAIL"
|
||||||
|
PHONE = "PHONE"
|
||||||
|
POSITION_WISHES = "POSITION_WISHES"
|
||||||
|
ARTIST_WISHES = "ARTIST_WISHES"
|
||||||
|
COMPANION_WISH = "COMPANION_WISH"
|
||||||
|
AVAILABLE = "AVAILABLE"
|
||||||
|
WILLING_TO_PAY_FEE = "WILLING_TO_PAY_FEE"
|
||||||
|
PHOTO = "PHOTO"
|
||||||
|
COMMENT = "COMMENT"
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ dependencies = [
|
|||||||
"ortools>=9.11",
|
"ortools>=9.11",
|
||||||
"reportlab>=4.2",
|
"reportlab>=4.2",
|
||||||
"pillow>=11.0",
|
"pillow>=11.0",
|
||||||
|
"httpx>=0.27",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.3",
|
"pytest>=8.3",
|
||||||
"pytest-cov>=6.0",
|
"pytest-cov>=6.0",
|
||||||
"httpx>=0.27",
|
|
||||||
"ruff>=0.8",
|
"ruff>=0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ from sqlmodel import Session, SQLModel, create_engine, select
|
|||||||
|
|
||||||
from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata)
|
from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata)
|
||||||
from app.auth.deps import get_current_account
|
from app.auth.deps import get_current_account
|
||||||
from app.auth.rate_limit import login_limiter, magic_link_limiter
|
from app.auth.rate_limit import (
|
||||||
|
form_submission_limiter,
|
||||||
|
login_limiter,
|
||||||
|
magic_link_limiter,
|
||||||
|
)
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.main import app
|
from app.main import app
|
||||||
@@ -31,6 +35,7 @@ def _reset_rate_limiters():
|
|||||||
déclenche un ne doit pas polluer les suivants."""
|
déclenche un ne doit pas polluer les suivants."""
|
||||||
login_limiter.reset()
|
login_limiter.reset()
|
||||||
magic_link_limiter.reset()
|
magic_link_limiter.reset()
|
||||||
|
form_submission_limiter.reset()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
"""Formulaire public d'inscription : configuration (COORDINATION) et
|
||||||
|
soumission publique (aucune authentification)."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import datetime as dt
|
||||||
|
import io
|
||||||
|
|
||||||
|
from app.auth.rate_limit import form_submission_limiter
|
||||||
|
from app.models import Role
|
||||||
|
from tests.factories import make_artist, make_position
|
||||||
|
|
||||||
|
# PNG 1×1 valide (transparent), le plus petit possible.
|
||||||
|
_PNG = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
|
||||||
|
"42YAAAAASUVORK5CYII="
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _form_url(edition, suffix=""):
|
||||||
|
return f"/editions/{edition.id}/form{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _submit(client, edition, **fields):
|
||||||
|
return client.post(_form_url(edition, "/public/submit"), data=fields)
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_starts_open_with_no_fields(client, edition):
|
||||||
|
resp = client.get(_form_url(edition))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["is_open"] is True
|
||||||
|
assert body["fields"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_is_coordinator_only(edition, make_account, as_account):
|
||||||
|
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||||
|
lead_client = as_account(lead)
|
||||||
|
assert lead_client.get(_form_url(edition)).status_code == 403
|
||||||
|
resp = lead_client.put(_form_url(edition, "/fields"), json={"fields": []})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_setting_fields_replaces_them_entirely(client, edition):
|
||||||
|
resp = client.put(
|
||||||
|
_form_url(edition, "/fields"),
|
||||||
|
json={"fields": ["EMAIL", "PHONE", "PHOTO"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert sorted(resp.json()["fields"]) == ["EMAIL", "PHONE", "PHOTO"]
|
||||||
|
|
||||||
|
# Un second PUT remplace, ne cumule pas.
|
||||||
|
resp = client.put(_form_url(edition, "/fields"), json={"fields": ["EMAIL"]})
|
||||||
|
assert resp.json()["fields"] == ["EMAIL"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_closing_the_form(client, edition):
|
||||||
|
resp = client.patch(_form_url(edition), json={"is_open": False})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["is_open"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_form_hides_positions_and_artists_unless_enabled(
|
||||||
|
client, raw_client, edition, session
|
||||||
|
):
|
||||||
|
make_position(session, edition, name="Bar central")
|
||||||
|
make_artist(session, edition, name="Chilla")
|
||||||
|
|
||||||
|
resp = raw_client.get(_form_url(edition, "/public"))
|
||||||
|
body = resp.json()
|
||||||
|
assert body["positions"] == []
|
||||||
|
assert body["artists"] == []
|
||||||
|
|
||||||
|
client.put(
|
||||||
|
_form_url(edition, "/fields"),
|
||||||
|
json={"fields": ["POSITION_WISHES", "ARTIST_WISHES"]},
|
||||||
|
)
|
||||||
|
resp = raw_client.get(_form_url(edition, "/public"))
|
||||||
|
body = resp.json()
|
||||||
|
assert [p["name"] for p in body["positions"]] == ["Bar central"]
|
||||||
|
assert [a["name"] for a in body["artists"]] == ["Chilla"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_creates_a_volunteer(raw_client, client, edition, session):
|
||||||
|
client.put(
|
||||||
|
_form_url(edition, "/fields"),
|
||||||
|
json={"fields": ["EMAIL", "PHONE", "AVAILABLE", "WILLING_TO_PAY_FEE"]},
|
||||||
|
)
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="alex", last_name="martin", hcaptcha_token="x",
|
||||||
|
email="alex@example.org", phone="0600000000",
|
||||||
|
available="true", willing_to_pay_fee="true",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
volunteer_id = resp.json()["volunteer_id"]
|
||||||
|
|
||||||
|
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||||
|
assert fiche["first_name"] == "Alex"
|
||||||
|
assert fiche["last_name"] == "Martin"
|
||||||
|
assert fiche["email"] == "alex@example.org"
|
||||||
|
assert fiche["self_declared_available"] is True
|
||||||
|
assert fiche["willing_to_pay_fee"] is True
|
||||||
|
|
||||||
|
# Le bénévole créé apparaît dans la liste de la coordination.
|
||||||
|
volunteers = client.get(f"/editions/{edition.id}/volunteers").json()
|
||||||
|
assert any(v["id"] == volunteer_id for v in volunteers)
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_ignores_fields_not_enabled(raw_client, client, edition):
|
||||||
|
# Aucun champ activé : email/téléphone envoyés quand même, ignorés.
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
email="alex@example.org", phone="0600000000",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
volunteer_id = resp.json()["volunteer_id"]
|
||||||
|
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||||
|
assert fiche["email"] is None
|
||||||
|
assert fiche["phone"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_resolves_position_and_artist_wishes(
|
||||||
|
raw_client, client, edition, session
|
||||||
|
):
|
||||||
|
position = make_position(session, edition, name="Bar central")
|
||||||
|
artist = make_artist(session, edition, name="Chilla")
|
||||||
|
client.put(
|
||||||
|
_form_url(edition, "/fields"),
|
||||||
|
json={"fields": ["POSITION_WISHES", "ARTIST_WISHES", "COMPANION_WISH"]},
|
||||||
|
)
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
position_wish_ids=f'["{position.id}"]',
|
||||||
|
artist_wish_ids=f'["{artist.id}"]',
|
||||||
|
companion_wish_note="Sam, il/elle s'inscrira bientôt",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
volunteer_id = resp.json()["volunteer_id"]
|
||||||
|
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||||
|
assert fiche["wished_position_ids"] == [str(position.id)]
|
||||||
|
assert fiche["artist_ids"] == [str(artist.id)]
|
||||||
|
assert fiche["companion_wish_note"] == "Sam, il/elle s'inscrira bientôt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_with_a_photo(raw_client, client, edition):
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]})
|
||||||
|
resp = raw_client.post(
|
||||||
|
_form_url(edition, "/public/submit"),
|
||||||
|
data={"first_name": "Alex", "last_name": "Martin", "hcaptcha_token": "x"},
|
||||||
|
files={"photo": ("selfie.png", io.BytesIO(_PNG), "image/png")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
volunteer_id = resp.json()["volunteer_id"]
|
||||||
|
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||||
|
assert fiche["has_photo"] is True
|
||||||
|
|
||||||
|
photo = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}/photo")
|
||||||
|
assert photo.status_code == 200
|
||||||
|
assert photo.headers["content-type"] == "image/png"
|
||||||
|
assert photo.content == _PNG
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_an_unsupported_photo_type_is_rejected(raw_client, client, edition):
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]})
|
||||||
|
resp = raw_client.post(
|
||||||
|
_form_url(edition, "/public/submit"),
|
||||||
|
data={"first_name": "Alex", "last_name": "Martin", "hcaptcha_token": "x"},
|
||||||
|
files={"photo": ("selfie.gif", b"not-a-real-gif", "image/gif")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "form.photo_type_not_supported"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_more_than_two_position_wishes_is_rejected(
|
||||||
|
raw_client, client, edition, session
|
||||||
|
):
|
||||||
|
positions = [
|
||||||
|
make_position(session, edition, name=f"Poste {i}") for i in range(3)
|
||||||
|
]
|
||||||
|
ids = "[" + ",".join(f'"{p.id}"' for p in positions) + "]"
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["POSITION_WISHES"]})
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
position_wish_ids=ids,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "form.too_many_position_wishes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_two_position_wishes_is_accepted(raw_client, client, edition, session):
|
||||||
|
positions = [make_position(session, edition, name=f"Poste {i}") for i in range(2)]
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["POSITION_WISHES"]})
|
||||||
|
ids = "[" + ",".join(f'"{p.id}"' for p in positions) + "]"
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
position_wish_ids=ids,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_two_artist_wishes_on_the_same_day_is_rejected(
|
||||||
|
raw_client, client, edition, session
|
||||||
|
):
|
||||||
|
a1 = make_artist(session, edition, name="Chilla")
|
||||||
|
a2 = make_artist(session, edition, name="Kntongo") # même jour par défaut
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["ARTIST_WISHES"]})
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
artist_wish_ids=f'["{a1.id}", "{a2.id}"]',
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "form.too_many_artists_same_day"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_artist_wishes_on_different_days_is_accepted(
|
||||||
|
raw_client, client, edition, session
|
||||||
|
):
|
||||||
|
a1 = make_artist(session, edition, name="Chilla", performance_date=dt.date(2026, 7, 9))
|
||||||
|
a2 = make_artist(session, edition, name="Kntongo", performance_date=dt.date(2026, 7, 10))
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["ARTIST_WISHES"]})
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
artist_wish_ids=f'["{a1.id}", "{a2.id}"]',
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_a_comment(raw_client, client, edition):
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["COMMENT"]})
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
comment="Dispo seulement le samedi",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
volunteer_id = resp.json()["volunteer_id"]
|
||||||
|
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||||
|
assert fiche["comment"] == "Dispo seulement le samedi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_with_an_unknown_position_id_is_rejected(raw_client, client, edition):
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["POSITION_WISHES"]})
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
position_wish_ids='["00000000-0000-0000-0000-000000000000"]',
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "position.unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_when_the_form_is_closed_is_rejected(raw_client, client, edition):
|
||||||
|
client.patch(_form_url(edition), json={"is_open": False})
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "form.closed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_with_a_rejected_captcha(raw_client, edition, monkeypatch):
|
||||||
|
monkeypatch.setattr("app.api.forms.verify_hcaptcha", lambda token: False)
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "form.captcha_failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_is_rate_limited_per_edition(raw_client, edition):
|
||||||
|
for _ in range(form_submission_limiter.max_attempts):
|
||||||
|
form_submission_limiter.hit(str(edition.id))
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 429
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Vérification hCaptcha (module pur, pas de base) — voir `app/forms/captcha.py`."""
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.forms.captcha import verify_hcaptcha
|
||||||
|
|
||||||
|
|
||||||
|
def test_verification_is_skipped_without_a_configured_secret(monkeypatch):
|
||||||
|
monkeypatch.delenv("HCAPTCHA_SECRET_KEY", raising=False)
|
||||||
|
get_settings.cache_clear()
|
||||||
|
assert verify_hcaptcha(None) is True
|
||||||
|
assert verify_hcaptcha("") is True
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_token_is_rejected_when_a_secret_is_configured(monkeypatch):
|
||||||
|
monkeypatch.setenv("HCAPTCHA_SECRET_KEY", "secret")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
assert verify_hcaptcha(None) is False
|
||||||
|
assert verify_hcaptcha("") is False
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delegates_to_the_hcaptcha_api_when_a_secret_is_configured(monkeypatch):
|
||||||
|
monkeypatch.setenv("HCAPTCHA_SECRET_KEY", "secret")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.forms.captcha._verify_with_hcaptcha",
|
||||||
|
lambda token, secret: calls.append((token, secret)) or True,
|
||||||
|
)
|
||||||
|
assert verify_hcaptcha("a-token") is True
|
||||||
|
assert calls == [("a-token", "secret")]
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_failure_is_treated_as_a_failed_verification(monkeypatch):
|
||||||
|
monkeypatch.setenv("HCAPTCHA_SECRET_KEY", "secret")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
def _raise(*args, **kwargs):
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
raise httpx.ConnectError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr("httpx.post", _raise)
|
||||||
|
assert verify_hcaptcha("a-token") is False
|
||||||
|
get_settings.cache_clear()
|
||||||
@@ -29,6 +29,8 @@ EXPECTED_TABLES = {
|
|||||||
"assignment",
|
"assignment",
|
||||||
"badge_template",
|
"badge_template",
|
||||||
"badge_field",
|
"badge_field",
|
||||||
|
"form_template",
|
||||||
|
"form_field",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -106,6 +106,7 @@ dependencies = [
|
|||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
{ name = "bcrypt" },
|
{ name = "bcrypt" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
{ name = "httpx" },
|
||||||
{ name = "openpyxl" },
|
{ name = "openpyxl" },
|
||||||
{ name = "ortools" },
|
{ name = "ortools" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
@@ -120,7 +121,6 @@ dependencies = [
|
|||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "httpx" },
|
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-cov" },
|
{ name = "pytest-cov" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
@@ -131,6 +131,7 @@ requires-dist = [
|
|||||||
{ name = "alembic", specifier = ">=1.14" },
|
{ name = "alembic", specifier = ">=1.14" },
|
||||||
{ name = "bcrypt", specifier = ">=4.1" },
|
{ name = "bcrypt", specifier = ">=4.1" },
|
||||||
{ name = "fastapi", specifier = ">=0.115" },
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
{ name = "openpyxl", specifier = ">=3.1" },
|
{ name = "openpyxl", specifier = ">=3.1" },
|
||||||
{ name = "ortools", specifier = ">=9.11" },
|
{ name = "ortools", specifier = ">=9.11" },
|
||||||
{ name = "pillow", specifier = ">=11.0" },
|
{ name = "pillow", specifier = ">=11.0" },
|
||||||
@@ -145,7 +146,6 @@ requires-dist = [
|
|||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "httpx", specifier = ">=0.27" },
|
|
||||||
{ name = "pytest", specifier = ">=8.3" },
|
{ name = "pytest", specifier = ">=8.3" },
|
||||||
{ name = "pytest-cov", specifier = ">=6.0" },
|
{ name = "pytest-cov", specifier = ">=6.0" },
|
||||||
{ name = "ruff", specifier = ">=0.8" },
|
{ name = "ruff", specifier = ">=0.8" },
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ Variables (Paramètres → Actions → Variables) :
|
|||||||
| `SSH_USER` | Utilisateur SSH |
|
| `SSH_USER` | Utilisateur SSH |
|
||||||
| `DEPLOY_PATH` | Dossier du projet sur le VPS (ex. `/opt/benevoles`) |
|
| `DEPLOY_PATH` | Dossier du projet sur le VPS (ex. `/opt/benevoles`) |
|
||||||
| `VITE_EDITION_ID` | UUID de l'édition de production — **à ajouter après** l'étape 3 |
|
| `VITE_EDITION_ID` | UUID de l'édition de production — **à ajouter après** l'étape 3 |
|
||||||
|
| `VITE_HCAPTCHA_SITE_KEY` | Clé de site hCaptcha (publique) — voir étape 1, formulaire public d'inscription. Optionnelle : vide ⇒ formulaire sans vérification anti-robot |
|
||||||
|
|
||||||
La clé publique correspondant à `SSH_KEY` doit être dans le
|
La clé publique correspondant à `SSH_KEY` doit être dans le
|
||||||
`~/.ssh/authorized_keys` de `SSH_USER` sur le VPS.
|
`~/.ssh/authorized_keys` de `SSH_USER` sur le VPS.
|
||||||
@@ -100,6 +101,13 @@ SMTP_PASSWORD=<mot de passe SMTP>
|
|||||||
SMTP_STARTTLS=true
|
SMTP_STARTTLS=true
|
||||||
SMTP_SSL=false
|
SMTP_SSL=false
|
||||||
SMTP_FROM=Festival <no-reply@mon-domaine.fr>
|
SMTP_FROM=Festival <no-reply@mon-domaine.fr>
|
||||||
|
|
||||||
|
# Formulaire public d'inscription (/inscription). Vide ⇒ aucune vérification
|
||||||
|
# anti-robot (déconseillé en public) — créer un compte gratuit sur
|
||||||
|
# https://hcaptcha.com, puis renseigner ici le secret du couple site/secret
|
||||||
|
# (la clé de site va, elle, dans la variable Gitea VITE_HCAPTCHA_SITE_KEY —
|
||||||
|
# voir tableau ci-dessus — car figée au build du frontend, pas modifiable ici).
|
||||||
|
HCAPTCHA_SECRET_KEY=
|
||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ name: benevoles
|
|||||||
|
|
||||||
# Déployé sur le VPS. Les variables sont lues depuis un fichier .env situé à
|
# Déployé sur le VPS. Les variables sont lues depuis un fichier .env situé à
|
||||||
# côté de ce fichier (non versionné) : POSTGRES_USER, POSTGRES_PASSWORD,
|
# côté de ce fichier (non versionné) : POSTGRES_USER, POSTGRES_PASSWORD,
|
||||||
# POSTGRES_DB, JWT_SECRET, PUBLIC_BASE_URL et les variables SMTP_* / EMAIL_ENABLED.
|
# POSTGRES_DB, JWT_SECRET, PUBLIC_BASE_URL, HCAPTCHA_SECRET_KEY et les
|
||||||
# BACKEND_IMAGE / FRONTEND_IMAGE sont exportées par le pipeline de déploiement
|
# variables SMTP_* / EMAIL_ENABLED. BACKEND_IMAGE / FRONTEND_IMAGE sont
|
||||||
# (.gitea/workflows/ci-cd.yml).
|
# exportées par le pipeline de déploiement (.gitea/workflows/ci-cd.yml) —
|
||||||
|
# VITE_HCAPTCHA_SITE_KEY, elle, est figée au build de l'image frontend (voir
|
||||||
|
# ci-cd.yml et deploy/README.md), pas modifiable ici.
|
||||||
#
|
#
|
||||||
# Les deux services ne publient que sur `127.0.0.1` : c'est le Caddy du VPS
|
# Les deux services ne publient que sur `127.0.0.1` : c'est le Caddy du VPS
|
||||||
# (hors de ce compose, voir deploy/README.md) qui reçoit le trafic public et
|
# (hors de ce compose, voir deploy/README.md) qui reçoit le trafic public et
|
||||||
@@ -41,6 +43,7 @@ services:
|
|||||||
SMTP_STARTTLS: ${SMTP_STARTTLS:-true}
|
SMTP_STARTTLS: ${SMTP_STARTTLS:-true}
|
||||||
SMTP_SSL: ${SMTP_SSL:-false}
|
SMTP_SSL: ${SMTP_SSL:-false}
|
||||||
SMTP_FROM: ${SMTP_FROM:-}
|
SMTP_FROM: ${SMTP_FROM:-}
|
||||||
|
HCAPTCHA_SECRET_KEY: ${HCAPTCHA_SECRET_KEY:-}
|
||||||
TZ: Europe/Paris
|
TZ: Europe/Paris
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ services:
|
|||||||
SMTP_STARTTLS: ${SMTP_STARTTLS:-true}
|
SMTP_STARTTLS: ${SMTP_STARTTLS:-true}
|
||||||
SMTP_SSL: ${SMTP_SSL:-false}
|
SMTP_SSL: ${SMTP_SSL:-false}
|
||||||
SMTP_FROM: ${SMTP_FROM:-Festival des bénévoles <no-reply@example.test>}
|
SMTP_FROM: ${SMTP_FROM:-Festival des bénévoles <no-reply@example.test>}
|
||||||
|
# Formulaire public d'inscription (app/api/forms.py). Vide en dev ⇒
|
||||||
|
# vérification hCaptcha désactivée (voir app/forms/captcha.py).
|
||||||
|
HCAPTCHA_SECRET_KEY: ${HCAPTCHA_SECRET_KEY:-}
|
||||||
TZ: Europe/Paris
|
TZ: Europe/Paris
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
@@ -54,6 +57,10 @@ services:
|
|||||||
# existante, p. ex. après `docker compose run --rm backend uv run python -m app.seed`).
|
# existante, p. ex. après `docker compose run --rm backend uv run python -m app.seed`).
|
||||||
VITE_API_MODE: ${VITE_API_MODE:-mock}
|
VITE_API_MODE: ${VITE_API_MODE:-mock}
|
||||||
VITE_EDITION_ID: ${VITE_EDITION_ID:-}
|
VITE_EDITION_ID: ${VITE_EDITION_ID:-}
|
||||||
|
# Clé de site hCaptcha (publique) : sans elle, le formulaire public
|
||||||
|
# d'inscription s'affiche sans vérification anti-robot (voir
|
||||||
|
# PublicSignupPage.tsx).
|
||||||
|
VITE_HCAPTCHA_SITE_KEY: ${HCAPTCHA_SITE_KEY:-}
|
||||||
# Cible du proxy `/api` de Vite à l'intérieur du réseau compose.
|
# Cible du proxy `/api` de Vite à l'intérieur du réseau compose.
|
||||||
VITE_API_TARGET: http://backend:8000
|
VITE_API_TARGET: http://backend:8000
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ COPY . .
|
|||||||
|
|
||||||
ARG VITE_API_MODE=http
|
ARG VITE_API_MODE=http
|
||||||
ARG VITE_EDITION_ID
|
ARG VITE_EDITION_ID
|
||||||
|
ARG VITE_HCAPTCHA_SITE_KEY
|
||||||
ENV VITE_API_MODE=$VITE_API_MODE
|
ENV VITE_API_MODE=$VITE_API_MODE
|
||||||
ENV VITE_EDITION_ID=$VITE_EDITION_ID
|
ENV VITE_EDITION_ID=$VITE_EDITION_ID
|
||||||
|
ENV VITE_HCAPTCHA_SITE_KEY=$VITE_HCAPTCHA_SITE_KEY
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { AuthContext, AuthProvider, type AuthContextValue } from '@/auth/auth-co
|
|||||||
import { routes } from '@/app/router';
|
import { routes } from '@/app/router';
|
||||||
import { BadgesPage } from '@/pages/BadgesPage';
|
import { BadgesPage } from '@/pages/BadgesPage';
|
||||||
import { ConfigPage } from '@/pages/ConfigPage';
|
import { ConfigPage } from '@/pages/ConfigPage';
|
||||||
|
import { FormBuilderPage } from '@/pages/FormBuilderPage';
|
||||||
import { GrillePage } from '@/pages/GrillePage';
|
import { GrillePage } from '@/pages/GrillePage';
|
||||||
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
||||||
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
|
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
|
||||||
@@ -81,6 +82,79 @@ async function checkAuth() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Exerce le formulaire public de bout en bout (mode mock) : configuration,
|
||||||
|
* lecture publique, soumission, vœux (max 2 postes, un artiste par jour),
|
||||||
|
* et le bénévole créé doit apparaître dans la liste de la coordination. */
|
||||||
|
async function checkPublicForm() {
|
||||||
|
const positions = await api.positions.list(e);
|
||||||
|
const artists = await api.artists.list(e);
|
||||||
|
const beforeCount = (await api.volunteers.list(e)).length;
|
||||||
|
|
||||||
|
await api.forms.setFields(e, {
|
||||||
|
fields: ['EMAIL', 'POSITION_WISHES', 'ARTIST_WISHES', 'COMMENT'],
|
||||||
|
});
|
||||||
|
const config = await api.forms.getPublicConfig(e);
|
||||||
|
if (!config.is_open) throw new Error('formulaire public : devrait être ouvert par défaut');
|
||||||
|
if (config.positions.length !== positions.length) {
|
||||||
|
throw new Error('formulaire public : postes manquants dans la config publique');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameDay = artists.filter((a) => a.performance_date === artists[0]?.performance_date);
|
||||||
|
let tooManyArtistsRejectedWith = 'non';
|
||||||
|
if (sameDay.length >= 2) {
|
||||||
|
try {
|
||||||
|
await api.forms.submitPublic(e, {
|
||||||
|
first_name: 'Test',
|
||||||
|
last_name: 'Smoke',
|
||||||
|
hcaptcha_token: 'dev',
|
||||||
|
artist_wish_ids: [sameDay[0].id, sameDay[1].id],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
tooManyArtistsRejectedWith = err instanceof ApiError ? err.code : String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tooManyPositionsRejectedWith = 'non';
|
||||||
|
if (positions.length >= 3) {
|
||||||
|
try {
|
||||||
|
await api.forms.submitPublic(e, {
|
||||||
|
first_name: 'Test',
|
||||||
|
last_name: 'Smoke',
|
||||||
|
hcaptcha_token: 'dev',
|
||||||
|
position_wish_ids: positions.slice(0, 3).map((p) => p.id),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
tooManyPositionsRejectedWith = err instanceof ApiError ? err.code : String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.forms.submitPublic(e, {
|
||||||
|
first_name: 'test',
|
||||||
|
last_name: 'smoke',
|
||||||
|
hcaptcha_token: 'dev',
|
||||||
|
email: 'test.smoke@example.org',
|
||||||
|
comment: 'Créé par le smoke test',
|
||||||
|
position_wish_ids: positions.slice(0, 2).map((p) => p.id),
|
||||||
|
artist_wish_ids: artists[0] ? [artists[0].id] : [],
|
||||||
|
});
|
||||||
|
const created = await api.volunteers.get(e, result.volunteer_id);
|
||||||
|
const afterCount = (await api.volunteers.list(e)).length;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
formOpenByDefault: config.is_open,
|
||||||
|
tooManyPositionsRejectedWith,
|
||||||
|
tooManyArtistsRejectedWith,
|
||||||
|
createdVolunteerName: `${created.first_name} ${created.last_name}`,
|
||||||
|
createdVolunteerAppearsInList: afterCount === beforeCount + 1,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function prefetchCore(client: QueryClient) {
|
async function prefetchCore(client: QueryClient) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }),
|
client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }),
|
||||||
@@ -148,9 +222,11 @@ function renderPublic(path: string) {
|
|||||||
|
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
await checkAuth().catch((err) => fail('checkAuth', err));
|
await checkAuth().catch((err) => fail('checkAuth', err));
|
||||||
|
await checkPublicForm().catch((err) => fail('checkPublicForm', err));
|
||||||
|
|
||||||
renderPublic('/connexion');
|
renderPublic('/connexion');
|
||||||
renderPublic('/connexion/coordination');
|
renderPublic('/connexion/coordination');
|
||||||
|
renderPublic('/inscription'); // formulaire public, hors de <RequireAuth/>
|
||||||
|
|
||||||
if (coordinator) {
|
if (coordinator) {
|
||||||
setAuthToken(coordinatorToken);
|
setAuthToken(coordinatorToken);
|
||||||
@@ -158,6 +234,7 @@ if (coordinator) {
|
|||||||
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
|
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
|
||||||
await renderProtected('/configuration', coordinator, createElement(ConfigPage));
|
await renderProtected('/configuration', coordinator, createElement(ConfigPage));
|
||||||
await renderProtected('/badges', coordinator, createElement(BadgesPage));
|
await renderProtected('/badges', coordinator, createElement(BadgesPage));
|
||||||
|
await renderProtected('/inscriptions', coordinator, createElement(FormBuilderPage));
|
||||||
}
|
}
|
||||||
if (volunteerAccount) {
|
if (volunteerAccount) {
|
||||||
const vid = volunteerAccount.volunteer_id as string;
|
const vid = volunteerAccount.volunteer_id as string;
|
||||||
|
|||||||
@@ -23,10 +23,13 @@ import type {
|
|||||||
DayPartCreate,
|
DayPartCreate,
|
||||||
DayPartUpdate,
|
DayPartUpdate,
|
||||||
EditionUpdate,
|
EditionUpdate,
|
||||||
|
FormFieldsSet,
|
||||||
|
FormTemplateUpdate,
|
||||||
ImportMapping,
|
ImportMapping,
|
||||||
PlanningNotificationRequest,
|
PlanningNotificationRequest,
|
||||||
PositionCreate,
|
PositionCreate,
|
||||||
PositionUpdate,
|
PositionUpdate,
|
||||||
|
PublicFormSubmission,
|
||||||
ShiftCreate,
|
ShiftCreate,
|
||||||
ShiftUpdate,
|
ShiftUpdate,
|
||||||
VolunteerCreate,
|
VolunteerCreate,
|
||||||
@@ -97,6 +100,30 @@ export function useVolunteerPlanning(volunteerId: string | null | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** URL objet (aperçu) de la photo d'un bénévole (formulaire public), ou
|
||||||
|
* `null` si absente. */
|
||||||
|
export function useVolunteerPhotoUrl(volunteerId: string | null | undefined, hasPhoto: boolean) {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let objectUrl: string | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
if (!hasPhoto || !volunteerId) {
|
||||||
|
setUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api.volunteers.photoBlob(edition, volunteerId).then((blob) => {
|
||||||
|
if (cancelled || !blob) return;
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
setUrl(objectUrl);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
}, [volunteerId, hasPhoto]);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
export function useShifts(positionId?: string) {
|
export function useShifts(positionId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.shifts(positionId),
|
queryKey: queryKeys.shifts(positionId),
|
||||||
@@ -553,3 +580,50 @@ export function useGenerateBadges() {
|
|||||||
mutationFn: (body: BadgeGenerateRequest) => api.badges.generate(edition, body),
|
mutationFn: (body: BadgeGenerateRequest) => api.badges.generate(edition, body),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Formulaire public d'inscription //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
/** Configuration du formulaire (COORDINATION). */
|
||||||
|
export function useFormTemplate() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['formTemplate', edition],
|
||||||
|
queryFn: () => api.forms.getTemplate(edition),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInvalidateFormTemplate() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
return () => client.invalidateQueries({ queryKey: ['formTemplate', edition] });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetFormFields() {
|
||||||
|
const invalidate = useInvalidateFormTemplate();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: FormFieldsSet) => api.forms.setFields(edition, body),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetFormOpen() {
|
||||||
|
const invalidate = useInvalidateFormTemplate();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: FormTemplateUpdate) => api.forms.setOpen(edition, body),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Configuration publique du formulaire — utilisable sans compte connecté
|
||||||
|
* (écran `/inscription`, hors de `RequireAuth`). */
|
||||||
|
export function usePublicFormConfig() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['publicFormConfig', edition],
|
||||||
|
queryFn: () => api.forms.getPublicConfig(edition),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubmitPublicForm() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: PublicFormSubmission) => api.forms.submitPublic(edition, body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ import type {
|
|||||||
DayPartCreate,
|
DayPartCreate,
|
||||||
DayPartUpdate,
|
DayPartUpdate,
|
||||||
EditionUpdate,
|
EditionUpdate,
|
||||||
|
FormFieldsSet,
|
||||||
|
FormTemplateUpdate,
|
||||||
ImportMapping,
|
ImportMapping,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
MagicLinkRequest,
|
MagicLinkRequest,
|
||||||
PlanningNotificationRequest,
|
PlanningNotificationRequest,
|
||||||
PositionCreate,
|
PositionCreate,
|
||||||
PositionUpdate,
|
PositionUpdate,
|
||||||
|
PublicFormSubmission,
|
||||||
ShiftCreate,
|
ShiftCreate,
|
||||||
ShiftUpdate,
|
ShiftUpdate,
|
||||||
VerifyRequest,
|
VerifyRequest,
|
||||||
@@ -144,6 +147,7 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }),
|
request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }),
|
||||||
planning: (editionId, id) =>
|
planning: (editionId, id) =>
|
||||||
request(`${base(editionId)}/volunteers/${id}/planning`),
|
request(`${base(editionId)}/volunteers/${id}/planning`),
|
||||||
|
photoBlob: (editionId, id) => requestBlob(`${base(editionId)}/volunteers/${id}/photo`),
|
||||||
},
|
},
|
||||||
accounts: {
|
accounts: {
|
||||||
list: (editionId) => request(`${base(editionId)}/accounts`),
|
list: (editionId) => request(`${base(editionId)}/accounts`),
|
||||||
@@ -262,6 +266,39 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
return blob;
|
return blob;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
forms: {
|
||||||
|
getTemplate: (editionId) => request(`${base(editionId)}/form`),
|
||||||
|
setFields: (editionId, body: FormFieldsSet) =>
|
||||||
|
request(`${base(editionId)}/form/fields`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
setOpen: (editionId, body: FormTemplateUpdate) =>
|
||||||
|
request(`${base(editionId)}/form`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||||
|
getPublicConfig: (editionId) => request(`${base(editionId)}/form/public`),
|
||||||
|
submitPublic: (editionId, body: PublicFormSubmission) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('first_name', body.first_name);
|
||||||
|
form.append('last_name', body.last_name);
|
||||||
|
form.append('hcaptcha_token', body.hcaptcha_token);
|
||||||
|
if (body.email) form.append('email', body.email);
|
||||||
|
if (body.phone) form.append('phone', body.phone);
|
||||||
|
if (body.comment) form.append('comment', body.comment);
|
||||||
|
if (body.companion_wish_note) form.append('companion_wish_note', body.companion_wish_note);
|
||||||
|
if (body.available !== undefined) form.append('available', String(body.available));
|
||||||
|
if (body.willing_to_pay_fee !== undefined) {
|
||||||
|
form.append('willing_to_pay_fee', String(body.willing_to_pay_fee));
|
||||||
|
}
|
||||||
|
if (body.position_wish_ids) {
|
||||||
|
form.append('position_wish_ids', JSON.stringify(body.position_wish_ids));
|
||||||
|
}
|
||||||
|
if (body.artist_wish_ids) {
|
||||||
|
form.append('artist_wish_ids', JSON.stringify(body.artist_wish_ids));
|
||||||
|
}
|
||||||
|
if (body.photo) form.append('photo', body.photo);
|
||||||
|
return request(`${base(editionId)}/form/public/submit`, { method: 'POST', body: form });
|
||||||
|
},
|
||||||
|
},
|
||||||
solver: {
|
solver: {
|
||||||
run: (editionId) =>
|
run: (editionId) =>
|
||||||
request(`${base(editionId)}/solver/suggestions`, { method: 'POST' }),
|
request(`${base(editionId)}/solver/suggestions`, { method: 'POST' }),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
BadgeFieldRead,
|
BadgeFieldRead,
|
||||||
DayPartRead,
|
DayPartRead,
|
||||||
EditionRead,
|
EditionRead,
|
||||||
|
FormFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
PositionRead,
|
PositionRead,
|
||||||
ShiftRead,
|
ShiftRead,
|
||||||
@@ -49,6 +50,12 @@ export interface MockState {
|
|||||||
backImageType: string | null;
|
backImageType: string | null;
|
||||||
fields: BadgeFieldRead[];
|
fields: BadgeFieldRead[];
|
||||||
};
|
};
|
||||||
|
formTemplate: {
|
||||||
|
isOpen: boolean;
|
||||||
|
fields: FormFieldType[];
|
||||||
|
};
|
||||||
|
/** volunteerId -> photo (formulaire public), data URL. */
|
||||||
|
photos: Map<string, string>;
|
||||||
seq: number;
|
seq: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +265,10 @@ export function buildMockState(): MockState {
|
|||||||
artist_ids,
|
artist_ids,
|
||||||
wished_position_ids,
|
wished_position_ids,
|
||||||
wished_companion_ids,
|
wished_companion_ids,
|
||||||
|
has_photo: false,
|
||||||
|
companion_wish_note: null,
|
||||||
|
self_declared_available: false,
|
||||||
|
willing_to_pay_fee: false,
|
||||||
created_at: NOW,
|
created_at: NOW,
|
||||||
updated_at: NOW,
|
updated_at: NOW,
|
||||||
});
|
});
|
||||||
@@ -431,6 +442,8 @@ export function buildMockState(): MockState {
|
|||||||
backImageType: null,
|
backImageType: null,
|
||||||
fields: [],
|
fields: [],
|
||||||
},
|
},
|
||||||
|
formTemplate: { isOpen: true, fields: [] },
|
||||||
|
photos: new Map(),
|
||||||
seq,
|
seq,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import type {
|
|||||||
DayPartRead,
|
DayPartRead,
|
||||||
DayPartUpdate,
|
DayPartUpdate,
|
||||||
EditionUpdate,
|
EditionUpdate,
|
||||||
|
FormFieldsSet,
|
||||||
|
FormSubmissionResult,
|
||||||
|
FormTemplateRead,
|
||||||
|
FormTemplateUpdate,
|
||||||
ImportAnalyzeResponse,
|
ImportAnalyzeResponse,
|
||||||
ImportMapping,
|
ImportMapping,
|
||||||
ImportPreviewRow,
|
ImportPreviewRow,
|
||||||
@@ -26,6 +30,8 @@ import type {
|
|||||||
ImportRowReport,
|
ImportRowReport,
|
||||||
MatrixResponse,
|
MatrixResponse,
|
||||||
PlanningNotificationRequest,
|
PlanningNotificationRequest,
|
||||||
|
PublicFormConfig,
|
||||||
|
PublicFormSubmission,
|
||||||
ShiftRead,
|
ShiftRead,
|
||||||
SolverRunResult,
|
SolverRunResult,
|
||||||
SolverSuggestion,
|
SolverSuggestion,
|
||||||
@@ -102,6 +108,11 @@ const imageSizeMm = async (file: File): Promise<{ width_mm: number; height_mm: n
|
|||||||
return { width_mm, height_mm };
|
return { width_mm, height_mm };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Approche de `str.title()` (Python) utilisé côté back-end à la soumission
|
||||||
|
// du formulaire public — capitalise chaque mot (« - » compris).
|
||||||
|
const titleCase = (s: string): string =>
|
||||||
|
s.toLowerCase().replace(/\b\p{L}/gu, (c) => c.toUpperCase());
|
||||||
|
|
||||||
const fileToDataUrl = (file: File): Promise<string> =>
|
const fileToDataUrl = (file: File): Promise<string> =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -831,6 +842,10 @@ export function createMockApi(): PlanningApi {
|
|||||||
'position.unknown',
|
'position.unknown',
|
||||||
),
|
),
|
||||||
wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []),
|
wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []),
|
||||||
|
has_photo: false,
|
||||||
|
companion_wish_note: null,
|
||||||
|
self_declared_available: false,
|
||||||
|
willing_to_pay_fee: false,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -861,6 +876,15 @@ export function createMockApi(): PlanningApi {
|
|||||||
...(body.wished_companion_ids != null
|
...(body.wished_companion_ids != null
|
||||||
? { wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids, v.id) }
|
? { wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids, v.id) }
|
||||||
: {}),
|
: {}),
|
||||||
|
...('companion_wish_note' in body
|
||||||
|
? { companion_wish_note: body.companion_wish_note ?? null }
|
||||||
|
: {}),
|
||||||
|
...('self_declared_available' in body && body.self_declared_available != null
|
||||||
|
? { self_declared_available: body.self_declared_available }
|
||||||
|
: {}),
|
||||||
|
...('willing_to_pay_fee' in body && body.willing_to_pay_fee != null
|
||||||
|
? { willing_to_pay_fee: body.willing_to_pay_fee }
|
||||||
|
: {}),
|
||||||
updated_at: touch(),
|
updated_at: touch(),
|
||||||
});
|
});
|
||||||
return delay(v);
|
return delay(v);
|
||||||
@@ -869,6 +893,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
state.volunteers = state.volunteers.filter((x) => x.id !== id);
|
state.volunteers = state.volunteers.filter((x) => x.id !== id);
|
||||||
state.assignments = state.assignments.filter((a) => a.volunteer_id !== id);
|
state.assignments = state.assignments.filter((a) => a.volunteer_id !== id);
|
||||||
state.availability.delete(id);
|
state.availability.delete(id);
|
||||||
|
state.photos.delete(id);
|
||||||
// RGPD : effacement complet, y compris le compte de connexion éventuel
|
// RGPD : effacement complet, y compris le compte de connexion éventuel
|
||||||
// et les vœux de compagnon dont il était la cible.
|
// et les vœux de compagnon dont il était la cible.
|
||||||
state.accounts = state.accounts.filter((a) => a.volunteer_id !== id);
|
state.accounts = state.accounts.filter((a) => a.volunteer_id !== id);
|
||||||
@@ -877,6 +902,11 @@ export function createMockApi(): PlanningApi {
|
|||||||
});
|
});
|
||||||
return delay(undefined);
|
return delay(undefined);
|
||||||
},
|
},
|
||||||
|
photoBlob: async (_edition, id) => {
|
||||||
|
const dataUrl = state.photos.get(id);
|
||||||
|
if (!dataUrl) return null;
|
||||||
|
return (await fetch(dataUrl)).blob();
|
||||||
|
},
|
||||||
planning: (_edition, id) => {
|
planning: (_edition, id) => {
|
||||||
if (!state.volunteers.some((v) => v.id === id)) return notFound();
|
if (!state.volunteers.some((v) => v.id === id)) return notFound();
|
||||||
const entries = state.assignments
|
const entries = state.assignments
|
||||||
@@ -1147,6 +1177,10 @@ export function createMockApi(): PlanningApi {
|
|||||||
artist_ids: [],
|
artist_ids: [],
|
||||||
wished_position_ids: [],
|
wished_position_ids: [],
|
||||||
wished_companion_ids: [],
|
wished_companion_ids: [],
|
||||||
|
has_photo: false,
|
||||||
|
companion_wish_note: null,
|
||||||
|
self_declared_available: false,
|
||||||
|
willing_to_pay_fee: false,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -1430,6 +1464,99 @@ export function createMockApi(): PlanningApi {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
forms: {
|
||||||
|
getTemplate: (): Promise<FormTemplateRead> =>
|
||||||
|
delay({
|
||||||
|
edition_id: state.edition.id,
|
||||||
|
is_open: state.formTemplate.isOpen,
|
||||||
|
fields: state.formTemplate.fields,
|
||||||
|
}),
|
||||||
|
setFields: (_edition, body: FormFieldsSet) => {
|
||||||
|
state.formTemplate.fields = [...new Set(body.fields)];
|
||||||
|
return delay<FormTemplateRead>({
|
||||||
|
edition_id: state.edition.id,
|
||||||
|
is_open: state.formTemplate.isOpen,
|
||||||
|
fields: state.formTemplate.fields,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setOpen: (_edition, body: FormTemplateUpdate) => {
|
||||||
|
state.formTemplate.isOpen = body.is_open;
|
||||||
|
return delay<FormTemplateRead>({
|
||||||
|
edition_id: state.edition.id,
|
||||||
|
is_open: state.formTemplate.isOpen,
|
||||||
|
fields: state.formTemplate.fields,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getPublicConfig: (): Promise<PublicFormConfig> => {
|
||||||
|
const fields = state.formTemplate.fields;
|
||||||
|
return delay({
|
||||||
|
is_open: state.formTemplate.isOpen,
|
||||||
|
fields,
|
||||||
|
positions: fields.includes('POSITION_WISHES')
|
||||||
|
? state.positions.map((p) => ({ id: p.id, name: p.name }))
|
||||||
|
: [],
|
||||||
|
artists: fields.includes('ARTIST_WISHES')
|
||||||
|
? state.artists.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
performance_date: a.performance_date,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
submitPublic: async (_edition, body: PublicFormSubmission) => {
|
||||||
|
if (!state.formTemplate.isOpen) throw new ApiError(422, 'form.closed');
|
||||||
|
if (!body.hcaptcha_token) throw new ApiError(422, 'form.captcha_failed');
|
||||||
|
if (!body.first_name.trim() || !body.last_name.trim()) {
|
||||||
|
throw new ApiError(422, 'import.row.missing_required_field');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = new Set(state.formTemplate.fields);
|
||||||
|
const positionIds = fields.has('POSITION_WISHES') ? (body.position_wish_ids ?? []) : [];
|
||||||
|
if (positionIds.length > 2) throw new ApiError(422, 'form.too_many_position_wishes');
|
||||||
|
const artistIds = fields.has('ARTIST_WISHES') ? (body.artist_wish_ids ?? []) : [];
|
||||||
|
const artistDates = artistIds.map(
|
||||||
|
(id) => state.artists.find((a) => a.id === id)?.performance_date,
|
||||||
|
);
|
||||||
|
if (new Set(artistDates).size !== artistDates.length) {
|
||||||
|
throw new ApiError(422, 'form.too_many_artists_same_day');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = touch();
|
||||||
|
const volunteer: VolunteerRead = {
|
||||||
|
id: `v${state.seq++}`,
|
||||||
|
edition_id: state.edition.id,
|
||||||
|
first_name: titleCase(body.first_name.trim()),
|
||||||
|
last_name: titleCase(body.last_name.trim()),
|
||||||
|
email: fields.has('EMAIL') ? body.email?.trim() || null : null,
|
||||||
|
phone: fields.has('PHONE') ? body.phone?.trim() || null : null,
|
||||||
|
comment: fields.has('COMMENT') ? body.comment?.trim() || null : null,
|
||||||
|
artist_ids: resolveWishes(state.artists, artistIds, 'artist.unknown'),
|
||||||
|
wished_position_ids: resolveWishes(state.positions, positionIds, 'position.unknown'),
|
||||||
|
wished_companion_ids: [],
|
||||||
|
has_photo: false,
|
||||||
|
companion_wish_note: fields.has('COMPANION_WISH')
|
||||||
|
? body.companion_wish_note?.trim() || null
|
||||||
|
: null,
|
||||||
|
self_declared_available: fields.has('AVAILABLE') ? Boolean(body.available) : false,
|
||||||
|
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE')
|
||||||
|
? Boolean(body.willing_to_pay_fee)
|
||||||
|
: false,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fields.has('PHOTO') && body.photo) {
|
||||||
|
volunteer.has_photo = true;
|
||||||
|
state.photos.set(volunteer.id, await fileToDataUrl(body.photo));
|
||||||
|
}
|
||||||
|
|
||||||
|
state.volunteers.push(volunteer);
|
||||||
|
state.availability.set(volunteer.id, []);
|
||||||
|
return delay<FormSubmissionResult>({ volunteer_id: volunteer.id });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
solver: {
|
solver: {
|
||||||
run: () => delay(runSolver()),
|
run: () => delay(runSolver()),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ export interface VolunteerRead {
|
|||||||
wished_position_ids: string[];
|
wished_position_ids: string[];
|
||||||
/** Vœux : bénévoles avec qui il voudrait être affecté (0 à N, à sens unique). */
|
/** Vœux : bénévoles avec qui il voudrait être affecté (0 à N, à sens unique). */
|
||||||
wished_companion_ids: string[];
|
wished_companion_ids: string[];
|
||||||
|
/** Renseignés par le formulaire public d'inscription (voir schema forms). */
|
||||||
|
has_photo: boolean;
|
||||||
|
companion_wish_note: string | null;
|
||||||
|
self_declared_available: boolean;
|
||||||
|
willing_to_pay_fee: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -133,6 +138,9 @@ export interface VolunteerUpdate {
|
|||||||
artist_ids?: string[] | null;
|
artist_ids?: string[] | null;
|
||||||
wished_position_ids?: string[] | null;
|
wished_position_ids?: string[] | null;
|
||||||
wished_companion_ids?: string[] | null;
|
wished_companion_ids?: string[] | null;
|
||||||
|
companion_wish_note?: string | null;
|
||||||
|
self_declared_available?: boolean | null;
|
||||||
|
willing_to_pay_fee?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -532,3 +540,73 @@ export interface PlanningNotificationResult {
|
|||||||
skipped_no_email: number;
|
skipped_no_email: number;
|
||||||
recipients: string[];
|
recipients: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Formulaire public d'inscription //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Nom/prénom toujours collectés, pas dans cette liste (voir app/api/forms.py).
|
||||||
|
export type FormFieldType =
|
||||||
|
| 'EMAIL'
|
||||||
|
| 'PHONE'
|
||||||
|
| 'POSITION_WISHES'
|
||||||
|
| 'ARTIST_WISHES'
|
||||||
|
| 'COMPANION_WISH'
|
||||||
|
| 'AVAILABLE'
|
||||||
|
| 'WILLING_TO_PAY_FEE'
|
||||||
|
| 'PHOTO'
|
||||||
|
| 'COMMENT';
|
||||||
|
|
||||||
|
export interface FormFieldsSet {
|
||||||
|
fields: FormFieldType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormTemplateRead {
|
||||||
|
edition_id: string;
|
||||||
|
is_open: boolean;
|
||||||
|
fields: FormFieldType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormTemplateUpdate {
|
||||||
|
is_open: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicPositionOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicArtistOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
performance_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tout ce qu'il faut pour afficher le formulaire public — jamais plus. */
|
||||||
|
export interface PublicFormConfig {
|
||||||
|
is_open: boolean;
|
||||||
|
fields: FormFieldType[];
|
||||||
|
positions: PublicPositionOption[];
|
||||||
|
artists: PublicArtistOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormSubmissionResult {
|
||||||
|
volunteer_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Champs du formulaire public — envoyés en `multipart/form-data` (photo
|
||||||
|
* possible). `position_wish_ids`/`artist_wish_ids` : JSON stringifié
|
||||||
|
* (`'["id1","id2"]'`), pas de tableau natif possible en multipart. */
|
||||||
|
export interface PublicFormSubmission {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
hcaptcha_token: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
comment?: string;
|
||||||
|
companion_wish_note?: string;
|
||||||
|
available?: boolean;
|
||||||
|
willing_to_pay_fee?: boolean;
|
||||||
|
position_wish_ids?: string[];
|
||||||
|
artist_wish_ids?: string[];
|
||||||
|
photo?: File | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import type {
|
|||||||
DayPartUpdate,
|
DayPartUpdate,
|
||||||
EditionRead,
|
EditionRead,
|
||||||
EditionUpdate,
|
EditionUpdate,
|
||||||
|
FormFieldsSet,
|
||||||
|
FormSubmissionResult,
|
||||||
|
FormTemplateRead,
|
||||||
|
FormTemplateUpdate,
|
||||||
GenerateShiftsResult,
|
GenerateShiftsResult,
|
||||||
ImportAnalyzeResponse,
|
ImportAnalyzeResponse,
|
||||||
ImportMapping,
|
ImportMapping,
|
||||||
@@ -35,6 +39,8 @@ import type {
|
|||||||
PositionCreate,
|
PositionCreate,
|
||||||
PositionRead,
|
PositionRead,
|
||||||
PositionUpdate,
|
PositionUpdate,
|
||||||
|
PublicFormConfig,
|
||||||
|
PublicFormSubmission,
|
||||||
ShiftCreate,
|
ShiftCreate,
|
||||||
ShiftRead,
|
ShiftRead,
|
||||||
ShiftUpdate,
|
ShiftUpdate,
|
||||||
@@ -77,6 +83,8 @@ export interface PlanningApi {
|
|||||||
remove(editionId: string, id: string): Promise<void>;
|
remove(editionId: string, id: string): Promise<void>;
|
||||||
/** Planning enrichi (poste, lieu, référent) — écran bénévole. */
|
/** Planning enrichi (poste, lieu, référent) — écran bénévole. */
|
||||||
planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>;
|
planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>;
|
||||||
|
/** Photo transmise via le formulaire public, `null` si absente. */
|
||||||
|
photoBlob(editionId: string, id: string): Promise<Blob | null>;
|
||||||
};
|
};
|
||||||
accounts: {
|
accounts: {
|
||||||
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
|
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
|
||||||
@@ -153,6 +161,15 @@ export interface PlanningApi {
|
|||||||
/** Génère la planche PDF (recto personnalisé + verso fixe). */
|
/** Génère la planche PDF (recto personnalisé + verso fixe). */
|
||||||
generate(editionId: string, body: BadgeGenerateRequest): Promise<Blob>;
|
generate(editionId: string, body: BadgeGenerateRequest): Promise<Blob>;
|
||||||
};
|
};
|
||||||
|
forms: {
|
||||||
|
/** Configuration (COORDINATION uniquement). */
|
||||||
|
getTemplate(editionId: string): Promise<FormTemplateRead>;
|
||||||
|
setFields(editionId: string, body: FormFieldsSet): Promise<FormTemplateRead>;
|
||||||
|
setOpen(editionId: string, body: FormTemplateUpdate): Promise<FormTemplateRead>;
|
||||||
|
/** Publics — aucune authentification, utilisables sans compte connecté. */
|
||||||
|
getPublicConfig(editionId: string): Promise<PublicFormConfig>;
|
||||||
|
submitPublic(editionId: string, body: PublicFormSubmission): Promise<FormSubmissionResult>;
|
||||||
|
};
|
||||||
solver: {
|
solver: {
|
||||||
/** Calcule des suggestions (CP-SAT) ; ne persiste jamais d'affectation. */
|
/** Calcule des suggestions (CP-SAT) ; ne persiste jamais d'affectation. */
|
||||||
run(editionId: string): Promise<SolverRunResult>;
|
run(editionId: string): Promise<SolverRunResult>;
|
||||||
|
|||||||
@@ -6,14 +6,20 @@ import { AdminLoginPage } from '@/pages/auth/AdminLoginPage';
|
|||||||
import { ConnexionPage } from '@/pages/auth/ConnexionPage';
|
import { ConnexionPage } from '@/pages/auth/ConnexionPage';
|
||||||
import { BadgesPage } from '@/pages/BadgesPage';
|
import { BadgesPage } from '@/pages/BadgesPage';
|
||||||
import { ConfigPage } from '@/pages/ConfigPage';
|
import { ConfigPage } from '@/pages/ConfigPage';
|
||||||
|
import { FormBuilderPage } from '@/pages/FormBuilderPage';
|
||||||
import { GrillePage } from '@/pages/GrillePage';
|
import { GrillePage } from '@/pages/GrillePage';
|
||||||
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
||||||
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
|
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
|
||||||
|
import { PublicSignupPage } from '@/pages/PublicSignupPage';
|
||||||
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
|
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
|
||||||
|
|
||||||
export const routes: RouteObject[] = [
|
export const routes: RouteObject[] = [
|
||||||
{ path: '/connexion', element: <ConnexionPage /> },
|
{ path: '/connexion', element: <ConnexionPage /> },
|
||||||
{ path: '/connexion/coordination', element: <AdminLoginPage /> },
|
{ path: '/connexion/coordination', element: <AdminLoginPage /> },
|
||||||
|
// Formulaire public d'inscription : aucun compte requis, donc hors de
|
||||||
|
// <RequireAuth/> — voir app/api/forms.py (les seuls endpoints publics de
|
||||||
|
// toute l'API).
|
||||||
|
{ path: '/inscription', element: <PublicSignupPage /> },
|
||||||
{
|
{
|
||||||
element: <RequireAuth />,
|
element: <RequireAuth />,
|
||||||
children: [
|
children: [
|
||||||
@@ -28,6 +34,7 @@ export const routes: RouteObject[] = [
|
|||||||
{ path: 'mobile-referent', element: <MobileLeadPage /> },
|
{ path: 'mobile-referent', element: <MobileLeadPage /> },
|
||||||
{ path: 'configuration', element: <ConfigPage /> },
|
{ path: 'configuration', element: <ConfigPage /> },
|
||||||
{ path: 'badges', element: <BadgesPage /> },
|
{ path: 'badges', element: <BadgesPage /> },
|
||||||
|
{ path: 'inscriptions', element: <FormBuilderPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const ROUTE_ROLES: Record<string, Role[]> = {
|
|||||||
'/benevoles': ['COORDINATOR'],
|
'/benevoles': ['COORDINATOR'],
|
||||||
'/configuration': ['COORDINATOR'],
|
'/configuration': ['COORDINATOR'],
|
||||||
'/badges': ['COORDINATOR'],
|
'/badges': ['COORDINATOR'],
|
||||||
|
'/inscriptions': ['COORDINATOR'],
|
||||||
'/mobile-benevole': ['VOLUNTEER'],
|
'/mobile-benevole': ['VOLUNTEER'],
|
||||||
'/mobile-referent': ['LEAD'],
|
'/mobile-referent': ['LEAD'],
|
||||||
};
|
};
|
||||||
@@ -21,6 +22,7 @@ export const NAV_TABS: Array<{ to: string; num: string; labelKey: string }> = [
|
|||||||
{ to: '/mobile-referent', num: '05', labelKey: 'tab.mobileLead' },
|
{ to: '/mobile-referent', num: '05', labelKey: 'tab.mobileLead' },
|
||||||
{ to: '/configuration', num: '06', labelKey: 'tab.config' },
|
{ to: '/configuration', num: '06', labelKey: 'tab.config' },
|
||||||
{ to: '/badges', num: '07', labelKey: 'tab.badges' },
|
{ to: '/badges', num: '07', labelKey: 'tab.badges' },
|
||||||
|
{ to: '/inscriptions', num: '08', labelKey: 'tab.forms' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function canAccess(pathname: string, role: Role): boolean {
|
export function canAccess(pathname: string, role: Role): boolean {
|
||||||
|
|||||||
@@ -300,6 +300,8 @@
|
|||||||
"config.vol.noCompanions": "Aucun bénévole choisi",
|
"config.vol.noCompanions": "Aucun bénévole choisi",
|
||||||
"config.vol.pickCompanion": "Ajouter un bénévole…",
|
"config.vol.pickCompanion": "Ajouter un bénévole…",
|
||||||
"config.vol.searchCompanion": "Rechercher un bénévole…",
|
"config.vol.searchCompanion": "Rechercher un bénévole…",
|
||||||
|
"config.vol.publicForm.title": "Formulaire public",
|
||||||
|
"config.vol.publicForm.hint": "Réponses saisies par le bénévole lui-même via le formulaire public d'inscription.",
|
||||||
"config.vol.colWishes": "VŒUX",
|
"config.vol.colWishes": "VŒUX",
|
||||||
"config.vol.wishesCount": "{count} vœu(x)",
|
"config.vol.wishesCount": "{count} vœu(x)",
|
||||||
"config.vol.addAvailabilityHint": "Les disponibilités se renseignent sur la fiche du bénévole, une fois celui-ci créé.",
|
"config.vol.addAvailabilityHint": "Les disponibilités se renseignent sur la fiche du bénévole, une fois celui-ci créé.",
|
||||||
@@ -360,5 +362,49 @@
|
|||||||
"badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois.",
|
"badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois.",
|
||||||
"badge.invalid_size": "La taille du badge doit être strictement positive.",
|
"badge.invalid_size": "La taille du badge doit être strictement positive.",
|
||||||
"badge.no_fields": "Activez au moins un champ sur le recto avant de générer.",
|
"badge.no_fields": "Activez au moins un champ sur le recto avant de générer.",
|
||||||
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection."
|
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
|
||||||
|
|
||||||
|
"tab.forms": "Inscriptions",
|
||||||
|
"form.field.email": "Email",
|
||||||
|
"form.field.phone": "Téléphone",
|
||||||
|
"form.field.positionWishes": "Vœux de poste",
|
||||||
|
"form.field.positionWishes.help": "Deux vœux au maximum, pour forcer une vraie priorisation.",
|
||||||
|
"form.field.artistWishes": "Concert(s) à voir",
|
||||||
|
"form.field.artistWishes.help": "Un seul artiste souhaité par soir de festival.",
|
||||||
|
"form.field.companionWish": "Collègue apprécié",
|
||||||
|
"form.field.companionWish.help": "Texte libre : la personne visée n'est pas forcément déjà inscrite. À résoudre ensuite depuis sa fiche.",
|
||||||
|
"form.field.available": "Case « je suis disponible »",
|
||||||
|
"form.field.available.help": "Purement informatif — n'a aucun lien avec les disponibilités détaillées de la fiche bénévole.",
|
||||||
|
"form.field.willingToPayFee": "Case « je veux bien payer une cotisation »",
|
||||||
|
"form.field.photo": "Photo",
|
||||||
|
"form.field.comment": "Commentaire",
|
||||||
|
"form.link.title": "Lien du formulaire",
|
||||||
|
"form.link.hint": "À partager aux bénévoles potentiels — n'importe qui muni du lien peut s'inscrire.",
|
||||||
|
"form.link.copy": "Copier",
|
||||||
|
"form.link.copied": "Copié ✓",
|
||||||
|
"form.status.title": "Inscriptions",
|
||||||
|
"form.status.open": "OUVERTES",
|
||||||
|
"form.status.closed": "FERMÉES",
|
||||||
|
"form.fields.title": "Champs du formulaire",
|
||||||
|
"form.save": "Enregistrer",
|
||||||
|
"form.saving": "Enregistrement…",
|
||||||
|
"form.saved": "Enregistré ✓",
|
||||||
|
|
||||||
|
"publicForm.title": "Inscription bénévole",
|
||||||
|
"publicForm.positionWishes.count": "{count} / {max}",
|
||||||
|
"publicForm.artistWishes.none": "— Aucun —",
|
||||||
|
"publicForm.captcha.disabled": "(Vérification anti-robot non configurée sur cette instance.)",
|
||||||
|
"publicForm.submitting": "Envoi…",
|
||||||
|
"publicForm.submit": "Envoyer mon inscription",
|
||||||
|
"publicForm.success.title": "Inscription reçue",
|
||||||
|
"publicForm.success.body": "Merci ! Ton inscription a bien été enregistrée. La coordination du festival prendra contact avec toi.",
|
||||||
|
"publicForm.closed.title": "Inscriptions fermées",
|
||||||
|
"publicForm.closed.body": "Les inscriptions bénévoles ne sont pas ouvertes pour le moment. Reviens un peu plus tard !",
|
||||||
|
"form.closed": "Les inscriptions sont actuellement fermées.",
|
||||||
|
"form.captcha_failed": "Vérification anti-robot invalide, réessaie.",
|
||||||
|
"form.invalid_payload": "Données du formulaire invalides.",
|
||||||
|
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
|
||||||
|
"form.photo_too_large": "La photo est trop volumineuse.",
|
||||||
|
"form.too_many_position_wishes": "Deux vœux de poste au maximum.",
|
||||||
|
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,7 @@ export const ENV = {
|
|||||||
apiBaseUrl: raw.VITE_API_BASE_URL ?? '/api',
|
apiBaseUrl: raw.VITE_API_BASE_URL ?? '/api',
|
||||||
editionId: raw.VITE_EDITION_ID,
|
editionId: raw.VITE_EDITION_ID,
|
||||||
demoVolunteerId: raw.VITE_DEMO_VOLUNTEER_ID ?? '',
|
demoVolunteerId: raw.VITE_DEMO_VOLUNTEER_ID ?? '',
|
||||||
|
// Clé de site hCaptcha (publique, sans risque à exposer) — le formulaire
|
||||||
|
// public d'inscription (`/inscription`) ne s'affiche pas sans elle.
|
||||||
|
hcaptchaSiteKey: raw.VITE_HCAPTCHA_SITE_KEY ?? '',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useFormTemplate, useSetFormFields, useSetFormOpen } from '@/api/hooks';
|
||||||
|
import type { FormFieldType } from '@/api/schema';
|
||||||
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
|
import { Btn, HelpTip, Panel, PanelHeader } from '@/components/ui';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
|
// Nom/prénom toujours collectés (indispensables à la création du bénévole) —
|
||||||
|
// pas dans cette liste, voir CLAUDE.md § 2 (Volunteer) et app/api/forms.py.
|
||||||
|
const FIELD_TYPES: FormFieldType[] = [
|
||||||
|
'EMAIL',
|
||||||
|
'PHONE',
|
||||||
|
'POSITION_WISHES',
|
||||||
|
'ARTIST_WISHES',
|
||||||
|
'COMPANION_WISH',
|
||||||
|
'AVAILABLE',
|
||||||
|
'WILLING_TO_PAY_FEE',
|
||||||
|
'PHOTO',
|
||||||
|
'COMMENT',
|
||||||
|
];
|
||||||
|
|
||||||
|
const FIELD_LABEL_KEY: Record<FormFieldType, string> = {
|
||||||
|
EMAIL: 'form.field.email',
|
||||||
|
PHONE: 'form.field.phone',
|
||||||
|
POSITION_WISHES: 'form.field.positionWishes',
|
||||||
|
ARTIST_WISHES: 'form.field.artistWishes',
|
||||||
|
COMPANION_WISH: 'form.field.companionWish',
|
||||||
|
AVAILABLE: 'form.field.available',
|
||||||
|
WILLING_TO_PAY_FEE: 'form.field.willingToPayFee',
|
||||||
|
PHOTO: 'form.field.photo',
|
||||||
|
COMMENT: 'form.field.comment',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIELD_HELP_KEY: Record<FormFieldType, string | null> = {
|
||||||
|
EMAIL: null,
|
||||||
|
PHONE: null,
|
||||||
|
POSITION_WISHES: 'form.field.positionWishes.help',
|
||||||
|
ARTIST_WISHES: 'form.field.artistWishes.help',
|
||||||
|
COMPANION_WISH: 'form.field.companionWish.help',
|
||||||
|
AVAILABLE: 'form.field.available.help',
|
||||||
|
WILLING_TO_PAY_FEE: null,
|
||||||
|
PHOTO: null,
|
||||||
|
COMMENT: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FormBuilderPage() {
|
||||||
|
const template = useFormTemplate();
|
||||||
|
const setFields = useSetFormFields();
|
||||||
|
const setOpen = useSetFormOpen();
|
||||||
|
|
||||||
|
const [enabled, setEnabled] = useState<Set<FormFieldType> | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (template.data) setEnabled(new Set(template.data.fields));
|
||||||
|
}, [template.data]);
|
||||||
|
|
||||||
|
if (template.isLoading || !enabled) return <Loading />;
|
||||||
|
if (template.error) return <ErrorNote error={template.error} />;
|
||||||
|
|
||||||
|
const toggle = (type: FormFieldType) => {
|
||||||
|
setEnabled((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(type)) next.delete(type);
|
||||||
|
else next.add(type);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const link = `${window.location.origin}/inscription`;
|
||||||
|
const copyLink = () => {
|
||||||
|
navigator.clipboard.writeText(link).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col overflow-auto p-4">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start">
|
||||||
|
<Panel className="max-w-[420px] flex-1">
|
||||||
|
<PanelHeader>{t('form.link.title')}</PanelHeader>
|
||||||
|
<div className="flex flex-col gap-3 p-3">
|
||||||
|
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
|
||||||
|
{t('form.link.hint')}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={link}
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
|
className="w-full border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[11px]"
|
||||||
|
/>
|
||||||
|
<Btn variant="ghost" onClick={copyLink}>
|
||||||
|
{copied ? t('form.link.copied') : t('form.link.copy')}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center gap-3 border-t-2 border-ink pt-3">
|
||||||
|
<span className="font-display text-[11px] font-extrabold uppercase tracking-wide">
|
||||||
|
{t('form.status.title')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen.mutate({ is_open: !template.data?.is_open })}
|
||||||
|
disabled={setOpen.isPending}
|
||||||
|
className={cx(
|
||||||
|
'cursor-pointer border-2 border-ink px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wide',
|
||||||
|
template.data?.is_open ? 'bg-green text-white' : 'bg-orange text-ink',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{template.data?.is_open ? t('form.status.open') : t('form.status.closed')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel className="min-w-[320px] flex-1">
|
||||||
|
<PanelHeader>{t('form.fields.title')}</PanelHeader>
|
||||||
|
<div className="flex flex-col divide-y-2 divide-ink/10">
|
||||||
|
{FIELD_TYPES.map((type) => (
|
||||||
|
<label
|
||||||
|
key={type}
|
||||||
|
className="flex cursor-pointer items-center gap-[10px] p-3 hover:bg-yellow-soft"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled.has(type)}
|
||||||
|
onChange={() => toggle(type)}
|
||||||
|
className="h-[16px] w-[16px] accent-ink"
|
||||||
|
/>
|
||||||
|
<span className="font-display text-[12px] font-extrabold uppercase tracking-wide">
|
||||||
|
{t(FIELD_LABEL_KEY[type])}
|
||||||
|
</span>
|
||||||
|
{FIELD_HELP_KEY[type] ? <HelpTip text={t(FIELD_HELP_KEY[type]!)} /> : null}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 border-t-2 border-ink p-3">
|
||||||
|
<Btn
|
||||||
|
onClick={() => setFields.mutate({ fields: [...enabled] })}
|
||||||
|
disabled={setFields.isPending}
|
||||||
|
>
|
||||||
|
{setFields.isPending ? t('form.saving') : t('form.save')}
|
||||||
|
</Btn>
|
||||||
|
{setFields.isSuccess ? (
|
||||||
|
<span className="font-mono text-[9.5px] font-bold text-green">{t('form.saved')}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { ApiError } from '@/api';
|
||||||
|
import { usePublicFormConfig, useSubmitPublicForm } from '@/api/hooks';
|
||||||
|
import type { PublicArtistOption } from '@/api/schema';
|
||||||
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
|
import { Btn, FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
import { ENV } from '@/lib/env';
|
||||||
|
import { longDayLabel } from '@/lib/time';
|
||||||
|
|
||||||
|
const MAX_POSITION_WISHES = 2;
|
||||||
|
|
||||||
|
// Charge le script hCaptcha une seule fois, quel que soit le nombre de
|
||||||
|
// montages de <HCaptchaWidget> (StrictMode, navigation aller-retour…).
|
||||||
|
let hcaptchaScriptPromise: Promise<void> | null = null;
|
||||||
|
function loadHCaptchaScript(): Promise<void> {
|
||||||
|
if (!hcaptchaScriptPromise) {
|
||||||
|
hcaptchaScriptPromise = new Promise((resolve) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://js.hcaptcha.com/1/api.js';
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.onload = () => resolve();
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return hcaptchaScriptPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Widget hCaptcha (rendu implicite) : le script scanne le DOM et rend les
|
||||||
|
* `.h-captcha` qu'il trouve. `onToken` reçoit le jeton une fois résolu. */
|
||||||
|
function HCaptchaWidget({ onToken }: { onToken: (token: string) => void }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const callbackName = useRef(`hcaptchaCallback${Math.random().toString(36).slice(2)}`);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const win = window as unknown as Record<string, unknown>;
|
||||||
|
const name = callbackName.current;
|
||||||
|
win[name] = onToken;
|
||||||
|
loadHCaptchaScript();
|
||||||
|
return () => {
|
||||||
|
delete win[name];
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="h-captcha"
|
||||||
|
data-sitekey={ENV.hcaptchaSiteKey}
|
||||||
|
data-callback={callbackName.current}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
if (error instanceof ApiError) return t(error.code);
|
||||||
|
return t('error.generic');
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupArtistsByDay(artists: PublicArtistOption[]): Array<[string, PublicArtistOption[]]> {
|
||||||
|
const byDay = new Map<string, PublicArtistOption[]>();
|
||||||
|
for (const artist of artists) {
|
||||||
|
const list = byDay.get(artist.performance_date) ?? [];
|
||||||
|
list.push(artist);
|
||||||
|
byDay.set(artist.performance_date, list);
|
||||||
|
}
|
||||||
|
return [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublicSignupPage() {
|
||||||
|
const config = usePublicFormConfig();
|
||||||
|
const submit = useSubmitPublicForm();
|
||||||
|
|
||||||
|
const [firstName, setFirstName] = useState('');
|
||||||
|
const [lastName, setLastName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
const [companionNote, setCompanionNote] = useState('');
|
||||||
|
const [available, setAvailable] = useState(false);
|
||||||
|
const [willingToPayFee, setWillingToPayFee] = useState(false);
|
||||||
|
const [positionIds, setPositionIds] = useState<string[]>([]);
|
||||||
|
const [artistByDay, setArtistByDay] = useState<Record<string, string>>({});
|
||||||
|
const [photo, setPhoto] = useState<File | null>(null);
|
||||||
|
const [captchaToken, setCaptchaToken] = useState('');
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [volunteerId, setVolunteerId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const artistsByDay = useMemo(
|
||||||
|
() => groupArtistsByDay(config.data?.artists ?? []),
|
||||||
|
[config.data?.artists],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (config.isLoading) return <Loading />;
|
||||||
|
if (config.error) return <ErrorNote error={config.error} />;
|
||||||
|
if (!config.data) return null;
|
||||||
|
|
||||||
|
if (volunteerId) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader>{t('publicForm.success.title')}</PanelHeader>
|
||||||
|
<p className="p-4 font-mono text-[12px] leading-relaxed">{t('publicForm.success.body')}</p>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.data.is_open) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader>{t('publicForm.closed.title')}</PanelHeader>
|
||||||
|
<p className="p-4 font-mono text-[12px] leading-relaxed">{t('publicForm.closed.body')}</p>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = new Set(config.data.fields);
|
||||||
|
const toggleEnabled = ENV.hcaptchaSiteKey.length > 0;
|
||||||
|
|
||||||
|
const togglePosition = (id: string) => {
|
||||||
|
setPositionIds((prev) => {
|
||||||
|
if (prev.includes(id)) return prev.filter((x) => x !== id);
|
||||||
|
if (prev.length >= MAX_POSITION_WISHES) return prev;
|
||||||
|
return [...prev, id];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickArtistForDay = (day: string, artistId: string) => {
|
||||||
|
setArtistByDay((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (!artistId) delete next[day];
|
||||||
|
else next[day] = artistId;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
firstName.trim().length > 0 &&
|
||||||
|
lastName.trim().length > 0 &&
|
||||||
|
(toggleEnabled ? captchaToken.length > 0 : true) &&
|
||||||
|
!submit.isPending;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitError(null);
|
||||||
|
try {
|
||||||
|
const result = await submit.mutateAsync({
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
hcaptcha_token: captchaToken || 'dev',
|
||||||
|
email: fields.has('EMAIL') ? email : undefined,
|
||||||
|
phone: fields.has('PHONE') ? phone : undefined,
|
||||||
|
comment: fields.has('COMMENT') ? comment : undefined,
|
||||||
|
companion_wish_note: fields.has('COMPANION_WISH') ? companionNote : undefined,
|
||||||
|
available: fields.has('AVAILABLE') ? available : undefined,
|
||||||
|
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE') ? willingToPayFee : undefined,
|
||||||
|
position_wish_ids: fields.has('POSITION_WISHES') ? positionIds : undefined,
|
||||||
|
artist_wish_ids: fields.has('ARTIST_WISHES') ? Object.values(artistByDay) : undefined,
|
||||||
|
photo: fields.has('PHOTO') ? photo : undefined,
|
||||||
|
});
|
||||||
|
setVolunteerId(result.volunteer_id);
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitError(errorMessage(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-[560px] flex-col gap-4 p-4">
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader>{t('publicForm.title')}</PanelHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 p-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('config.vol.field.firstName')}</FieldLabel>
|
||||||
|
<TextInput value={firstName} onChange={setFirstName} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('config.vol.field.lastName')}</FieldLabel>
|
||||||
|
<TextInput value={lastName} onChange={setLastName} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fields.has('EMAIL') ? (
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('form.field.email')}</FieldLabel>
|
||||||
|
<TextInput mono value={email} onChange={setEmail} />
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fields.has('PHONE') ? (
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('form.field.phone')}</FieldLabel>
|
||||||
|
<TextInput mono value={phone} onChange={setPhone} />
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fields.has('POSITION_WISHES') && config.data.positions.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<FieldLabel>
|
||||||
|
{t('form.field.positionWishes')} — {t('publicForm.positionWishes.count', {
|
||||||
|
count: positionIds.length,
|
||||||
|
max: MAX_POSITION_WISHES,
|
||||||
|
})}
|
||||||
|
</FieldLabel>
|
||||||
|
<div className="flex flex-col gap-[6px] border-2 border-ink bg-cream p-[9px]">
|
||||||
|
{config.data.positions.map((p) => {
|
||||||
|
const checked = positionIds.includes(p.id);
|
||||||
|
const disabled = !checked && positionIds.length >= MAX_POSITION_WISHES;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={p.id}
|
||||||
|
className={`flex items-center gap-[8px] font-mono text-[12px] ${disabled ? 'opacity-40' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={() => togglePosition(p.id)}
|
||||||
|
className="h-[15px] w-[15px] accent-ink"
|
||||||
|
/>
|
||||||
|
{p.name}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fields.has('ARTIST_WISHES') && artistsByDay.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<FieldLabel>{t('form.field.artistWishes')}</FieldLabel>
|
||||||
|
<div className="flex flex-col gap-[8px]">
|
||||||
|
{artistsByDay.map(([day, artists]) => (
|
||||||
|
<label key={day} className="block">
|
||||||
|
<span className="mb-[3px] block font-mono text-[10px] opacity-60">
|
||||||
|
{longDayLabel(`${day}T12:00`)}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={artistByDay[day] ?? ''}
|
||||||
|
onChange={(e) => pickArtistForDay(day, e.target.value)}
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[8px] font-mono text-[12px]"
|
||||||
|
>
|
||||||
|
<option value="">{t('publicForm.artistWishes.none')}</option>
|
||||||
|
{artists.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fields.has('COMPANION_WISH') ? (
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('form.field.companionWish')}</FieldLabel>
|
||||||
|
<TextInput value={companionNote} onChange={setCompanionNote} />
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fields.has('COMMENT') ? (
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('form.field.comment')}</FieldLabel>
|
||||||
|
<textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[9px] font-mono text-[12px]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fields.has('AVAILABLE') ? (
|
||||||
|
<label className="flex cursor-pointer items-center gap-[8px]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={available}
|
||||||
|
onChange={(e) => setAvailable(e.target.checked)}
|
||||||
|
className="h-[16px] w-[16px] accent-ink"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-[12px]">{t('form.field.available')}</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fields.has('WILLING_TO_PAY_FEE') ? (
|
||||||
|
<label className="flex cursor-pointer items-center gap-[8px]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={willingToPayFee}
|
||||||
|
onChange={(e) => setWillingToPayFee(e.target.checked)}
|
||||||
|
className="h-[16px] w-[16px] accent-ink"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-[12px]">{t('form.field.willingToPayFee')}</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fields.has('PHOTO') ? (
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('form.field.photo')}</FieldLabel>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
onChange={(e) => setPhoto(e.target.files?.[0] ?? null)}
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[8px] font-mono text-[11px]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{toggleEnabled ? (
|
||||||
|
<HCaptchaWidget onToken={setCaptchaToken} />
|
||||||
|
) : (
|
||||||
|
<p className="font-mono text-[9.5px] opacity-50">{t('publicForm.captcha.disabled')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submitError ? (
|
||||||
|
<p className="font-mono text-[11px] font-bold text-orange-dark">{submitError}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Btn type="submit" disabled={!canSubmit}>
|
||||||
|
{submit.isPending ? t('publicForm.submitting') : t('publicForm.submit')}
|
||||||
|
</Btn>
|
||||||
|
</form>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
useUpdatePosition,
|
useUpdatePosition,
|
||||||
useUpdateVolunteer,
|
useUpdateVolunteer,
|
||||||
useVolunteerAvailability,
|
useVolunteerAvailability,
|
||||||
|
useVolunteerPhotoUrl,
|
||||||
useVolunteers,
|
useVolunteers,
|
||||||
} from '@/api/hooks';
|
} from '@/api/hooks';
|
||||||
import type {
|
import type {
|
||||||
@@ -30,6 +31,7 @@ import type {
|
|||||||
PositionRead,
|
PositionRead,
|
||||||
VolunteerCreate,
|
VolunteerCreate,
|
||||||
VolunteerRead,
|
VolunteerRead,
|
||||||
|
VolunteerUpdate,
|
||||||
} from '@/api/schema';
|
} from '@/api/schema';
|
||||||
import { Chip, FieldLabel, HelpTip, TextInput } from '@/components/ui';
|
import { Chip, FieldLabel, HelpTip, TextInput } from '@/components/ui';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
@@ -158,6 +160,79 @@ function VolunteerAccessSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponses du formulaire public d'inscription (voir `app/api/forms.py`) —
|
||||||
|
* note de compagnon en texte libre (à résoudre manuellement en vrai vœu via
|
||||||
|
* le sélecteur ci-dessus une fois la personne visée elle-même inscrite),
|
||||||
|
* cases informatives, photo. N'apparaît que si le bénévole vient
|
||||||
|
* effectivement de ce formulaire (sinon rien à afficher).
|
||||||
|
*/
|
||||||
|
function PublicFormAnswers({
|
||||||
|
volunteer,
|
||||||
|
patch,
|
||||||
|
}: {
|
||||||
|
volunteer: VolunteerRead;
|
||||||
|
patch: (body: VolunteerUpdate) => void;
|
||||||
|
}) {
|
||||||
|
const photoUrl = useVolunteerPhotoUrl(volunteer.id, volunteer.has_photo);
|
||||||
|
|
||||||
|
const hasAnything =
|
||||||
|
volunteer.has_photo ||
|
||||||
|
Boolean(volunteer.companion_wish_note) ||
|
||||||
|
volunteer.self_declared_available ||
|
||||||
|
volunteer.willing_to_pay_fee;
|
||||||
|
if (!hasAnything) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 border-2 border-ink bg-sand p-[9px]">
|
||||||
|
<FieldLabel>
|
||||||
|
{t('config.vol.publicForm.title')} <HelpTip text={t('config.vol.publicForm.hint')} />
|
||||||
|
</FieldLabel>
|
||||||
|
|
||||||
|
{photoUrl ? (
|
||||||
|
<img
|
||||||
|
src={photoUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-[90px] w-[90px] flex-none border-2 border-ink object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{volunteer.companion_wish_note ? (
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('form.field.companionWish')}</FieldLabel>
|
||||||
|
<textarea
|
||||||
|
value={volunteer.companion_wish_note}
|
||||||
|
onChange={(e) => patch({ companion_wish_note: e.target.value || null })}
|
||||||
|
rows={2}
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-[14px]">
|
||||||
|
<label className="flex cursor-pointer items-center gap-[7px]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={volunteer.self_declared_available}
|
||||||
|
onChange={(e) => patch({ self_declared_available: e.target.checked })}
|
||||||
|
className="h-[15px] w-[15px] accent-ink"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-[11px]">{t('form.field.available')}</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex cursor-pointer items-center gap-[7px]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={volunteer.willing_to_pay_fee}
|
||||||
|
onChange={(e) => patch({ willing_to_pay_fee: e.target.checked })}
|
||||||
|
className="h-[15px] w-[15px] accent-ink"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-[11px]">{t('form.field.willingToPayFee')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Postes dont ce bénévole est référent (`Position.lead_account_id`). Cocher
|
* Postes dont ce bénévole est référent (`Position.lead_account_id`). Cocher
|
||||||
* un poste crée ou met à niveau son accès de connexion en RÉFÉRENT (LEAD) —
|
* un poste crée ou met à niveau son accès de connexion en RÉFÉRENT (LEAD) —
|
||||||
@@ -638,6 +713,8 @@ export function ConfigVolunteers() {
|
|||||||
searchPlaceholder={t('config.vol.searchCompanion')}
|
searchPlaceholder={t('config.vol.searchCompanion')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PublicFormAnswers volunteer={selected} patch={patch} />
|
||||||
|
|
||||||
<AvailabilityEditor volunteerId={selected.id} />
|
<AvailabilityEditor volunteerId={selected.id} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user