Inscription en deux temps, emails de confirmation et export Excel des bénévoles
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:
co-authored by
Claude Sonnet 5
parent
68d42ee303
commit
a2b748566b
@@ -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 +
|
||||
|
||||
Reference in New Issue
Block a user