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