Inscription en deux temps, emails de confirmation et export Excel des bénévoles
ci-cd / build-backend (push) Successful in 30s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s

Formulaire public — paiement en ligne :
- Le bénévole n'est plus compté en base tant qu'un paiement en ligne choisi
  n'est pas confirmé (PendingFormSubmission, promu en Volunteer à la
  confirmation, webhook ou vérification en direct).
- Type de paiement « mécénat » : étape d'adresse postale avant le paiement,
  conservée sur le bénévole pour un reçu fiscal (cerfa) ultérieur.
- Page de confirmation au retour de SumUp, avec relance possible après
  échec/expiration sans ressaisir le formulaire.
- « Je paierai sur place » remplace « je ne participe pas » ; tous les
  champs activés par la coordination sont désormais obligatoires.
- La fiche bénévole n'affiche plus l'état des cases à cocher, seulement le
  résultat réel du paiement (libellé, montant, statut).

Emails :
- Email de confirmation d'inscription à la création du bénévole (tient lieu
  de reçu de paiement quand une cotisation est payée en ligne).
- Mise en forme HTML au thème de l'application (logo de l'édition en
  en-tête) pour tous les emails envoyés, texte brut conservé en repli.

Export Excel des bénévoles (COORDINATION) : coordonnées, vœux résolus en
noms lisibles, cotisation et mécénat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-30 07:41:58 +02:00
co-authored by Claude Sonnet 5
parent 68d42ee303
commit a2b748566b
34 changed files with 2318 additions and 291 deletions
+126 -52
View File
@@ -194,6 +194,15 @@ les tables sauf les tables d'association.
`POST /editions/{id}/position-sheets/generate` (COORDINATION,
`{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune
affectation n'apparaît pas (rien à imprimer).
- **Export bénévoles** (Excel, `app/exporting/xlsx.py` + `GET
/editions/{id}/volunteers/export`, COORDINATION) : une ligne par
bénévole, mêmes champs que la fiche (coordonnées, vœux, cotisation,
mécénat) — les vœux (postes/artistes/compagnons) sont résolus en noms
lisibles, jamais en UUID. `openpyxl`, déjà utilisé en lecture par l'import
(`app/importing/`), cette fois en écriture. Bouton « Exporter en Excel »
sur l'écran de configuration des bénévoles (`ConfigVolunteers.tsx`), même
mécanique de téléchargement que les badges/feuilles de poste (blob →
`<a download>` généré côté client, voir `BadgesPage.tsx`).
- **`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. `sumup_merchant_code`/
@@ -203,64 +212,104 @@ les tables sauf les tables d'association.
place ; chaîne vide en écriture ⇒ l'efface).
- **`FormPaymentOption`** : un type de paiement de la cotisation (ex.
« Cotisation classique », « Mécénat ») — `label` + `amount` (montant
fixe), `order`. Liste libre par édition, autant que la coordination veut
(comme les créneaux types `DayPart`), gérée depuis l'écran « Inscriptions »
(bloc « Types de paiement »). CRUD `GET/POST/PATCH/DELETE
/editions/{id}/form/payment-options` (COORDINATION). Unique
(`edition_id`, `label`) — 409 `form.duplicate_payment_option_label` sinon ;
montant `> 0` requis — 422 `form.invalid_sumup_amount` sinon. Le bénévole
en choisit au plus un à l'inscription (voir `Paiement de la cotisation`
plus bas) ; supprimer une option n'affecte jamais les bénévoles qui
l'avaient déjà choisie (copie figée sur `Volunteer`).
fixe), `order`, `requires_mecenat_info` (voir plus bas). Liste libre par
édition, autant que la coordination veut (comme les créneaux types
`DayPart`), gérée depuis l'écran « Inscriptions » (bloc « Types de
paiement »). CRUD `GET/POST/PATCH/DELETE /editions/{id}/form/payment-options`
(COORDINATION). Unique (`edition_id`, `label`) — 409
`form.duplicate_payment_option_label` sinon ; montant `> 0` requis — 422
`form.invalid_sumup_amount` sinon. Le bénévole en choisit au plus un à
l'inscription (voir `Paiement de la cotisation` plus bas) ; supprimer une
option n'affecte jamais les bénévoles qui l'avaient déjà choisie (copie
figée sur `Volunteer`).
- **`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
sont pas dans cette liste, contrairement aux neuf autres. **Un champ activé
est obligatoire** à la soumission (422 `import.row.missing_required_field`
sinon) — `EMAIL`/`PHONE` non vides, `AVAILABLE` coché ; le formulaire
public n'est plus une simple collecte d'infos facultatives. 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`, `POST .../form/public/submit` et
`POST .../form/public/sumup-webhook` sont les **trois seuls endpoints de
toute l'API sans authentification** — les deux premiers 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 webhook est appelé par SumUp
lui-même, donc sans hCaptcha possible — voir plus bas. 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.
- **Paiement de la cotisation (SumUp)** : si le bénévole choisit un type de
paiement (`FormPaymentOption`, ci-dessus — le formulaire public
n'affiche ce choix, à la place de la case `WILLING_TO_PAY_FEE` toute
seule, que si `sumup_enabled` sur `PublicFormConfig` est vrai, c.-à-d.
identifiants SumUp renseignés **et** au moins un type de paiement
configuré ; sinon la case reste une simple déclaration, comme avant SumUp)
et que l'édition a configuré ses identifiants, la soumission crée un
checkout hébergé (`app/sumup/client.py:create_checkout`, API SumUp
`POST /v0.1/checkouts`, `hosted_checkout.enabled=true`, montant et
libellé de l'option choisie) et renvoie son URL de paiement dans
`FormSubmissionResult.sumup_checkout_url` (le bénévole y est redirigé
juste après son inscription — pas avant, contrairement à l'ancien lien
Yapla statique). Le libellé et le montant de l'option choisie sont
recopiés sur `Volunteer.fee_payment_label`/`fee_amount` **au moment du
choix** — une copie figée, jamais recalculée si l'option est éditée ou
supprimée ensuite (`FormPaymentOption` reste une pure configuration,
jamais une référence vivante depuis l'historique des bénévoles).
`Volunteer.fee_checkout_id` (identifiant SumUp, jamais exposé) et
`fee_payment_status` (`PENDING`/`PAID`/`FAILED`/`EXPIRED`, `NULL` tant
qu'aucun checkout n'a été créé) suivent l'état ; `fee_paid_at` est posé à
la première confirmation `PAID`. Best effort, comme l'envoi d'emails : un
échec de l'appel API SumUp (réseau, identifiants invalides…) ne bloque
jamais l'inscription. Le statut est tenu à jour par
`POST .../form/public/sumup-webhook` (`event_type:
CHECKOUT_STATUS_CHANGED`, `id`) : le payload n'étant qu'un identifiant,
l'app relit toujours l'état réel via l'API (`get_checkout`) avant de
l'appliquer, jamais fait confiance au seul contenu du webhook. Visible par
la coordination sur la fiche du bénévole (`ConfigVolunteers.tsx`, libellé
+ montant + statut), en lecture seule (pas de bascule manuelle).
`GET/PUT/PATCH /editions/{id}/form[...]` (COORDINATION) configurent ; le
reste des endpoints sous ce routeur est public (**aucune authentification**) :
`GET .../form/public` et `POST .../form/public/submit` (protégés par
hCaptcha, `app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` — 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`), plus, pour le
paiement en ligne (voir plus bas), `POST .../form/public/pending/{token}
/mecenat-info`, `GET .../form/public/pending/{token}`,
`POST .../form/public/pending/{token}/retry-payment` et
`POST .../form/public/sumup-webhook` (appelé par SumUp lui-même, donc sans
hCaptcha possible). Un bénévole créé directement (sans paiement en ligne)
apparaît immédiatement dans la liste de la coordination, comme n'importe
quel autre bénévole.
- **Paiement de la cotisation (SumUp)** — deux chemins selon le choix du
bénévole (le formulaire public n'affiche ce choix, à la place de la case
`WILLING_TO_PAY_FEE` toute seule, que si `sumup_enabled` sur
`PublicFormConfig` est vrai, c.-à-d. identifiants SumUp renseignés **et**
au moins un type de paiement configuré ; sinon la case reste une simple
déclaration à cocher, obligatoire comme les autres, comme avant SumUp) :
- **« Je paierai sur place »** (pas de paiement en ligne) : le `Volunteer`
est créé tout de suite, comme n'importe quelle autre soumission.
- **Un type de paiement en ligne choisi** : **rien n'est compté en base
tant que le paiement n'est pas confirmé** — la soumission crée une
ligne `PendingFormSubmission` (mêmes champs que le formulaire, plus
volatils) à la place du `Volunteer`, pour ne jamais compter en base
quelqu'un qui a rempli le formulaire sans aller au bout du paiement.
Si l'option choisie porte `requires_mecenat_info` (ex. « Mécénat ») :
`FormSubmissionResult.requires_mecenat_info=true` — le front-end
enchaîne sur une étape d'adresse postale
(`POST .../pending/{token}/mecenat-info` : `address`/`city`/
`postal_code`/`country`, tous obligatoires) avant de créer le checkout,
pour permettre l'édition d'un reçu fiscal (cerfa) plus tard ; ces
coordonnées vivent sur `Volunteer.mecenat_address`/`mecenat_city`/
`mecenat_postal_code`/`mecenat_country` une fois le bénévole créé.
Sinon, le checkout est créé tout de suite. Dans les deux cas, le
checkout hébergé (`app/sumup/client.py:create_checkout`, API SumUp
`POST /v0.1/checkouts`, `hosted_checkout.enabled=true`, montant et
libellé de l'option choisie, `redirect_url` pointant vers
`/inscription?paiement={pending_id}`) renvoie son URL dans
`FormSubmissionResult.sumup_checkout_url` — le navigateur y est
entièrement redirigé (`window.location.href`, quitte le site). Un
échec de l'appel API SumUp (réseau, identifiants invalides…) ne perd
jamais les informations déjà saisies : `sumup_checkout_url` vaut alors
`None` et le front-end propose de réessayer
(`POST .../pending/{token}/retry-payment`, qui ouvre un nouveau
checkout — un `checkout_reference` n'est réutilisable qu'une fois côté
SumUp).
Au retour du bénévole sur `/inscription?paiement={token}`, la page de
confirmation interroge en direct `GET .../form/public/pending/{token}`
(toutes les 3 s, jusqu'à 40 fois) : cet endpoint relit toujours l'état
réel du checkout auprès de SumUp (`get_checkout`) à chaque appel — jamais
seulement la dernière valeur connue en base — et **c'est cette confirmation
qui crée enfin le `Volunteer`** (`app/api/forms.py:_promote_pending`),
résolution des vœux de poste/artiste y compris (silencieusement filtrés
s'ils ont entretemps disparu — le paiement a déjà eu lieu, rien ne doit
plus faire échouer la création). Le webhook SumUp
(`POST .../form/public/sumup-webhook`, `event_type:
CHECKOUT_STATUS_CHANGED` + `id`) fait la même chose de son côté,
indépendamment (pas de dépendance stricte à ce que le navigateur reste
ouvert) ; les deux chemins partagent la même fonction d'application du
résultat, idempotente. Le libellé et le montant de l'option choisie sont
recopiés **au moment du choix** sur `Volunteer.fee_payment_label`/
`fee_amount` — une copie figée, jamais recalculée si l'option est éditée
ou supprimée ensuite. `Volunteer.fee_checkout_id` (identifiant SumUp,
jamais exposé) et `fee_payment_status` (`PENDING`/`PAID`/`FAILED`/
`EXPIRED`, `NULL` tant qu'aucun checkout n'a été créé) suivent l'état ;
`fee_paid_at` est posé à la première confirmation `PAID`. Une fois promue,
la ligne `PendingFormSubmission` correspondante voit ses champs nominatifs
vidés (RGPD : l'information ne vit plus que sur `Volunteer`) ; une ligne
jamais payée est purgée après 48 h sans être relancée
(`_prune_stale_pending`, nettoyage paresseux à chaque soumission/relance,
même principe que `app/auth/rate_limit.py`). Visible par la coordination
sur la fiche du bénévole (`ConfigVolunteers.tsx`, libellé + montant +
statut — jamais l'état de la case à cocher elle-même), en lecture seule
(pas de bascule manuelle).
Conventions techniques :
@@ -366,6 +415,17 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
alias validé.
- Diagnostic : `docker compose run --rm backend uv run python -m app.email.check
<destinataire>` — affiche la config effective et la cause exacte d'un échec.
- **Mise en forme** : chaque email part en `multipart/alternative` — texte
brut (source de vérité, utilisé par les tests) **et** une version HTML au
thème de l'appli (`app/email/html.py:render_html` — couleurs/typographie
de `frontend/tailwind.config.js`, mise en page par tableau et styles en
ligne, sans police web ni CSS externe, pour rester lisible dans le plus de
clients mail possible). Construite par `build_message`
(`app/email/sender.py`), pour **tous** les emails envoyés, sans exception.
Logo de l'édition en en-tête si elle en a importé un pour son formulaire
public (`app/email/branding.py:edition_logo_url`, `FormTemplate.logo` —
lien vers `GET .../form/logo`, public) ; à défaut, un bandeau texte avec
le sujet.
- **Lien magique** : `POST .../auth/magic-link` envoie l'email contenant
`PUBLIC_BASE_URL` + `magic_link_path` + `?token=`. Sans SMTP, le jeton (non
@@ -378,6 +438,18 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
de l'édition qui ont un email. Envoi en tâche de fond (`BackgroundTasks`,
une connexion SMTP réutilisée). Réponse : `{ queued, skipped_no_email,
recipients }`.
- **Confirmation d'inscription** (`app/api/forms.py`) : dès qu'un `Volunteer`
est effectivement créé et a un email — que ce soit tout de suite (pas de
paiement en ligne) ou à la promotion d'une `PendingFormSubmission`
(paiement confirmé, voir § 2 `Paiement de la cotisation`) — un email de
confirmation part en tâche de fond (`BackgroundTasks`, best effort, comme
les autres). Deux modèles (`render_registration_confirmation`/
`render_registration_paid_confirmation`, `app/email/templates.py`) : le
second ajoute le libellé et le montant payés, et tient lieu de **reçu de
paiement** — l'API SumUp Hosted Checkout utilisée ici ne documente pas de
mécanisme pour que SumUp envoie elle-même un reçu au payeur (son
« envoi automatique de reçu » vise le lecteur de carte/point de vente, pas
ce mode d'intégration), donc l'appli ne compte pas dessus.
---
@@ -577,8 +649,9 @@ l'étape 21.)*
│ │ │ # assignments, solver, matrix, notifications,
│ │ │ # badges, position_sheets, forms
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
│ │ ├── email/ # SMTP (sender) + rendu des emails (templates)
│ │ ├── email/ # SMTP (sender) + rendu texte (templates) et HTML (html.py) + logo (branding.py)
│ │ ├── importing/ # import CSV/Excel (parser, text, service)
│ │ ├── exporting/ # export bénévoles en Excel (xlsx.py, openpyxl)
│ │ ├── models/ # tables SQLModel
│ │ ├── validation/ # moteur pur (models, engine) + loader (base)
│ │ ├── solver/ # suggestions OR-Tools (models, engine, loader)
@@ -700,7 +773,8 @@ Aucune dépendance locale requise hors Docker.
| 22 | Feuilles de poste (PDF, complément des badges) : une page par poste et par jour avec le référent, les créneaux, les bénévoles affectés et leur téléphone — bouton depuis la grille des besoins (COORDINATION) ; photo du bénévole affichée sur son écran et sur l'écran référent (en face du nom de chaque membre de l'équipe) | à valider |
| 23 | Créneaux : plus de génération automatique à la création d'un poste — création manuelle à horaire libre (`ConfigGrid.tsx`), génération depuis les créneaux types conservée en option (« Générer les créneaux ») ; grille des besoins redessinée en vrai axe des temps (une colonne = 30 min, bornée à la plage utilisée par jour, cases fusionnées pour chaque créneau — `buildTimeGrid`/`placeInTimeGrid`) | à valider |
| 24 | Corrections de stabilité de la grille des besoins (suite à l'étape 23) : positionnement explicite (`gridColumn`/`gridRow`) de chaque cellule (un poste aux créneaux non alignés sur 30 min ne décale plus les postes suivants) ; hiérarchie de `z-index` sans égalité entre les en-têtes figés et isolation de l'infobulle de survol dans son propre contexte d'empilement (ne recouvrait plus la ligne « Affectés / idéal » pendant un défilement) | à valider |
| 25 | Paiement de la cotisation via l'API SumUp (remplace le lien statique Yapla) : plusieurs types de paiement configurables par édition (`FormPaymentOption` — libellé + montant, ex. « Cotisation classique », « Mécénat »), le bénévole en choisit un à l'inscription, checkout hébergé créé à la soumission si l'édition a configuré ses identifiants SumUp, lien de paiement affiché juste après l'inscription, statut (`PENDING`/`PAID`/`FAILED`/`EXPIRED`) tenu à jour par un webhook SumUp et visible (libellé + montant + statut) sur la fiche du bénévole | à valider |
| 25 | Paiement de la cotisation via l'API SumUp (remplace le lien statique Yapla) : plusieurs types de paiement configurables par édition (`FormPaymentOption` — libellé + montant, ex. « Cotisation classique », « Mécénat »), inscription en deux temps pour un paiement en ligne (`PendingFormSubmission` : le `Volunteer` n'est créé qu'à la confirmation du paiement, jamais avant), étape d'adresse postale pour un type « mécénat » (cerfa), page de confirmation en direct au retour de SumUp avec relance possible après échec, tous les champs activés désormais obligatoires, statut (`PENDING`/`PAID`/`FAILED`/`EXPIRED`) tenu à jour par webhook et visible (libellé + montant + statut) sur la fiche du bénévole, email de confirmation d'inscription à la création du bénévole (tenant lieu de reçu quand un paiement est confirmé), mise en forme HTML au thème de l'appli (logo de l'édition en en-tête) pour tous les emails envoyés | à valider |
| 26 | Export Excel de la liste des bénévoles (`GET /editions/{id}/volunteers/export`, COORDINATION, `app/exporting/xlsx.py`) : coordonnées, vœux (résolus en noms lisibles), cotisation et mécénat — bouton dédié sur l'écran de configuration des bénévoles | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +