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,
|
`POST /editions/{id}/position-sheets/generate` (COORDINATION,
|
||||||
`{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune
|
`{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune
|
||||||
affectation n'apparaît pas (rien à imprimer).
|
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
|
- **`FormTemplate`** : configuration du formulaire public d'inscription
|
||||||
d'une édition (une ligne, créée à la demande). `is_open` coupe les
|
d'une édition (une ligne, créée à la demande). `is_open` coupe les
|
||||||
nouvelles soumissions sans effacer la configuration. `sumup_merchant_code`/
|
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).
|
place ; chaîne vide en écriture ⇒ l'efface).
|
||||||
- **`FormPaymentOption`** : un type de paiement de la cotisation (ex.
|
- **`FormPaymentOption`** : un type de paiement de la cotisation (ex.
|
||||||
« Cotisation classique », « Mécénat ») — `label` + `amount` (montant
|
« Cotisation classique », « Mécénat ») — `label` + `amount` (montant
|
||||||
fixe), `order`. Liste libre par édition, autant que la coordination veut
|
fixe), `order`, `requires_mecenat_info` (voir plus bas). Liste libre par
|
||||||
(comme les créneaux types `DayPart`), gérée depuis l'écran « Inscriptions »
|
édition, autant que la coordination veut (comme les créneaux types
|
||||||
(bloc « Types de paiement »). CRUD `GET/POST/PATCH/DELETE
|
`DayPart`), gérée depuis l'écran « Inscriptions » (bloc « Types de
|
||||||
/editions/{id}/form/payment-options` (COORDINATION). Unique
|
paiement »). CRUD `GET/POST/PATCH/DELETE /editions/{id}/form/payment-options`
|
||||||
(`edition_id`, `label`) — 409 `form.duplicate_payment_option_label` sinon ;
|
(COORDINATION). Unique (`edition_id`, `label`) — 409
|
||||||
montant `> 0` requis — 422 `form.invalid_sumup_amount` sinon. Le bénévole
|
`form.duplicate_payment_option_label` sinon ; montant `> 0` requis — 422
|
||||||
en choisit au plus un à l'inscription (voir `Paiement de la cotisation`
|
`form.invalid_sumup_amount` sinon. Le bénévole en choisit au plus un à
|
||||||
plus bas) ; supprimer une option n'affecte jamais les bénévoles qui
|
l'inscription (voir `Paiement de la cotisation` plus bas) ; supprimer une
|
||||||
l'avaient déjà choisie (copie figée sur `Volunteer`).
|
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` ∈
|
- **`FormField`** : un champ activé sur le formulaire (`field_type` ∈
|
||||||
`EMAIL` / `PHONE` / `POSITION_WISHES` / `ARTIST_WISHES` / `COMPANION_WISH`
|
`EMAIL` / `PHONE` / `POSITION_WISHES` / `ARTIST_WISHES` / `COMPANION_WISH`
|
||||||
/ `AVAILABLE` / `WILLING_TO_PAY_FEE` / `PHOTO` / `COMMENT`, au plus un par
|
/ `AVAILABLE` / `WILLING_TO_PAY_FEE` / `PHOTO` / `COMMENT`, au plus un par
|
||||||
édition — `PUT .../form/fields` remplace tout). **Nom et prénom sont
|
édition — `PUT .../form/fields` remplace tout). **Nom et prénom sont
|
||||||
toujours collectés**, indispensables à la création du `Volunteer` : ils ne
|
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
|
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
|
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`).
|
souhaité par jour de festival (`app/api/forms.py:_check_one_artist_wish_per_day`).
|
||||||
`GET/PUT/PATCH /editions/{id}/form[...]` (COORDINATION) configurent ;
|
`GET/PUT/PATCH /editions/{id}/form[...]` (COORDINATION) configurent ; le
|
||||||
`GET /editions/{id}/form/public`, `POST .../form/public/submit` et
|
reste des endpoints sous ce routeur est public (**aucune authentification**) :
|
||||||
`POST .../form/public/sumup-webhook` sont les **trois seuls endpoints de
|
`GET .../form/public` et `POST .../form/public/submit` (protégés par
|
||||||
toute l'API sans authentification** — les deux premiers protégés par
|
hCaptcha, `app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` — désactivée si
|
||||||
hCaptcha (`app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` ; vérification
|
absente, comme `EMAIL_ENABLED=false` — et un filet de sécurité global par
|
||||||
désactivée si absente, comme `EMAIL_ENABLED=false`) et un filet de
|
édition, `app/auth/rate_limit.py:form_submission_limiter`), plus, pour le
|
||||||
sécurité global par édition (`app/auth/rate_limit.py:form_submission_limiter`,
|
paiement en ligne (voir plus bas), `POST .../form/public/pending/{token}
|
||||||
hCaptcha reste la défense principale) ; le webhook est appelé par SumUp
|
/mecenat-info`, `GET .../form/public/pending/{token}`,
|
||||||
lui-même, donc sans hCaptcha possible — voir plus bas. Le bénévole créé
|
`POST .../form/public/pending/{token}/retry-payment` et
|
||||||
par une soumission apparaît immédiatement dans la liste de la
|
`POST .../form/public/sumup-webhook` (appelé par SumUp lui-même, donc sans
|
||||||
coordination, comme n'importe quel autre bénévole.
|
hCaptcha possible). Un bénévole créé directement (sans paiement en ligne)
|
||||||
- **Paiement de la cotisation (SumUp)** : si le bénévole choisit un type de
|
apparaît immédiatement dans la liste de la coordination, comme n'importe
|
||||||
paiement (`FormPaymentOption`, ci-dessus — le formulaire public
|
quel autre bénévole.
|
||||||
n'affiche ce choix, à la place de la case `WILLING_TO_PAY_FEE` toute
|
- **Paiement de la cotisation (SumUp)** — deux chemins selon le choix du
|
||||||
seule, que si `sumup_enabled` sur `PublicFormConfig` est vrai, c.-à-d.
|
bénévole (le formulaire public n'affiche ce choix, à la place de la case
|
||||||
identifiants SumUp renseignés **et** au moins un type de paiement
|
`WILLING_TO_PAY_FEE` toute seule, que si `sumup_enabled` sur
|
||||||
configuré ; sinon la case reste une simple déclaration, comme avant SumUp)
|
`PublicFormConfig` est vrai, c.-à-d. identifiants SumUp renseignés **et**
|
||||||
et que l'édition a configuré ses identifiants, la soumission crée un
|
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
|
checkout hébergé (`app/sumup/client.py:create_checkout`, API SumUp
|
||||||
`POST /v0.1/checkouts`, `hosted_checkout.enabled=true`, montant et
|
`POST /v0.1/checkouts`, `hosted_checkout.enabled=true`, montant et
|
||||||
libellé de l'option choisie) et renvoie son URL de paiement dans
|
libellé de l'option choisie, `redirect_url` pointant vers
|
||||||
`FormSubmissionResult.sumup_checkout_url` (le bénévole y est redirigé
|
`/inscription?paiement={pending_id}`) renvoie son URL dans
|
||||||
juste après son inscription — pas avant, contrairement à l'ancien lien
|
`FormSubmissionResult.sumup_checkout_url` — le navigateur y est
|
||||||
Yapla statique). Le libellé et le montant de l'option choisie sont
|
entièrement redirigé (`window.location.href`, quitte le site). Un
|
||||||
recopiés sur `Volunteer.fee_payment_label`/`fee_amount` **au moment du
|
échec de l'appel API SumUp (réseau, identifiants invalides…) ne perd
|
||||||
choix** — une copie figée, jamais recalculée si l'option est éditée ou
|
jamais les informations déjà saisies : `sumup_checkout_url` vaut alors
|
||||||
supprimée ensuite (`FormPaymentOption` reste une pure configuration,
|
`None` et le front-end propose de réessayer
|
||||||
jamais une référence vivante depuis l'historique des bénévoles).
|
(`POST .../pending/{token}/retry-payment`, qui ouvre un nouveau
|
||||||
`Volunteer.fee_checkout_id` (identifiant SumUp, jamais exposé) et
|
checkout — un `checkout_reference` n'est réutilisable qu'une fois côté
|
||||||
`fee_payment_status` (`PENDING`/`PAID`/`FAILED`/`EXPIRED`, `NULL` tant
|
SumUp).
|
||||||
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
|
Au retour du bénévole sur `/inscription?paiement={token}`, la page de
|
||||||
échec de l'appel API SumUp (réseau, identifiants invalides…) ne bloque
|
confirmation interroge en direct `GET .../form/public/pending/{token}`
|
||||||
jamais l'inscription. Le statut est tenu à jour par
|
(toutes les 3 s, jusqu'à 40 fois) : cet endpoint relit toujours l'état
|
||||||
`POST .../form/public/sumup-webhook` (`event_type:
|
réel du checkout auprès de SumUp (`get_checkout`) à chaque appel — jamais
|
||||||
CHECKOUT_STATUS_CHANGED`, `id`) : le payload n'étant qu'un identifiant,
|
seulement la dernière valeur connue en base — et **c'est cette confirmation
|
||||||
l'app relit toujours l'état réel via l'API (`get_checkout`) avant de
|
qui crée enfin le `Volunteer`** (`app/api/forms.py:_promote_pending`),
|
||||||
l'appliquer, jamais fait confiance au seul contenu du webhook. Visible par
|
résolution des vœux de poste/artiste y compris (silencieusement filtrés
|
||||||
la coordination sur la fiche du bénévole (`ConfigVolunteers.tsx`, libellé
|
s'ils ont entretemps disparu — le paiement a déjà eu lieu, rien ne doit
|
||||||
+ montant + statut), en lecture seule (pas de bascule manuelle).
|
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 :
|
Conventions techniques :
|
||||||
|
|
||||||
@@ -366,6 +415,17 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
|
|||||||
alias validé.
|
alias validé.
|
||||||
- Diagnostic : `docker compose run --rm backend uv run python -m app.email.check
|
- 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.
|
<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
|
- **Lien magique** : `POST .../auth/magic-link` envoie l'email contenant
|
||||||
`PUBLIC_BASE_URL` + `magic_link_path` + `?token=`. Sans SMTP, le jeton (non
|
`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`,
|
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,
|
une connexion SMTP réutilisée). Réponse : `{ queued, skipped_no_email,
|
||||||
recipients }`.
|
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,
|
│ │ │ # assignments, solver, matrix, notifications,
|
||||||
│ │ │ # badges, position_sheets, forms
|
│ │ │ # badges, position_sheets, forms
|
||||||
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
|
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
|
||||||
│ │ ├── email/ # SMTP (sender) + rendu des emails (templates)
|
│ │ ├── email/ # SMTP (sender) + rendu texte (templates) et HTML (html.py) + logo (branding.py)
|
||||||
│ │ ├── importing/ # import CSV/Excel (parser, text, service)
|
│ │ ├── importing/ # import CSV/Excel (parser, text, service)
|
||||||
|
│ │ ├── exporting/ # export bénévoles en Excel (xlsx.py, openpyxl)
|
||||||
│ │ ├── models/ # tables SQLModel
|
│ │ ├── models/ # tables SQLModel
|
||||||
│ │ ├── validation/ # moteur pur (models, engine) + loader (base)
|
│ │ ├── validation/ # moteur pur (models, engine) + loader (base)
|
||||||
│ │ ├── solver/ # suggestions OR-Tools (models, engine, loader)
|
│ │ ├── solver/ # suggestions OR-Tools (models, engine, loader)
|
||||||
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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
|
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
||||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from app.auth.rate_limit import login_limiter, magic_link_limiter
|
|||||||
from app.auth.tokens import create_access_token, hash_token
|
from app.auth.tokens import create_access_token, hash_token
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.email import render_magic_link, send
|
from app.email import edition_logo_url, render_magic_link, send
|
||||||
from app.models import Account, MagicLinkToken, local_now
|
from app.models import Account, MagicLinkToken, local_now
|
||||||
|
|
||||||
router = APIRouter(tags=["auth"])
|
router = APIRouter(tags=["auth"])
|
||||||
@@ -143,7 +143,9 @@ def request_magic_link(
|
|||||||
link=build_magic_link_url(raw),
|
link=build_magic_link_url(raw),
|
||||||
minutes=settings.magic_link_ttl_minutes,
|
minutes=settings.magic_link_ttl_minutes,
|
||||||
)
|
)
|
||||||
background.add_task(send, account.email, subject, body)
|
background.add_task(
|
||||||
|
send, account.email, subject, body, logo_url=edition_logo_url(session, edition_id)
|
||||||
|
)
|
||||||
if not settings.email_enabled:
|
if not settings.email_enabled:
|
||||||
# Aide au dev sans SMTP : le jeton (non nominatif) est journalisé.
|
# Aide au dev sans SMTP : le jeton (non nominatif) est journalisé.
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
+442
-87
@@ -20,12 +20,14 @@ libre (`companion_wish_note`) résolu ensuite manuellement par la
|
|||||||
coordination, faute de savoir si la personne visée est déjà inscrite (voir
|
coordination, faute de savoir si la personne visée est déjà inscrite (voir
|
||||||
CLAUDE.md § 2, `Volunteer`)."""
|
CLAUDE.md § 2, `Volunteer`)."""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
APIRouter,
|
APIRouter,
|
||||||
|
BackgroundTasks,
|
||||||
Depends,
|
Depends,
|
||||||
File,
|
File,
|
||||||
Form,
|
Form,
|
||||||
@@ -38,7 +40,12 @@ from sqlalchemy import delete
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.api._common import owned_or_404, set_artists, set_wished_positions
|
from app.api._common import (
|
||||||
|
owned_or_404,
|
||||||
|
resolve_owned_ids,
|
||||||
|
set_artists,
|
||||||
|
set_wished_positions,
|
||||||
|
)
|
||||||
from app.api.deps import get_edition
|
from app.api.deps import get_edition
|
||||||
from app.api.schemas import (
|
from app.api.schemas import (
|
||||||
FormFieldsSet,
|
FormFieldsSet,
|
||||||
@@ -48,6 +55,8 @@ from app.api.schemas import (
|
|||||||
FormSubmissionResult,
|
FormSubmissionResult,
|
||||||
FormTemplateRead,
|
FormTemplateRead,
|
||||||
FormTemplateUpdate,
|
FormTemplateUpdate,
|
||||||
|
MecenatInfoSubmit,
|
||||||
|
PendingSubmissionStatus,
|
||||||
PublicArtistOption,
|
PublicArtistOption,
|
||||||
PublicFormConfig,
|
PublicFormConfig,
|
||||||
PublicPaymentOption,
|
PublicPaymentOption,
|
||||||
@@ -58,6 +67,12 @@ from app.auth.deps import require_coordinator
|
|||||||
from app.auth.rate_limit import form_submission_limiter
|
from app.auth.rate_limit import form_submission_limiter
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
|
from app.email import (
|
||||||
|
edition_logo_url,
|
||||||
|
render_registration_confirmation,
|
||||||
|
render_registration_paid_confirmation,
|
||||||
|
send,
|
||||||
|
)
|
||||||
from app.forms import verify_hcaptcha
|
from app.forms import verify_hcaptcha
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Account,
|
Account,
|
||||||
@@ -68,11 +83,12 @@ from app.models import (
|
|||||||
FormFieldType,
|
FormFieldType,
|
||||||
FormPaymentOption,
|
FormPaymentOption,
|
||||||
FormTemplate,
|
FormTemplate,
|
||||||
|
PendingFormSubmission,
|
||||||
Position,
|
Position,
|
||||||
Volunteer,
|
Volunteer,
|
||||||
local_now,
|
local_now,
|
||||||
)
|
)
|
||||||
from app.sumup import create_checkout, get_checkout
|
from app.sumup import SumUpCheckout, create_checkout, get_checkout
|
||||||
|
|
||||||
logger = logging.getLogger("app.forms")
|
logger = logging.getLogger("app.forms")
|
||||||
|
|
||||||
@@ -83,6 +99,11 @@ _ALLOWED_PHOTO_TYPES = {"image/png", "image/jpeg"}
|
|||||||
# Vœux de poste : au plus deux, pour éviter que tout le monde coche tous les
|
# 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.
|
# postes « au cas où » — force une vraie priorisation dès le formulaire.
|
||||||
_MAX_POSITION_WISHES = 2
|
_MAX_POSITION_WISHES = 2
|
||||||
|
# Durée de vie d'une inscription en attente de paiement jamais menée à terme
|
||||||
|
# (ni payée, ni relancée) — purgée au-delà (voir `_prune_stale_pending`),
|
||||||
|
# pour ne pas garder indéfiniment des coordonnées de bénévoles qui n'ont
|
||||||
|
# jamais payé.
|
||||||
|
_PENDING_STALE_HOURS = 48
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -211,6 +232,7 @@ def create_payment_option(
|
|||||||
label=payload.label.strip(),
|
label=payload.label.strip(),
|
||||||
amount=payload.amount,
|
amount=payload.amount,
|
||||||
order=payload.order,
|
order=payload.order,
|
||||||
|
requires_mecenat_info=payload.requires_mecenat_info,
|
||||||
)
|
)
|
||||||
session.add(option)
|
session.add(option)
|
||||||
try:
|
try:
|
||||||
@@ -360,11 +382,79 @@ def get_public_form(
|
|||||||
description=template.description,
|
description=template.description,
|
||||||
sumup_enabled=_sumup_enabled(template, options),
|
sumup_enabled=_sumup_enabled(template, options),
|
||||||
payment_options=[
|
payment_options=[
|
||||||
PublicPaymentOption(id=o.id, label=o.label, amount=o.amount) for o in options
|
PublicPaymentOption(
|
||||||
|
id=o.id,
|
||||||
|
label=o.label,
|
||||||
|
amount=o.amount,
|
||||||
|
requires_mecenat_info=o.requires_mecenat_info,
|
||||||
|
)
|
||||||
|
for o in options
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_stale_pending(session: Session, edition_id: uuid.UUID) -> None:
|
||||||
|
"""Nettoyage paresseux (même principe que `app/auth/rate_limit.py`) :
|
||||||
|
supprime les inscriptions en attente jamais payées et jamais relancées
|
||||||
|
depuis `_PENDING_STALE_HOURS` — appelé à chaque soumission/relance, pas
|
||||||
|
de tâche planifiée dans cette appli."""
|
||||||
|
cutoff = local_now() - dt.timedelta(hours=_PENDING_STALE_HOURS)
|
||||||
|
session.execute(
|
||||||
|
delete(PendingFormSubmission).where(
|
||||||
|
PendingFormSubmission.edition_id == edition_id,
|
||||||
|
PendingFormSubmission.promoted_volunteer_id.is_(None),
|
||||||
|
PendingFormSubmission.created_at < cutoff,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None:
|
||||||
|
"""Validation partagée par l'inscription immédiate et l'inscription en
|
||||||
|
attente de paiement — `None` si aucun fichier fourni."""
|
||||||
|
if photo is None or not photo.filename:
|
||||||
|
return None
|
||||||
|
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"})
|
||||||
|
return (content, content_type) if content else None
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_owned_ids(
|
||||||
|
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID]
|
||||||
|
) -> list[uuid.UUID]:
|
||||||
|
"""Comme `resolve_owned_ids` (`app/api/_common.py`) mais silencieux : ne
|
||||||
|
garde que les ids toujours valides pour cette édition, sans jamais lever.
|
||||||
|
Utilisé à la promotion d'une inscription en attente — le paiement a déjà
|
||||||
|
eu lieu, un poste/artiste supprimé entretemps ne doit jamais faire
|
||||||
|
échouer la création du bénévole (voir `_promote_pending`)."""
|
||||||
|
wanted = list(dict.fromkeys(ids))
|
||||||
|
if not wanted:
|
||||||
|
return []
|
||||||
|
found = set(
|
||||||
|
session.exec(
|
||||||
|
select(model.id).where(
|
||||||
|
model.edition_id == edition_id, model.id.in_(wanted)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [i for i in wanted if i in found]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_pending_or_404(
|
||||||
|
session: Session, edition_id: uuid.UUID, token: uuid.UUID
|
||||||
|
) -> PendingFormSubmission:
|
||||||
|
pending = session.get(PendingFormSubmission, token)
|
||||||
|
if pending is None or pending.edition_id != edition_id:
|
||||||
|
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
|
||||||
|
return pending
|
||||||
|
|
||||||
|
|
||||||
def _parse_id_list(raw: str | None) -> list[uuid.UUID]:
|
def _parse_id_list(raw: str | None) -> list[uuid.UUID]:
|
||||||
"""`position_wish_ids`/`artist_wish_ids` arrivent en JSON dans un champ
|
"""`position_wish_ids`/`artist_wish_ids` arrivent en JSON dans un champ
|
||||||
`multipart/form-data` (impossible d'y envoyer un tableau natif)."""
|
`multipart/form-data` (impossible d'y envoyer un tableau natif)."""
|
||||||
@@ -405,6 +495,7 @@ def _check_one_artist_wish_per_day(
|
|||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
async def submit_public_form(
|
async def submit_public_form(
|
||||||
|
background: BackgroundTasks,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
first_name: str = Form(...),
|
first_name: str = Form(...),
|
||||||
@@ -415,7 +506,6 @@ async def submit_public_form(
|
|||||||
comment: str | None = Form(None),
|
comment: str | None = Form(None),
|
||||||
companion_wish_note: str | None = Form(None),
|
companion_wish_note: str | None = Form(None),
|
||||||
available: bool = Form(False),
|
available: bool = Form(False),
|
||||||
willing_to_pay_fee: bool = Form(False),
|
|
||||||
payment_option_id: str | None = Form(None),
|
payment_option_id: str | None = Form(None),
|
||||||
position_wish_ids: str | None = Form(None),
|
position_wish_ids: str | None = Form(None),
|
||||||
artist_wish_ids: str | None = Form(None),
|
artist_wish_ids: str | None = Form(None),
|
||||||
@@ -424,6 +514,7 @@ async def submit_public_form(
|
|||||||
# Filet de sécurité global (par édition) : hCaptcha est la défense
|
# Filet de sécurité global (par édition) : hCaptcha est la défense
|
||||||
# principale, voir `app/auth/rate_limit.py`.
|
# principale, voir `app/auth/rate_limit.py`.
|
||||||
form_submission_limiter.hit(str(edition.id))
|
form_submission_limiter.hit(str(edition.id))
|
||||||
|
_prune_stale_pending(session, edition.id)
|
||||||
|
|
||||||
template = _get_or_create_template(session, edition.id)
|
template = _get_or_create_template(session, edition.id)
|
||||||
if not template.is_open:
|
if not template.is_open:
|
||||||
@@ -443,6 +534,26 @@ async def submit_public_form(
|
|||||||
|
|
||||||
fields = set(_enabled_fields(session, edition.id))
|
fields = set(_enabled_fields(session, edition.id))
|
||||||
|
|
||||||
|
# Tout champ activé par la coordination est obligatoire — le formulaire
|
||||||
|
# public n'est plus une simple collecte d'infos facultatives.
|
||||||
|
email = (email or "").strip() or None
|
||||||
|
phone = (phone or "").strip() or None
|
||||||
|
comment = (comment or "").strip() or None
|
||||||
|
companion_wish_note = (companion_wish_note or "").strip() or None
|
||||||
|
for active, field_name, ok in (
|
||||||
|
(FormFieldType.EMAIL in fields, "email", bool(email)),
|
||||||
|
(FormFieldType.PHONE in fields, "phone", bool(phone)),
|
||||||
|
(FormFieldType.AVAILABLE in fields, "available", available),
|
||||||
|
):
|
||||||
|
if active and not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"code": "import.row.missing_required_field",
|
||||||
|
"params": {"field": field_name},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
position_ids = (
|
position_ids = (
|
||||||
_parse_id_list(position_wish_ids) if FormFieldType.POSITION_WISHES in fields else []
|
_parse_id_list(position_wish_ids) if FormFieldType.POSITION_WISHES in fields else []
|
||||||
)
|
)
|
||||||
@@ -450,12 +561,15 @@ async def submit_public_form(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422, detail={"code": "form.too_many_position_wishes"}
|
status_code=422, detail={"code": "form.too_many_position_wishes"}
|
||||||
)
|
)
|
||||||
|
if position_ids:
|
||||||
|
resolve_owned_ids(session, Position, edition.id, position_ids, "position.unknown")
|
||||||
|
|
||||||
artist_ids = (
|
artist_ids = (
|
||||||
_parse_id_list(artist_wish_ids) if FormFieldType.ARTIST_WISHES in fields else []
|
_parse_id_list(artist_wish_ids) if FormFieldType.ARTIST_WISHES in fields else []
|
||||||
)
|
)
|
||||||
if artist_ids:
|
if artist_ids:
|
||||||
_check_one_artist_wish_per_day(session, edition.id, artist_ids)
|
_check_one_artist_wish_per_day(session, edition.id, artist_ids)
|
||||||
|
resolve_owned_ids(session, Artist, edition.id, artist_ids, "artist.unknown")
|
||||||
|
|
||||||
payment_option: FormPaymentOption | None = None
|
payment_option: FormPaymentOption | None = None
|
||||||
if FormFieldType.WILLING_TO_PAY_FEE in fields and payment_option_id:
|
if FormFieldType.WILLING_TO_PAY_FEE in fields and payment_option_id:
|
||||||
@@ -471,109 +585,339 @@ async def submit_public_form(
|
|||||||
status_code=422, detail={"code": "form.unknown_payment_option"}
|
status_code=422, detail={"code": "form.unknown_payment_option"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
photo_data = await _read_photo(photo) if FormFieldType.PHOTO in fields else None
|
||||||
|
|
||||||
|
if payment_option is None:
|
||||||
|
# Pas de paiement en ligne (case désactivée, ou « je paierai sur
|
||||||
|
# place ») : rien à attendre, le bénévole est créé tout de suite.
|
||||||
volunteer = Volunteer(
|
volunteer = Volunteer(
|
||||||
edition_id=edition.id,
|
edition_id=edition.id,
|
||||||
first_name=first_name.strip().title(),
|
first_name=first_name.strip().title(),
|
||||||
last_name=last_name.strip().title(),
|
last_name=last_name.strip().title(),
|
||||||
email=((email or "").strip() or None) if FormFieldType.EMAIL in fields else None,
|
email=email if FormFieldType.EMAIL in fields else None,
|
||||||
phone=((phone or "").strip() or None) if FormFieldType.PHONE in fields else None,
|
phone=phone if FormFieldType.PHONE in fields else None,
|
||||||
comment=(
|
comment=comment if FormFieldType.COMMENT in fields else None,
|
||||||
(comment or "").strip() or None if FormFieldType.COMMENT in fields else None
|
|
||||||
),
|
|
||||||
companion_wish_note=(
|
companion_wish_note=(
|
||||||
(companion_wish_note or "").strip() or None
|
companion_wish_note if FormFieldType.COMPANION_WISH in fields else None
|
||||||
if FormFieldType.COMPANION_WISH in fields
|
|
||||||
else None
|
|
||||||
),
|
),
|
||||||
self_declared_available=(
|
self_declared_available=(
|
||||||
available if FormFieldType.AVAILABLE in fields else False
|
available if FormFieldType.AVAILABLE in fields else False
|
||||||
),
|
),
|
||||||
# Choisir un type de paiement vaut « je participe » même sans cocher
|
willing_to_pay_fee=FormFieldType.WILLING_TO_PAY_FEE in fields,
|
||||||
# la case (le formulaire public n'affiche d'ailleurs plus qu'un choix
|
|
||||||
# unique dès qu'au moins un type est configuré, voir PublicSignupPage).
|
|
||||||
willing_to_pay_fee=(
|
|
||||||
(willing_to_pay_fee or payment_option is not None)
|
|
||||||
if FormFieldType.WILLING_TO_PAY_FEE in fields
|
|
||||||
else False
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
if photo_data:
|
||||||
if FormFieldType.PHOTO in fields and photo is not None and photo.filename:
|
volunteer.photo, volunteer.photo_content_type = photo_data
|
||||||
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.add(volunteer)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
if FormFieldType.POSITION_WISHES in fields:
|
if FormFieldType.POSITION_WISHES in fields:
|
||||||
set_wished_positions(session, volunteer, position_ids)
|
set_wished_positions(session, volunteer, position_ids)
|
||||||
if FormFieldType.ARTIST_WISHES in fields:
|
if FormFieldType.ARTIST_WISHES in fields:
|
||||||
set_artists(session, volunteer, artist_ids)
|
set_artists(session, volunteer, artist_ids)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(volunteer)
|
||||||
|
if volunteer.email:
|
||||||
|
subject, body = render_registration_confirmation(first_name=volunteer.first_name)
|
||||||
|
background.add_task(
|
||||||
|
send,
|
||||||
|
volunteer.email,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
logo_url=edition_logo_url(session, edition.id),
|
||||||
|
)
|
||||||
|
return FormSubmissionResult(volunteer_id=volunteer.id)
|
||||||
|
|
||||||
|
# Paiement en ligne choisi : rien n'est compté en base tant que le
|
||||||
|
# paiement n'est pas confirmé — voir `PendingFormSubmission` et
|
||||||
|
# `_promote_pending`.
|
||||||
|
pending = PendingFormSubmission(
|
||||||
|
edition_id=edition.id,
|
||||||
|
first_name=first_name.strip().title(),
|
||||||
|
last_name=last_name.strip().title(),
|
||||||
|
email=email if FormFieldType.EMAIL in fields else None,
|
||||||
|
phone=phone if FormFieldType.PHONE in fields else None,
|
||||||
|
comment=comment if FormFieldType.COMMENT in fields else None,
|
||||||
|
companion_wish_note=(
|
||||||
|
companion_wish_note if FormFieldType.COMPANION_WISH in fields else None
|
||||||
|
),
|
||||||
|
available=available if FormFieldType.AVAILABLE in fields else False,
|
||||||
|
position_wish_ids_json=(
|
||||||
|
json.dumps([str(i) for i in position_ids]) if position_ids else None
|
||||||
|
),
|
||||||
|
artist_wish_ids_json=(
|
||||||
|
json.dumps([str(i) for i in artist_ids]) if artist_ids else None
|
||||||
|
),
|
||||||
|
payment_label=payment_option.label,
|
||||||
|
payment_amount=payment_option.amount,
|
||||||
|
requires_mecenat_info=payment_option.requires_mecenat_info,
|
||||||
|
)
|
||||||
|
if photo_data:
|
||||||
|
pending.photo, pending.photo_content_type = photo_data
|
||||||
|
session.add(pending)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(pending)
|
||||||
|
|
||||||
|
if pending.requires_mecenat_info:
|
||||||
|
return FormSubmissionResult(pending_token=pending.id, requires_mecenat_info=True)
|
||||||
|
|
||||||
|
checkout_url = _create_pending_checkout(session, edition, template, pending)
|
||||||
|
return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_pending_checkout(
|
||||||
|
session: Session, edition: Edition, template: FormTemplate, pending: PendingFormSubmission
|
||||||
|
) -> str | None:
|
||||||
|
"""Crée (ou recrée, voir `retry_pending_payment`) le checkout SumUp d'une
|
||||||
|
inscription en attente — best effort (comme l'envoi d'emails) : un échec
|
||||||
|
de l'appel API laisse l'inscription en attente, jamais perdue (la page de
|
||||||
|
confirmation propose alors de réessayer). `checkout_reference` porte un
|
||||||
|
suffixe aléatoire à chaque appel : une relance après échec/expiration doit
|
||||||
|
ouvrir un nouveau checkout SumUp, jamais réutiliser une référence déjà
|
||||||
|
consommée (409 côté SumUp sinon)."""
|
||||||
|
base = get_settings().public_base_url.rstrip("/")
|
||||||
|
checkout = create_checkout(
|
||||||
|
api_key=template.sumup_api_key,
|
||||||
|
merchant_code=template.sumup_merchant_code,
|
||||||
|
amount=pending.payment_amount,
|
||||||
|
currency="EUR",
|
||||||
|
checkout_reference=f"{pending.id}-{uuid.uuid4().hex[:8]}",
|
||||||
|
description=f"{pending.payment_label} — édition {edition.year}",
|
||||||
|
return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook",
|
||||||
|
redirect_url=f"{base}/inscription?paiement={pending.id}",
|
||||||
|
)
|
||||||
|
if checkout is None:
|
||||||
|
return None
|
||||||
|
pending.checkout_id = checkout.id
|
||||||
|
pending.checkout_status = FeePaymentStatus.PENDING
|
||||||
|
session.add(pending)
|
||||||
|
session.commit()
|
||||||
|
return checkout.hosted_checkout_url
|
||||||
|
|
||||||
|
|
||||||
|
def _promote_pending(
|
||||||
|
session: Session,
|
||||||
|
edition: Edition,
|
||||||
|
pending: PendingFormSubmission,
|
||||||
|
checkout: SumUpCheckout,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
) -> Volunteer:
|
||||||
|
"""Paiement confirmé : crée enfin le `Volunteer` (voir le docstring de
|
||||||
|
`PendingFormSubmission`), résout les vœux en filtrant silencieusement les
|
||||||
|
ids devenus invalides (`_filter_owned_ids` — le paiement a déjà eu lieu,
|
||||||
|
rien ne doit plus faire échouer la création), puis vide les champs
|
||||||
|
nominatifs de la ligne en attente (RGPD : cette information ne doit plus
|
||||||
|
vivre qu'à un seul endroit)."""
|
||||||
|
volunteer = Volunteer(
|
||||||
|
edition_id=edition.id,
|
||||||
|
first_name=pending.first_name or "",
|
||||||
|
last_name=pending.last_name or "",
|
||||||
|
email=pending.email,
|
||||||
|
phone=pending.phone,
|
||||||
|
comment=pending.comment,
|
||||||
|
companion_wish_note=pending.companion_wish_note,
|
||||||
|
self_declared_available=pending.available,
|
||||||
|
willing_to_pay_fee=True,
|
||||||
|
fee_payment_label=pending.payment_label,
|
||||||
|
fee_amount=pending.payment_amount,
|
||||||
|
fee_checkout_id=checkout.id,
|
||||||
|
fee_payment_status=FeePaymentStatus.PAID,
|
||||||
|
fee_paid_at=local_now(),
|
||||||
|
mecenat_address=pending.mecenat_address,
|
||||||
|
mecenat_city=pending.mecenat_city,
|
||||||
|
mecenat_postal_code=pending.mecenat_postal_code,
|
||||||
|
mecenat_country=pending.mecenat_country,
|
||||||
|
photo=pending.photo,
|
||||||
|
photo_content_type=pending.photo_content_type,
|
||||||
|
)
|
||||||
|
session.add(volunteer)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
position_ids = _filter_owned_ids(
|
||||||
|
session, Position, edition.id, _parse_id_list(pending.position_wish_ids_json)
|
||||||
|
)
|
||||||
|
if position_ids:
|
||||||
|
set_wished_positions(session, volunteer, position_ids)
|
||||||
|
artist_ids = _filter_owned_ids(
|
||||||
|
session, Artist, edition.id, _parse_id_list(pending.artist_wish_ids_json)
|
||||||
|
)
|
||||||
|
if artist_ids:
|
||||||
|
set_artists(session, volunteer, artist_ids)
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(volunteer)
|
session.refresh(volunteer)
|
||||||
|
|
||||||
checkout_url = None
|
pending.promoted_volunteer_id = volunteer.id
|
||||||
if (
|
pending.checkout_status = FeePaymentStatus.PAID
|
||||||
payment_option is not None
|
pending.first_name = None
|
||||||
and template.sumup_merchant_code
|
pending.last_name = None
|
||||||
and template.sumup_api_key
|
pending.email = None
|
||||||
):
|
pending.phone = None
|
||||||
checkout_url = _create_fee_checkout(
|
pending.comment = None
|
||||||
session, edition, template, volunteer, payment_option
|
pending.companion_wish_note = None
|
||||||
|
pending.photo = None
|
||||||
|
pending.photo_content_type = None
|
||||||
|
pending.mecenat_address = None
|
||||||
|
pending.mecenat_city = None
|
||||||
|
pending.mecenat_postal_code = None
|
||||||
|
pending.mecenat_country = None
|
||||||
|
session.add(pending)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
if volunteer.email:
|
||||||
|
subject, body = render_registration_paid_confirmation(
|
||||||
|
first_name=volunteer.first_name,
|
||||||
|
label=volunteer.fee_payment_label or "",
|
||||||
|
amount=volunteer.fee_amount or 0,
|
||||||
)
|
)
|
||||||
|
background.add_task(
|
||||||
return FormSubmissionResult(volunteer_id=volunteer.id, sumup_checkout_url=checkout_url)
|
send,
|
||||||
|
volunteer.email,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
logo_url=edition_logo_url(session, edition.id),
|
||||||
|
)
|
||||||
|
return volunteer
|
||||||
|
|
||||||
|
|
||||||
def _create_fee_checkout(
|
def _apply_pending_checkout_result(
|
||||||
session: Session,
|
session: Session,
|
||||||
edition: Edition,
|
edition: Edition,
|
||||||
template: FormTemplate,
|
pending: PendingFormSubmission,
|
||||||
volunteer: Volunteer,
|
checkout: SumUpCheckout,
|
||||||
option: FormPaymentOption,
|
background: BackgroundTasks,
|
||||||
) -> str | None:
|
) -> None:
|
||||||
"""Crée le checkout SumUp du type de paiement choisi — best effort (comme
|
"""Met à jour une inscription en attente à partir d'un état de checkout
|
||||||
l'envoi d'emails) : un échec de l'appel API ne remet jamais en cause
|
fraîchement relu chez SumUp — partagé par le webhook et la vérification
|
||||||
l'inscription déjà enregistrée. `option.label`/`option.amount` sont
|
en direct (`get_pending_status`)."""
|
||||||
recopiés sur le bénévole (`fee_payment_label`/`fee_amount`) avant même
|
if pending.promoted_volunteer_id is not None:
|
||||||
l'appel, pour garder une trace du choix même si l'appel échoue."""
|
return
|
||||||
volunteer.fee_payment_label = option.label
|
try:
|
||||||
volunteer.fee_amount = option.amount
|
new_status = FeePaymentStatus(checkout.status)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
if new_status == FeePaymentStatus.PAID:
|
||||||
|
_promote_pending(session, edition, pending, checkout, background)
|
||||||
|
else:
|
||||||
|
pending.checkout_status = new_status
|
||||||
|
session.add(pending)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
base = get_settings().public_base_url.rstrip("/")
|
|
||||||
checkout = create_checkout(
|
@router.post(
|
||||||
api_key=template.sumup_api_key,
|
"/public/pending/{token}/mecenat-info",
|
||||||
merchant_code=template.sumup_merchant_code,
|
response_model=FormSubmissionResult,
|
||||||
amount=option.amount,
|
)
|
||||||
currency="EUR",
|
def submit_mecenat_info(
|
||||||
checkout_reference=str(volunteer.id),
|
token: uuid.UUID,
|
||||||
description=f"{option.label} — édition {edition.year}",
|
payload: MecenatInfoSubmit,
|
||||||
return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook",
|
edition: Edition = Depends(get_edition),
|
||||||
redirect_url=f"{base}/inscription?paiement=ok",
|
session: Session = Depends(get_session),
|
||||||
|
) -> FormSubmissionResult:
|
||||||
|
"""Étape d'adresse postale d'un type de paiement « mécénat », entre la
|
||||||
|
soumission du formulaire principal et le paiement lui-même — crée le
|
||||||
|
checkout SumUp une fois ces coordonnées enregistrées."""
|
||||||
|
form_submission_limiter.hit(str(edition.id))
|
||||||
|
pending = _get_pending_or_404(session, edition.id, token)
|
||||||
|
if pending.promoted_volunteer_id is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail={"code": "form.pending_already_paid"}
|
||||||
|
)
|
||||||
|
if not pending.requires_mecenat_info:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail={"code": "form.pending_not_awaiting_info"}
|
||||||
|
)
|
||||||
|
|
||||||
|
address = payload.address.strip()
|
||||||
|
city = payload.city.strip()
|
||||||
|
postal_code = payload.postal_code.strip()
|
||||||
|
country = payload.country.strip()
|
||||||
|
if not (address and city and postal_code and country):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"code": "import.row.missing_required_field",
|
||||||
|
"params": {"field": "address"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
pending.mecenat_address = address
|
||||||
|
pending.mecenat_city = city
|
||||||
|
pending.mecenat_postal_code = postal_code
|
||||||
|
pending.mecenat_country = country
|
||||||
|
session.add(pending)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
template = _get_or_create_template(session, edition.id)
|
||||||
|
checkout_url = _create_pending_checkout(session, edition, template, pending)
|
||||||
|
return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/public/pending/{token}", response_model=PendingSubmissionStatus)
|
||||||
|
def get_pending_status(
|
||||||
|
token: uuid.UUID,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> PendingSubmissionStatus:
|
||||||
|
"""Interrogé en direct par la page de confirmation (voir
|
||||||
|
`PublicSignupPage.tsx`) jusqu'à ce que le statut ne soit plus `PENDING` —
|
||||||
|
relit toujours l'état réel auprès de SumUp à chaque appel, jamais
|
||||||
|
seulement la dernière valeur connue en base."""
|
||||||
|
pending = _get_pending_or_404(session, edition.id, token)
|
||||||
|
if pending.promoted_volunteer_id is None and pending.checkout_id:
|
||||||
|
template = _get_or_create_template(session, edition.id)
|
||||||
|
if template.sumup_api_key:
|
||||||
|
checkout = get_checkout(
|
||||||
|
api_key=template.sumup_api_key, checkout_id=pending.checkout_id
|
||||||
)
|
)
|
||||||
if checkout is not None:
|
if checkout is not None:
|
||||||
volunteer.fee_checkout_id = checkout.id
|
_apply_pending_checkout_result(session, edition, pending, checkout, background)
|
||||||
volunteer.fee_payment_status = FeePaymentStatus.PENDING
|
session.refresh(pending)
|
||||||
session.add(volunteer)
|
|
||||||
session.commit()
|
if pending.promoted_volunteer_id is not None:
|
||||||
return checkout.hosted_checkout_url if checkout is not None else None
|
return PendingSubmissionStatus(
|
||||||
|
status=FeePaymentStatus.PAID,
|
||||||
|
label=pending.payment_label,
|
||||||
|
amount=pending.payment_amount,
|
||||||
|
volunteer_id=pending.promoted_volunteer_id,
|
||||||
|
)
|
||||||
|
return PendingSubmissionStatus(
|
||||||
|
status=pending.checkout_status or FeePaymentStatus.PENDING,
|
||||||
|
label=pending.payment_label,
|
||||||
|
amount=pending.payment_amount,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/public/pending/{token}/retry-payment",
|
||||||
|
response_model=FormSubmissionResult,
|
||||||
|
)
|
||||||
|
def retry_pending_payment(
|
||||||
|
token: uuid.UUID,
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> FormSubmissionResult:
|
||||||
|
"""Relance un paiement après échec/expiration, sans faire ressaisir le
|
||||||
|
formulaire — un nouveau checkout SumUp est créé pour la même inscription
|
||||||
|
en attente (voir `_create_pending_checkout`)."""
|
||||||
|
form_submission_limiter.hit(str(edition.id))
|
||||||
|
pending = _get_pending_or_404(session, edition.id, token)
|
||||||
|
if pending.promoted_volunteer_id is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail={"code": "form.pending_already_paid"}
|
||||||
|
)
|
||||||
|
if pending.requires_mecenat_info and pending.mecenat_address is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail={"code": "form.pending_not_awaiting_info"}
|
||||||
|
)
|
||||||
|
|
||||||
|
template = _get_or_create_template(session, edition.id)
|
||||||
|
checkout_url = _create_pending_checkout(session, edition, template, pending)
|
||||||
|
return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/public/sumup-webhook", status_code=status.HTTP_200_OK)
|
@router.post("/public/sumup-webhook", status_code=status.HTTP_200_OK)
|
||||||
def sumup_webhook(
|
def sumup_webhook(
|
||||||
payload: SumUpWebhookPayload,
|
payload: SumUpWebhookPayload,
|
||||||
|
background: BackgroundTasks,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -583,29 +927,40 @@ def sumup_webhook(
|
|||||||
(voir `app/sumup/client.py:get_checkout`). Répond toujours `200`, y
|
(voir `app/sumup/client.py:get_checkout`). Répond toujours `200`, y
|
||||||
compris sur un événement inconnu — SumUp réessaie sinon inutilement."""
|
compris sur un événement inconnu — SumUp réessaie sinon inutilement."""
|
||||||
template = _get_or_create_template(session, edition.id)
|
template = _get_or_create_template(session, edition.id)
|
||||||
if template.sumup_api_key:
|
if not template.sumup_api_key:
|
||||||
|
return Response(status_code=status.HTTP_200_OK)
|
||||||
|
|
||||||
checkout = get_checkout(api_key=template.sumup_api_key, checkout_id=payload.id)
|
checkout = get_checkout(api_key=template.sumup_api_key, checkout_id=payload.id)
|
||||||
volunteer = (
|
if checkout is None:
|
||||||
session.exec(
|
logger.warning("Webhook SumUp : relecture du checkout impossible.")
|
||||||
select(Volunteer).where(
|
return Response(status_code=status.HTTP_200_OK)
|
||||||
Volunteer.edition_id == edition.id,
|
|
||||||
Volunteer.fee_checkout_id == payload.id,
|
pending = session.exec(
|
||||||
|
select(PendingFormSubmission).where(
|
||||||
|
PendingFormSubmission.edition_id == edition.id,
|
||||||
|
PendingFormSubmission.checkout_id == payload.id,
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
if checkout is not None
|
if pending is not None:
|
||||||
else None
|
_apply_pending_checkout_result(session, edition, pending, checkout, background)
|
||||||
|
return Response(status_code=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
# Filet de sécurité pour d'anciennes inscriptions créées avant le passage
|
||||||
|
# à l'inscription en deux temps (voir CLAUDE.md § 2, Paiement de la
|
||||||
|
# cotisation) : un `Volunteer` directement lié à un checkout.
|
||||||
|
volunteer = session.exec(
|
||||||
|
select(Volunteer).where(
|
||||||
|
Volunteer.edition_id == edition.id, Volunteer.fee_checkout_id == payload.id
|
||||||
)
|
)
|
||||||
if checkout is not None and volunteer is not None:
|
).first()
|
||||||
|
if volunteer is not None:
|
||||||
try:
|
try:
|
||||||
new_status = FeePaymentStatus(checkout.status)
|
new_status = FeePaymentStatus(checkout.status)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
new_status = None
|
return Response(status_code=status.HTTP_200_OK)
|
||||||
if new_status is not None:
|
|
||||||
volunteer.fee_payment_status = new_status
|
volunteer.fee_payment_status = new_status
|
||||||
if new_status == FeePaymentStatus.PAID and volunteer.fee_paid_at is None:
|
if new_status == FeePaymentStatus.PAID and volunteer.fee_paid_at is None:
|
||||||
volunteer.fee_paid_at = local_now()
|
volunteer.fee_paid_at = local_now()
|
||||||
session.add(volunteer)
|
session.add(volunteer)
|
||||||
session.commit()
|
session.commit()
|
||||||
elif checkout is None:
|
|
||||||
logger.warning("Webhook SumUp : relecture du checkout impossible.")
|
|
||||||
return Response(status_code=status.HTTP_200_OK)
|
return Response(status_code=status.HTTP_200_OK)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from app.api.schemas import (
|
|||||||
from app.auth.deps import require_coordinator
|
from app.auth.deps import require_coordinator
|
||||||
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
|
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.email import render_planning, send_many
|
from app.email import edition_logo_url, render_planning, send_many
|
||||||
from app.i18n import t
|
from app.i18n import t
|
||||||
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
|
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ def send_planning_notification(
|
|||||||
recipients.append(volunteer.id)
|
recipients.append(volunteer.id)
|
||||||
|
|
||||||
session.commit() # persiste les éventuels MagicLinkToken émis
|
session.commit() # persiste les éventuels MagicLinkToken émis
|
||||||
background.add_task(send_many, items)
|
background.add_task(send_many, items, logo_url=edition_logo_url(session, edition.id))
|
||||||
return PlanningNotificationResult(
|
return PlanningNotificationResult(
|
||||||
queued=len(items),
|
queued=len(items),
|
||||||
skipped_no_email=skipped_no_email,
|
skipped_no_email=skipped_no_email,
|
||||||
|
|||||||
@@ -102,6 +102,12 @@ class VolunteerRead(ApiModel):
|
|||||||
fee_amount: float | None
|
fee_amount: float | None
|
||||||
fee_payment_status: FeePaymentStatus | None
|
fee_payment_status: FeePaymentStatus | None
|
||||||
fee_paid_at: dt.datetime | None
|
fee_paid_at: dt.datetime | None
|
||||||
|
# Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un
|
||||||
|
# type de paiement « mécénat » (voir `FormPaymentOption.requires_mecenat_info`).
|
||||||
|
mecenat_address: str | None
|
||||||
|
mecenat_city: str | None
|
||||||
|
mecenat_postal_code: str | None
|
||||||
|
mecenat_country: str | None
|
||||||
created_at: dt.datetime
|
created_at: dt.datetime
|
||||||
updated_at: dt.datetime
|
updated_at: dt.datetime
|
||||||
|
|
||||||
@@ -526,6 +532,7 @@ class FormPaymentOptionRead(ApiModel):
|
|||||||
label: str
|
label: str
|
||||||
amount: float
|
amount: float
|
||||||
order: int
|
order: int
|
||||||
|
requires_mecenat_info: bool
|
||||||
created_at: dt.datetime
|
created_at: dt.datetime
|
||||||
updated_at: dt.datetime
|
updated_at: dt.datetime
|
||||||
|
|
||||||
@@ -534,12 +541,14 @@ class FormPaymentOptionCreate(ApiModel):
|
|||||||
label: str
|
label: str
|
||||||
amount: float
|
amount: float
|
||||||
order: int = 0
|
order: int = 0
|
||||||
|
requires_mecenat_info: bool = False
|
||||||
|
|
||||||
|
|
||||||
class FormPaymentOptionUpdate(ApiModel):
|
class FormPaymentOptionUpdate(ApiModel):
|
||||||
label: str | None = None
|
label: str | None = None
|
||||||
amount: float | None = None
|
amount: float | None = None
|
||||||
order: int | None = None
|
order: int | None = None
|
||||||
|
requires_mecenat_info: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class PublicPositionOption(ApiModel):
|
class PublicPositionOption(ApiModel):
|
||||||
@@ -560,6 +569,7 @@ class PublicPaymentOption(ApiModel):
|
|||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
label: str
|
label: str
|
||||||
amount: float
|
amount: float
|
||||||
|
requires_mecenat_info: bool
|
||||||
|
|
||||||
|
|
||||||
class PublicFormConfig(ApiModel):
|
class PublicFormConfig(ApiModel):
|
||||||
@@ -582,14 +592,46 @@ class PublicFormConfig(ApiModel):
|
|||||||
|
|
||||||
|
|
||||||
class FormSubmissionResult(ApiModel):
|
class FormSubmissionResult(ApiModel):
|
||||||
volunteer_id: uuid.UUID
|
"""Trois formes possibles, selon le choix de paiement (voir
|
||||||
# Lien de paiement hébergé SumUp, si un checkout a pu être créé (voir
|
`app/api/forms.py:submit_public_form`) :
|
||||||
# `app/api/forms.py:submit_public_form`) — `None` si le bénévole n'a
|
- aucun paiement en ligne choisi (case désactivée, ou « je paierai sur
|
||||||
# choisi aucun type de paiement, si SumUp n'est pas configuré, ou en cas
|
place ») : `volunteer_id` seul, le bénévole est déjà en base ;
|
||||||
# d'échec de l'appel API (best effort, l'inscription reste valide).
|
- paiement en ligne, type « mécénat » : `pending_token` +
|
||||||
|
`requires_mecenat_info=True`, rien en base encore — direction l'étape
|
||||||
|
d'adresse (`submit_mecenat_info`) ;
|
||||||
|
- paiement en ligne, type simple : `pending_token` + `sumup_checkout_url`,
|
||||||
|
direction la page de paiement SumUp. Toujours rien en base tant que le
|
||||||
|
paiement n'est pas confirmé (voir `PendingFormSubmission`)."""
|
||||||
|
|
||||||
|
volunteer_id: uuid.UUID | None = None
|
||||||
|
pending_token: uuid.UUID | None = None
|
||||||
|
requires_mecenat_info: bool = False
|
||||||
sumup_checkout_url: str | None = None
|
sumup_checkout_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MecenatInfoSubmit(ApiModel):
|
||||||
|
"""Étape d'adresse postale pour un type de paiement « mécénat »
|
||||||
|
(`POST .../form/public/pending/{token}/mecenat-info`) — tous obligatoires,
|
||||||
|
nécessaires à l'édition d'un reçu fiscal (cerfa)."""
|
||||||
|
|
||||||
|
address: str
|
||||||
|
city: str
|
||||||
|
postal_code: str
|
||||||
|
country: str
|
||||||
|
|
||||||
|
|
||||||
|
class PendingSubmissionStatus(ApiModel):
|
||||||
|
"""Réponse de `GET .../form/public/pending/{token}` — interrogée en
|
||||||
|
direct par la page de confirmation jusqu'à ce que le statut ne soit plus
|
||||||
|
`PENDING`. Relit toujours l'état réel auprès de SumUp (jamais seulement
|
||||||
|
la dernière valeur connue), comme le webhook."""
|
||||||
|
|
||||||
|
status: FeePaymentStatus
|
||||||
|
label: str
|
||||||
|
amount: float
|
||||||
|
volunteer_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class SumUpWebhookPayload(ApiModel):
|
class SumUpWebhookPayload(ApiModel):
|
||||||
"""Corps envoyé par SumUp à `return_url` — voir la doc SumUp sur les
|
"""Corps envoyé par SumUp à `return_url` — voir la doc SumUp sur les
|
||||||
webhooks. Le seul événement existant à ce jour."""
|
webhooks. Le seul événement existant à ce jour."""
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from app.auth.deps import (
|
|||||||
require_staff,
|
require_staff,
|
||||||
)
|
)
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
|
from app.exporting import generate_volunteers_xlsx
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Account,
|
Account,
|
||||||
Artist,
|
Artist,
|
||||||
@@ -169,6 +170,56 @@ def list_volunteers(
|
|||||||
return [_read(session, v) for v in volunteers]
|
return [_read(session, v) for v in volunteers]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export")
|
||||||
|
def export_volunteers(
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_coordinator),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> Response:
|
||||||
|
"""Export Excel de la liste des bénévoles — vœux (postes, artistes,
|
||||||
|
compagnons) résolus en noms lisibles, pas en UUID. Déclaré avant
|
||||||
|
`/{volunteer_id}` : un chemin statique doit être enregistré avant le
|
||||||
|
chemin dynamique qui le suivrait sinon FastAPI essaierait de parser
|
||||||
|
« export » comme un UUID."""
|
||||||
|
volunteers_orm = list(
|
||||||
|
session.exec(
|
||||||
|
select(Volunteer)
|
||||||
|
.where(Volunteer.edition_id == edition.id)
|
||||||
|
.order_by(Volunteer.last_name, Volunteer.first_name)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
volunteers = [_read(session, v) for v in volunteers_orm]
|
||||||
|
position_names = {
|
||||||
|
p.id: p.name
|
||||||
|
for p in session.exec(
|
||||||
|
select(Position).where(Position.edition_id == edition.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
artist_names = {
|
||||||
|
a.id: a.name
|
||||||
|
for a in session.exec(select(Artist).where(Artist.edition_id == edition.id))
|
||||||
|
}
|
||||||
|
volunteer_names = {
|
||||||
|
v.id: f"{v.first_name} {v.last_name}" for v in volunteers_orm
|
||||||
|
}
|
||||||
|
|
||||||
|
content = generate_volunteers_xlsx(
|
||||||
|
volunteers,
|
||||||
|
position_names=position_names,
|
||||||
|
artist_names=artist_names,
|
||||||
|
volunteer_names=volunteer_names,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type=(
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
),
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="benevoles-{edition.year}.xlsx"'
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{volunteer_id}", response_model=VolunteerRead)
|
@router.get("/{volunteer_id}", response_model=VolunteerRead)
|
||||||
def get_volunteer(
|
def get_volunteer(
|
||||||
volunteer_id: uuid.UUID,
|
volunteer_id: uuid.UUID,
|
||||||
|
|||||||
@@ -4,7 +4,21 @@
|
|||||||
(sans destinataire ni corps — RGPD). Voir CLAUDE.md.
|
(sans destinataire ni corps — RGPD). Voir CLAUDE.md.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from app.email.branding import edition_logo_url
|
||||||
from app.email.sender import send, send_many
|
from app.email.sender import send, send_many
|
||||||
from app.email.templates import render_magic_link, render_planning
|
from app.email.templates import (
|
||||||
|
render_magic_link,
|
||||||
|
render_planning,
|
||||||
|
render_registration_confirmation,
|
||||||
|
render_registration_paid_confirmation,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["render_magic_link", "render_planning", "send", "send_many"]
|
__all__ = [
|
||||||
|
"edition_logo_url",
|
||||||
|
"render_magic_link",
|
||||||
|
"render_planning",
|
||||||
|
"render_registration_confirmation",
|
||||||
|
"render_registration_paid_confirmation",
|
||||||
|
"send",
|
||||||
|
"send_many",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""URL publique du logo d'une édition, pour l'en-tête des emails (voir
|
||||||
|
`app/email/html.py`) — `None` tant qu'aucun logo n'a été importé sur le
|
||||||
|
formulaire public (`FormTemplate.logo`, `app/api/forms.py:set_logo`)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models import FormTemplate
|
||||||
|
|
||||||
|
|
||||||
|
def edition_logo_url(session: Session, edition_id: uuid.UUID) -> str | None:
|
||||||
|
template = session.exec(
|
||||||
|
select(FormTemplate).where(FormTemplate.edition_id == edition_id)
|
||||||
|
).first()
|
||||||
|
if template is None or template.logo is None:
|
||||||
|
return None
|
||||||
|
base = get_settings().public_base_url.rstrip("/")
|
||||||
|
return f"{base}/api/editions/{edition_id}/form/logo"
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Version HTML des emails — même thème que l'appli (couleurs/typographie de
|
||||||
|
`frontend/tailwind.config.js`), logo de l'édition en en-tête si défini.
|
||||||
|
|
||||||
|
Chaque email reste envoyé en `multipart/alternative` (texte + HTML, voir
|
||||||
|
`app/email/sender.py:build_message`) : le texte brut reste la source de
|
||||||
|
vérité (accessibilité, clients sans HTML, RGPD — rien de plus dans le HTML
|
||||||
|
que dans le texte déjà journalisable). Pas de CSS externe ni de police web
|
||||||
|
(rarement chargées par les clients mail) : mise en page par tableau, styles
|
||||||
|
en ligne, polices système en repli du style « Archivo »/« DM Mono »."""
|
||||||
|
|
||||||
|
import html as _html
|
||||||
|
|
||||||
|
_INK = "#14110E"
|
||||||
|
_CREAM = "#F5EFE1"
|
||||||
|
_PAPER = "#FFFDF7"
|
||||||
|
_YELLOW = "#FFD23F"
|
||||||
|
|
||||||
|
_FONT_DISPLAY = "Arial, Helvetica, sans-serif"
|
||||||
|
_FONT_MONO = "'Courier New', Courier, monospace"
|
||||||
|
|
||||||
|
|
||||||
|
def _paragraphs(body: str) -> str:
|
||||||
|
"""`\n\n` sépare les paragraphes, un simple `\n` reste un retour à la
|
||||||
|
ligne — même convention que les modèles texte (`app/email/templates.py`)."""
|
||||||
|
blocks = body.strip().split("\n\n")
|
||||||
|
return "".join(
|
||||||
|
f'<p style="margin:0 0 14px;">{_html.escape(block).replace(chr(10), "<br>")}</p>'
|
||||||
|
for block in blocks
|
||||||
|
if block.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_html(*, subject: str, body: str, logo_url: str | None) -> str:
|
||||||
|
"""`logo_url` : voir `app/email/branding.py:edition_logo_url` — `None`
|
||||||
|
tant que l'édition n'a pas importé de logo, l'en-tête retombe alors sur
|
||||||
|
un simple bandeau texte (le sujet, en capitales)."""
|
||||||
|
if logo_url:
|
||||||
|
header = (
|
||||||
|
f'<img src="{_html.escape(logo_url)}" alt="" width="160" '
|
||||||
|
'style="max-height:64px;width:auto;display:block;margin:0 auto;'
|
||||||
|
'border:0;">'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
header = (
|
||||||
|
f'<span style="font-family:{_FONT_DISPLAY};font-weight:800;'
|
||||||
|
f"text-transform:uppercase;letter-spacing:1px;color:{_YELLOW};"
|
||||||
|
f'font-size:14px;">{_html.escape(subject)}</span>'
|
||||||
|
)
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head><meta charset="utf-8"></head>
|
||||||
|
<body style="margin:0;padding:24px 16px;background:{_CREAM};">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="520" cellpadding="0" cellspacing="0"
|
||||||
|
style="max-width:520px;width:100%;">
|
||||||
|
<tr>
|
||||||
|
<td style="background:{_INK};padding:18px;text-align:center;
|
||||||
|
border:3px solid {_INK};">
|
||||||
|
{header}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="background:{_PAPER};border:3px solid {_INK};
|
||||||
|
border-top:none;padding:22px;font-size:13px;
|
||||||
|
line-height:1.6;color:{_INK};font-family:{_FONT_MONO};">
|
||||||
|
{_paragraphs(body)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
@@ -12,6 +12,7 @@ from collections.abc import Iterable
|
|||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
|
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
|
from app.email.html import render_html
|
||||||
|
|
||||||
logger = logging.getLogger("app.email")
|
logger = logging.getLogger("app.email")
|
||||||
|
|
||||||
@@ -32,12 +33,21 @@ def _smtp_error_summary(exc: smtplib.SMTPException) -> str:
|
|||||||
return exc.__class__.__name__
|
return exc.__class__.__name__
|
||||||
|
|
||||||
|
|
||||||
def build_message(to: str, subject: str, body: str) -> EmailMessage:
|
def build_message(
|
||||||
|
to: str, subject: str, body: str, *, logo_url: str | None = None
|
||||||
|
) -> EmailMessage:
|
||||||
|
"""`logo_url` (voir `app/email/branding.py:edition_logo_url`) : ajoute une
|
||||||
|
version HTML au thème de l'appli (`app/email/html.py`) en alternative au
|
||||||
|
texte brut — le texte reste la version de référence, envoyée telle
|
||||||
|
quelle si aucun client ne préfère le HTML."""
|
||||||
message = EmailMessage()
|
message = EmailMessage()
|
||||||
message["From"] = get_settings().smtp_from
|
message["From"] = get_settings().smtp_from
|
||||||
message["To"] = to
|
message["To"] = to
|
||||||
message["Subject"] = subject
|
message["Subject"] = subject
|
||||||
message.set_content(body)
|
message.set_content(body)
|
||||||
|
message.add_alternative(
|
||||||
|
render_html(subject=subject, body=body, logo_url=logo_url), subtype="html"
|
||||||
|
)
|
||||||
return message
|
return message
|
||||||
|
|
||||||
|
|
||||||
@@ -111,10 +121,13 @@ def deliver_many(messages: list[EmailMessage]) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def send(to: str, subject: str, body: str) -> None:
|
def send(to: str, subject: str, body: str, *, logo_url: str | None = None) -> None:
|
||||||
deliver_many([build_message(to, subject, body)])
|
deliver_many([build_message(to, subject, body, logo_url=logo_url)])
|
||||||
|
|
||||||
|
|
||||||
def send_many(items: Iterable[tuple[str, str, str]]) -> None:
|
def send_many(
|
||||||
"""`items` = itérable de `(destinataire, sujet, corps)`."""
|
items: Iterable[tuple[str, str, str]], *, logo_url: str | None = None
|
||||||
deliver_many([build_message(*item) for item in items])
|
) -> None:
|
||||||
|
"""`items` = itérable de `(destinataire, sujet, corps)` — même logo pour
|
||||||
|
tous (un seul appel par édition, voir `app/api/notifications.py`)."""
|
||||||
|
deliver_many([build_message(*item, logo_url=logo_url) for item in items])
|
||||||
|
|||||||
@@ -21,6 +21,35 @@ def render_magic_link(*, link: str, minutes: int) -> tuple[str, str]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_registration_confirmation(*, first_name: str) -> tuple[str, str]:
|
||||||
|
"""Inscription prise en compte, sans paiement en ligne (case désactivée,
|
||||||
|
ou « je paierai sur place ») — voir `render_registration_paid_confirmation`
|
||||||
|
pour le cas d'un paiement confirmé."""
|
||||||
|
return (
|
||||||
|
t("email.registration_confirmation.subject"),
|
||||||
|
t("email.registration_confirmation.body", prenom=first_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_registration_paid_confirmation(
|
||||||
|
*, first_name: str, label: str, amount: float
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Inscription prise en compte **et** paiement en ligne confirmé — envoyé
|
||||||
|
une fois seulement, au moment de la promotion (`app/api/forms.py:
|
||||||
|
_promote_pending`). Sert de reçu (montant, type de paiement) en
|
||||||
|
l'absence de confirmation documentée que l'API SumUp Hosted Checkout en
|
||||||
|
envoie un elle-même."""
|
||||||
|
return (
|
||||||
|
t("email.registration_confirmation_paid.subject"),
|
||||||
|
t(
|
||||||
|
"email.registration_confirmation_paid.body",
|
||||||
|
prenom=first_name,
|
||||||
|
label=label,
|
||||||
|
montant=amount,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _fill(template: str, values: dict[str, object]) -> str:
|
def _fill(template: str, values: dict[str, object]) -> str:
|
||||||
out = template
|
out = template
|
||||||
for key in _PLANNING_PLACEHOLDERS:
|
for key in _PLANNING_PLACEHOLDERS:
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.exporting.xlsx import generate_volunteers_xlsx
|
||||||
|
|
||||||
|
__all__ = ["generate_volunteers_xlsx"]
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Export Excel de la liste des bénévoles d'une édition — voir
|
||||||
|
`app/api/volunteers.py:export_volunteers`. Même bibliothèque que l'import
|
||||||
|
(`openpyxl`, `app/importing/parser.py`), en écriture cette fois.
|
||||||
|
|
||||||
|
Texte en dur (en-têtes de colonnes, libellés) : comme `app/sheets/pdf.py` et
|
||||||
|
`app/badges/pdf.py`, un document généré n'est pas un texte d'interface —
|
||||||
|
pas de `app.i18n.t()` ici, contrairement aux réponses de l'API."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font
|
||||||
|
|
||||||
|
from app.api.schemas import VolunteerRead
|
||||||
|
|
||||||
|
_HEADER = [
|
||||||
|
"Prénom",
|
||||||
|
"Nom",
|
||||||
|
"Email",
|
||||||
|
"Téléphone",
|
||||||
|
"Disponible",
|
||||||
|
"Postes souhaités",
|
||||||
|
"Artistes souhaités",
|
||||||
|
"Compagnons souhaités",
|
||||||
|
"Note de compagnon",
|
||||||
|
"Cotisation choisie",
|
||||||
|
"Montant (€)",
|
||||||
|
"Statut du paiement",
|
||||||
|
"Payée le",
|
||||||
|
"Mécénat — adresse",
|
||||||
|
"Mécénat — ville",
|
||||||
|
"Mécénat — code postal",
|
||||||
|
"Mécénat — pays",
|
||||||
|
"Commentaire",
|
||||||
|
"Inscrit le",
|
||||||
|
]
|
||||||
|
|
||||||
|
_FEE_STATUS_LABELS = {
|
||||||
|
"PENDING": "En attente",
|
||||||
|
"PAID": "Payée",
|
||||||
|
"FAILED": "Échouée",
|
||||||
|
"EXPIRED": "Expirée",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _yes_no(value: bool) -> str:
|
||||||
|
return "Oui" if value else "Non"
|
||||||
|
|
||||||
|
|
||||||
|
def _join_names(ids: list[uuid.UUID], names: dict[uuid.UUID, str]) -> str:
|
||||||
|
return ", ".join(names[i] for i in ids if i in names)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_dt(value) -> str:
|
||||||
|
return value.strftime("%d/%m/%Y %H:%M") if value else ""
|
||||||
|
|
||||||
|
|
||||||
|
def generate_volunteers_xlsx(
|
||||||
|
volunteers: list[VolunteerRead],
|
||||||
|
*,
|
||||||
|
position_names: dict[uuid.UUID, str],
|
||||||
|
artist_names: dict[uuid.UUID, str],
|
||||||
|
volunteer_names: dict[uuid.UUID, str],
|
||||||
|
) -> bytes:
|
||||||
|
"""`position_names`/`artist_names`/`volunteer_names` : cartes id → nom
|
||||||
|
construites une fois par l'appelant (pas de requête par bénévole) —
|
||||||
|
voir `app/api/volunteers.py:export_volunteers`."""
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.title = "Bénévoles"
|
||||||
|
sheet.append(_HEADER)
|
||||||
|
for cell in sheet[1]:
|
||||||
|
cell.font = Font(bold=True)
|
||||||
|
|
||||||
|
for v in volunteers:
|
||||||
|
sheet.append(
|
||||||
|
[
|
||||||
|
v.first_name,
|
||||||
|
v.last_name,
|
||||||
|
v.email or "",
|
||||||
|
v.phone or "",
|
||||||
|
_yes_no(v.self_declared_available),
|
||||||
|
_join_names(v.wished_position_ids, position_names),
|
||||||
|
_join_names(v.artist_ids, artist_names),
|
||||||
|
_join_names(v.wished_companion_ids, volunteer_names),
|
||||||
|
v.companion_wish_note or "",
|
||||||
|
v.fee_payment_label or "",
|
||||||
|
v.fee_amount if v.fee_amount is not None else "",
|
||||||
|
_FEE_STATUS_LABELS.get(v.fee_payment_status, ""),
|
||||||
|
_fmt_dt(v.fee_paid_at),
|
||||||
|
v.mecenat_address or "",
|
||||||
|
v.mecenat_city or "",
|
||||||
|
v.mecenat_postal_code or "",
|
||||||
|
v.mecenat_country or "",
|
||||||
|
v.comment or "",
|
||||||
|
_fmt_dt(v.created_at),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
for column_cells in sheet.columns:
|
||||||
|
length = max(
|
||||||
|
(len(str(c.value)) for c in column_cells if c.value is not None),
|
||||||
|
default=8,
|
||||||
|
)
|
||||||
|
sheet.column_dimensions[column_cells[0].column_letter].width = min(
|
||||||
|
max(length + 2, 10), 40
|
||||||
|
)
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
workbook.save(buffer)
|
||||||
|
return buffer.getvalue()
|
||||||
@@ -49,6 +49,10 @@
|
|||||||
"notification.no_recipients": "Aucun bénévole ciblé.",
|
"notification.no_recipients": "Aucun bénévole ciblé.",
|
||||||
"email.magic_link.subject": "Ton lien de connexion",
|
"email.magic_link.subject": "Ton lien de connexion",
|
||||||
"email.magic_link.body": "Bonjour,\n\nVoici ton lien de connexion, valable {minutes} minutes :\n\n{link}\n\nSi tu n'es pas à l'origine de cette demande, ignore simplement cet email.",
|
"email.magic_link.body": "Bonjour,\n\nVoici ton lien de connexion, valable {minutes} minutes :\n\n{link}\n\nSi tu n'es pas à l'origine de cette demande, ignore simplement cet email.",
|
||||||
|
"email.registration_confirmation.subject": "Ton inscription bénévole est confirmée",
|
||||||
|
"email.registration_confirmation.body": "Bonjour {prenom},\n\nTon inscription comme bénévole a bien été enregistrée. La coordination du festival prendra contact avec toi si besoin.\n\nÀ bientôt !",
|
||||||
|
"email.registration_confirmation_paid.subject": "Ton inscription bénévole est confirmée — paiement reçu",
|
||||||
|
"email.registration_confirmation_paid.body": "Bonjour {prenom},\n\nTon inscription comme bénévole a bien été enregistrée, et nous confirmons la réception de ton paiement :\n\n{label} — {montant} €\n\nLa coordination du festival prendra contact avec toi si besoin.\n\nÀ bientôt !",
|
||||||
"email.planning.default_subject": "Ton planning bénévole",
|
"email.planning.default_subject": "Ton planning bénévole",
|
||||||
"email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !",
|
"email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !",
|
||||||
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
|
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
|
||||||
@@ -85,6 +89,8 @@
|
|||||||
"form.invalid_sumup_amount": "Le montant doit être supérieur à 0.",
|
"form.invalid_sumup_amount": "Le montant doit être supérieur à 0.",
|
||||||
"form.unknown_payment_option": "Ce type de paiement n'existe pas pour cette édition.",
|
"form.unknown_payment_option": "Ce type de paiement n'existe pas pour cette édition.",
|
||||||
"form.duplicate_payment_option_label": "Un type de paiement porte déjà ce libellé.",
|
"form.duplicate_payment_option_label": "Un type de paiement porte déjà ce libellé.",
|
||||||
|
"form.pending_already_paid": "Cette inscription est déjà payée.",
|
||||||
|
"form.pending_not_awaiting_info": "Cette inscription n'attend pas ces informations.",
|
||||||
"form.too_many_position_wishes": "Deux vœux de poste au maximum.",
|
"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.",
|
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.",
|
||||||
"position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.",
|
"position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.",
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Inscription en deux temps + mécénat (cerfa)
|
||||||
|
|
||||||
|
Revision ID: 0018_pending_submissions_mecenat
|
||||||
|
Revises: 0017_form_payment_options
|
||||||
|
Create Date: 2026-09-29
|
||||||
|
|
||||||
|
Un bénévole qui choisit un paiement en ligne n'est plus créé en base tant que
|
||||||
|
le paiement n'est pas confirmé : `pending_form_submission` retient le
|
||||||
|
formulaire (et l'état du checkout SumUp) jusqu'à la promotion en `Volunteer`
|
||||||
|
(`app/api/forms.py:_promote_pending`) ou jusqu'à purge si jamais payé.
|
||||||
|
`form_payment_option.requires_mecenat_info` distingue un type de paiement
|
||||||
|
« mécénat » (étape d'adresse postale avant paiement, cerfa) ; les coordonnées
|
||||||
|
correspondantes vivent sur `volunteer.mecenat_*` une fois le bénévole promu.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0018_pending_submissions_mecenat"
|
||||||
|
down_revision: str | None = "0017_form_payment_options"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_FEE_STATUS_ENUM = sa.Enum(
|
||||||
|
"PENDING", "PAID", "FAILED", "EXPIRED",
|
||||||
|
name="feepaymentstatus", native_enum=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"form_payment_option",
|
||||||
|
sa.Column(
|
||||||
|
"requires_mecenat_info", sa.Boolean(), server_default="false", nullable=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column("volunteer", sa.Column("mecenat_address", sa.String(), nullable=True))
|
||||||
|
op.add_column("volunteer", sa.Column("mecenat_city", sa.String(), nullable=True))
|
||||||
|
op.add_column("volunteer", sa.Column("mecenat_postal_code", sa.String(), nullable=True))
|
||||||
|
op.add_column("volunteer", sa.Column("mecenat_country", sa.String(), nullable=True))
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"pending_form_submission",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("first_name", sa.String(), nullable=True),
|
||||||
|
sa.Column("last_name", sa.String(), nullable=True),
|
||||||
|
sa.Column("email", sa.String(), nullable=True),
|
||||||
|
sa.Column("phone", sa.String(), nullable=True),
|
||||||
|
sa.Column("comment", sa.String(), nullable=True),
|
||||||
|
sa.Column("companion_wish_note", sa.String(), nullable=True),
|
||||||
|
sa.Column("available", sa.Boolean(), server_default="false", nullable=False),
|
||||||
|
sa.Column("position_wish_ids_json", sa.String(), nullable=True),
|
||||||
|
sa.Column("artist_wish_ids_json", sa.String(), nullable=True),
|
||||||
|
sa.Column("photo", sa.LargeBinary(), nullable=True),
|
||||||
|
sa.Column("photo_content_type", sa.String(), nullable=True),
|
||||||
|
sa.Column("payment_label", sa.String(), nullable=False),
|
||||||
|
sa.Column("payment_amount", sa.Float(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"requires_mecenat_info", sa.Boolean(), server_default="false", nullable=False
|
||||||
|
),
|
||||||
|
sa.Column("mecenat_address", sa.String(), nullable=True),
|
||||||
|
sa.Column("mecenat_city", sa.String(), nullable=True),
|
||||||
|
sa.Column("mecenat_postal_code", sa.String(), nullable=True),
|
||||||
|
sa.Column("mecenat_country", sa.String(), nullable=True),
|
||||||
|
sa.Column("checkout_id", sa.String(), nullable=True),
|
||||||
|
sa.Column("checkout_status", _FEE_STATUS_ENUM, nullable=True),
|
||||||
|
sa.Column("promoted_volunteer_id", sa.Uuid(), nullable=True),
|
||||||
|
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.ForeignKeyConstraint(
|
||||||
|
["promoted_volunteer_id"], ["volunteer.id"], ondelete="SET NULL"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_pending_form_submission_edition_id",
|
||||||
|
"pending_form_submission",
|
||||||
|
["edition_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_pending_form_submission_checkout_id",
|
||||||
|
"pending_form_submission",
|
||||||
|
["checkout_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
"ix_pending_form_submission_checkout_id", table_name="pending_form_submission"
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"ix_pending_form_submission_edition_id", table_name="pending_form_submission"
|
||||||
|
)
|
||||||
|
op.drop_table("pending_form_submission")
|
||||||
|
|
||||||
|
op.drop_column("volunteer", "mecenat_country")
|
||||||
|
op.drop_column("volunteer", "mecenat_postal_code")
|
||||||
|
op.drop_column("volunteer", "mecenat_city")
|
||||||
|
op.drop_column("volunteer", "mecenat_address")
|
||||||
|
|
||||||
|
op.drop_column("form_payment_option", "requires_mecenat_info")
|
||||||
@@ -18,6 +18,7 @@ from app.models.domain import (
|
|||||||
FormPaymentOption,
|
FormPaymentOption,
|
||||||
FormTemplate,
|
FormTemplate,
|
||||||
MagicLinkToken,
|
MagicLinkToken,
|
||||||
|
PendingFormSubmission,
|
||||||
Position,
|
Position,
|
||||||
Shift,
|
Shift,
|
||||||
Volunteer,
|
Volunteer,
|
||||||
@@ -53,6 +54,7 @@ __all__ = [
|
|||||||
"FormPaymentOption",
|
"FormPaymentOption",
|
||||||
"FormTemplate",
|
"FormTemplate",
|
||||||
"MagicLinkToken",
|
"MagicLinkToken",
|
||||||
|
"PendingFormSubmission",
|
||||||
"Position",
|
"Position",
|
||||||
"PositionCategory",
|
"PositionCategory",
|
||||||
"Role",
|
"Role",
|
||||||
|
|||||||
@@ -211,6 +211,15 @@ class Volunteer(TimestampMixin, table=True):
|
|||||||
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
|
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
|
||||||
)
|
)
|
||||||
fee_paid_at: dt.datetime | None = None
|
fee_paid_at: dt.datetime | None = None
|
||||||
|
# Coordonnées collectées uniquement pour un type de paiement « mécénat »
|
||||||
|
# (`FormPaymentOption.requires_mecenat_info`), avant le paiement (voir
|
||||||
|
# `PendingFormSubmission` et `app/api/forms.py:submit_mecenat_info`) —
|
||||||
|
# conservées pour générer un reçu fiscal (cerfa) ultérieurement. `None`
|
||||||
|
# pour un bénévole n'ayant pas payé via une option de mécénat.
|
||||||
|
mecenat_address: str | None = None
|
||||||
|
mecenat_city: str | None = None
|
||||||
|
mecenat_postal_code: str | None = None
|
||||||
|
mecenat_country: str | None = None
|
||||||
|
|
||||||
wished_artists: list[Artist] = Relationship(
|
wished_artists: list[Artist] = Relationship(
|
||||||
back_populates="fans",
|
back_populates="fans",
|
||||||
@@ -545,6 +554,93 @@ class FormPaymentOption(TimestampMixin, table=True):
|
|||||||
default=0,
|
default=0,
|
||||||
sa_column=Column(Integer, nullable=False, server_default="0"),
|
sa_column=Column(Integer, nullable=False, server_default="0"),
|
||||||
)
|
)
|
||||||
|
# Type « mécénat » : avant le paiement, le bénévole passe par une étape
|
||||||
|
# supplémentaire (adresse postale) pour permettre l'édition d'un reçu
|
||||||
|
# fiscal (cerfa) — voir `Volunteer.mecenat_*` et
|
||||||
|
# `app/api/forms.py:submit_mecenat_info`.
|
||||||
|
requires_mecenat_info: bool = Field(
|
||||||
|
default=False,
|
||||||
|
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PendingFormSubmission(TimestampMixin, table=True):
|
||||||
|
"""Inscription en attente de paiement en ligne (voir CLAUDE.md § 2,
|
||||||
|
`Paiement de la cotisation`). Créée à la place d'un `Volunteer` quand le
|
||||||
|
bénévole choisit un type de paiement en ligne (`FormPaymentOption`) sur
|
||||||
|
le formulaire public — le `Volunteer` n'est créé qu'à la confirmation du
|
||||||
|
paiement (`app/api/forms.py:_promote_pending`), pour ne jamais compter en
|
||||||
|
base quelqu'un qui a rempli le formulaire sans aller au bout du
|
||||||
|
paiement.
|
||||||
|
|
||||||
|
Reprend les champs du formulaire (identiques à `Volunteer`, en plus
|
||||||
|
volatil) ; `position_wish_ids_json`/`artist_wish_ids_json` : listes
|
||||||
|
JSON stringifiées (pas d'association SQL pour une ligne éphémère), leurs
|
||||||
|
UUID sont résolus (silencieusement filtrés si un poste/artiste a
|
||||||
|
entretemps disparu — le paiement ne doit jamais échouer pour ça) au
|
||||||
|
moment de la promotion en `Volunteer`.
|
||||||
|
|
||||||
|
`id` sert aussi de jeton public (`GET .../form/public/pending/{id}`,
|
||||||
|
aucune authentification) : un UUID v4 est déjà pratiquement impossible
|
||||||
|
à deviner, comme un identifiant de session de paiement chez la plupart
|
||||||
|
des prestataires.
|
||||||
|
|
||||||
|
`payment_label`/`payment_amount`/`requires_mecenat_info` sont une copie
|
||||||
|
figée de l'option choisie (même principe que `Volunteer.fee_payment_label`)
|
||||||
|
— indépendante d'une modification de `FormPaymentOption` pendant que le
|
||||||
|
paiement est en cours. `checkout_id`/`checkout_status` suivent le
|
||||||
|
checkout SumUp *courant* : relancés (nouvel identifiant) à chaque
|
||||||
|
nouvelle tentative après un échec/une expiration
|
||||||
|
(`app/api/forms.py:retry_pending_payment`), sans jamais perdre les
|
||||||
|
informations déjà saisies.
|
||||||
|
|
||||||
|
`promoted_volunteer_id` : posé au moment du paiement confirmé (`PAID`) —
|
||||||
|
le `Volunteer` créé. Les champs nominatifs sont alors vidés (RGPD :
|
||||||
|
la même information vit désormais uniquement sur `Volunteer`), seuls
|
||||||
|
les champs de suivi du paiement restent. Une ligne jamais promue est
|
||||||
|
purgée après un délai (voir `_prune_stale_pending`)."""
|
||||||
|
|
||||||
|
__tablename__ = "pending_form_submission"
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
edition_id: uuid.UUID = Field(
|
||||||
|
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||||
|
)
|
||||||
|
|
||||||
|
first_name: str | None = None
|
||||||
|
last_name: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
comment: str | None = None
|
||||||
|
companion_wish_note: str | None = None
|
||||||
|
available: bool = Field(
|
||||||
|
default=False,
|
||||||
|
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
position_wish_ids_json: str | None = None
|
||||||
|
artist_wish_ids_json: str | None = None
|
||||||
|
photo: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
||||||
|
photo_content_type: str | None = None
|
||||||
|
|
||||||
|
payment_label: str
|
||||||
|
payment_amount: float
|
||||||
|
requires_mecenat_info: bool = Field(
|
||||||
|
default=False,
|
||||||
|
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
mecenat_address: str | None = None
|
||||||
|
mecenat_city: str | None = None
|
||||||
|
mecenat_postal_code: str | None = None
|
||||||
|
mecenat_country: str | None = None
|
||||||
|
|
||||||
|
checkout_id: str | None = Field(default=None, index=True)
|
||||||
|
checkout_status: FeePaymentStatus | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
|
||||||
|
)
|
||||||
|
promoted_volunteer_id: uuid.UUID | None = Field(
|
||||||
|
default=None, foreign_key="volunteer.id", ondelete="SET NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FormField(SQLModel, table=True):
|
class FormField(SQLModel, table=True):
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Aide de test : les messages capturés par `outbox` (conftest.py) sont
|
||||||
|
`multipart/alternative` depuis l'ajout d'une version HTML (voir
|
||||||
|
`app/email/sender.py:build_message`) — `.get_content()` ne suffit plus à lui
|
||||||
|
seul, il faut préciser quelle partie lire."""
|
||||||
|
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
|
||||||
|
def plain_text(message: EmailMessage) -> str:
|
||||||
|
return message.get_body(preferencelist=("plain",)).get_content()
|
||||||
@@ -2,6 +2,7 @@ from sqlmodel import select
|
|||||||
|
|
||||||
from app.models import Assignment, MagicLinkToken, Role
|
from app.models import Assignment, MagicLinkToken, Role
|
||||||
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
||||||
|
from tests.mail import plain_text
|
||||||
|
|
||||||
|
|
||||||
def _url(edition):
|
def _url(edition):
|
||||||
@@ -38,17 +39,17 @@ def test_one_email_per_volunteer_with_an_address(client, edition, session, outbo
|
|||||||
|
|
||||||
by_to = {m["To"]: m for m in outbox}
|
by_to = {m["To"]: m for m in outbox}
|
||||||
assert by_to["ana@e.fr"]["Subject"] == "Planning de Ana"
|
assert by_to["ana@e.fr"]["Subject"] == "Planning de Ana"
|
||||||
ana_body = by_to["ana@e.fr"].get_content()
|
ana_body = plain_text(by_to["ana@e.fr"])
|
||||||
assert "Tu as 2 créneau(x), 7.0 h." in ana_body
|
assert "Tu as 2 créneau(x), 7.0 h." in ana_body
|
||||||
assert "Bar" in ana_body
|
assert "Bar" in ana_body
|
||||||
assert "Tu as 0 créneau(x), 0.0 h." in by_to["bob@e.fr"].get_content()
|
assert "Tu as 0 créneau(x), 0.0 h." in plain_text(by_to["bob@e.fr"])
|
||||||
|
|
||||||
|
|
||||||
def test_default_template_is_used_when_body_omitted(client, edition, session, outbox):
|
def test_default_template_is_used_when_body_omitted(client, edition, session, outbox):
|
||||||
make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
||||||
resp = client.post(_url(edition), json={})
|
resp = client.post(_url(edition), json={})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert outbox[0].get_content().startswith("Bonjour Ana,")
|
assert plain_text(outbox[0]).startswith("Bonjour Ana,")
|
||||||
|
|
||||||
|
|
||||||
def test_magic_link_is_embedded_when_placeholder_present(
|
def test_magic_link_is_embedded_when_placeholder_present(
|
||||||
@@ -61,7 +62,7 @@ def test_magic_link_is_embedded_when_placeholder_present(
|
|||||||
_url(edition), json={"body": "Connecte-toi : {lien_connexion}"}
|
_url(edition), json={"body": "Connecte-toi : {lien_connexion}"}
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "/connexion?token=" in outbox[0].get_content()
|
assert "/connexion?token=" in plain_text(outbox[0])
|
||||||
assert len(session.exec(select(MagicLinkToken)).all()) == 1
|
assert len(session.exec(select(MagicLinkToken)).all()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
import io
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
from app.models import Assignment, Availability, Role, Volunteer
|
from app.models import Assignment, Availability, Role, Volunteer
|
||||||
@@ -389,3 +391,42 @@ def test_lead_can_read_any_volunteers_planning(
|
|||||||
client = as_account(lead_account)
|
client = as_account(lead_account)
|
||||||
|
|
||||||
assert client.get(_url(edition, f"/{other.id}/planning")).status_code == 200
|
assert client.get(_url(edition, f"/{other.id}/planning")).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_volunteers_as_excel(client, edition, session):
|
||||||
|
position = make_position(session, edition, name="Bar central")
|
||||||
|
artist = make_artist(session, edition, name="Chilla")
|
||||||
|
volunteer = make_volunteer(
|
||||||
|
session, edition, first_name="Alex", last_name="Martin", email="alex@ex.fr"
|
||||||
|
)
|
||||||
|
client.patch(
|
||||||
|
_url(edition, f"/{volunteer.id}"),
|
||||||
|
json={
|
||||||
|
"wished_position_ids": [str(position.id)],
|
||||||
|
"artist_ids": [str(artist.id)],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get(_url(edition, "/export"))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"] == (
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
)
|
||||||
|
assert f"benevoles-{edition.year}.xlsx" in resp.headers["content-disposition"]
|
||||||
|
|
||||||
|
workbook = load_workbook(io.BytesIO(resp.content))
|
||||||
|
sheet = workbook.active
|
||||||
|
rows = list(sheet.iter_rows(values_only=True))
|
||||||
|
assert rows[0][0] == "Prénom"
|
||||||
|
row = rows[1]
|
||||||
|
assert row[0] == "Alex"
|
||||||
|
assert row[1] == "Martin"
|
||||||
|
assert row[2] == "alex@ex.fr"
|
||||||
|
assert row[5] == "Bar central"
|
||||||
|
assert row[6] == "Chilla"
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_volunteers_is_coordinator_only(edition, make_account, as_account):
|
||||||
|
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||||
|
resp = as_account(lead).get(_url(edition, "/export"))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from sqlmodel import select
|
|||||||
|
|
||||||
from app.auth.tokens import hash_token
|
from app.auth.tokens import hash_token
|
||||||
from app.models import MagicLinkToken, Role, local_now
|
from app.models import MagicLinkToken, Role, local_now
|
||||||
|
from tests.mail import plain_text
|
||||||
|
|
||||||
|
|
||||||
def _url(edition, suffix):
|
def _url(edition, suffix):
|
||||||
@@ -31,7 +32,7 @@ def test_full_flow_magic_link_then_jwt_then_me(
|
|||||||
assert resp.status_code == 202
|
assert resp.status_code == 202
|
||||||
assert len(outbox) == 1
|
assert len(outbox) == 1
|
||||||
assert outbox[0]["To"] == "ref@example.fr"
|
assert outbox[0]["To"] == "ref@example.fr"
|
||||||
raw_token = outbox[0].get_content().split("?token=")[1].split()[0].strip()
|
raw_token = plain_text(outbox[0]).split("?token=")[1].split()[0].strip()
|
||||||
|
|
||||||
verified = raw_client.post(
|
verified = raw_client.post(
|
||||||
_url(edition, "verify"), json={"token": raw_token}
|
_url(edition, "verify"), json={"token": raw_token}
|
||||||
|
|||||||
+310
-24
@@ -4,11 +4,13 @@ soumission publique (aucune authentification)."""
|
|||||||
import base64
|
import base64
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import io
|
import io
|
||||||
|
import uuid
|
||||||
|
|
||||||
from app.auth.rate_limit import form_submission_limiter
|
from app.auth.rate_limit import form_submission_limiter
|
||||||
from app.models import Role
|
from app.models import PendingFormSubmission, Role, local_now
|
||||||
from app.sumup import SumUpCheckout
|
from app.sumup import SumUpCheckout
|
||||||
from tests.factories import make_artist, make_position
|
from tests.factories import make_artist, make_position
|
||||||
|
from tests.mail import plain_text
|
||||||
|
|
||||||
_MERCHANT = "MXXXXX"
|
_MERCHANT = "MXXXXX"
|
||||||
_API_KEY = "sup_sk_secret"
|
_API_KEY = "sup_sk_secret"
|
||||||
@@ -27,6 +29,24 @@ def _configure_sumup(client, edition, amount=12.5, label="Cotisation classique")
|
|||||||
)
|
)
|
||||||
return resp.json()["id"]
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _pay(raw_client, edition, monkeypatch, checkout_id="co_1", status_after="PAID"):
|
||||||
|
"""Monte le checkout ensuite renvoyé par `get_checkout` sur `status_after`
|
||||||
|
(`PAID`/`FAILED`/`EXPIRED`) — les tests posent d'abord `create_checkout`
|
||||||
|
et soumettent eux-mêmes, cette aide ne fait que la partie webhook."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.forms.get_checkout",
|
||||||
|
lambda **kw: SumUpCheckout(id=checkout_id, status=status_after, hosted_checkout_url=None),
|
||||||
|
)
|
||||||
|
return raw_client.post(
|
||||||
|
_form_url(edition, "/public/sumup-webhook"),
|
||||||
|
json={"event_type": "CHECKOUT_STATUS_CHANGED", "id": checkout_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_status(raw_client, edition, token):
|
||||||
|
return raw_client.get(_form_url(edition, f"/public/pending/{token}")).json()
|
||||||
|
|
||||||
# PNG 1×1 valide (transparent), le plus petit possible.
|
# PNG 1×1 valide (transparent), le plus petit possible.
|
||||||
_PNG = base64.b64decode(
|
_PNG = base64.b64decode(
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
|
||||||
@@ -202,7 +222,9 @@ def test_deleting_a_payment_option_keeps_the_volunteers_snapshot(
|
|||||||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
payment_option_id=option_id,
|
payment_option_id=option_id,
|
||||||
)
|
)
|
||||||
volunteer_id = resp.json()["volunteer_id"]
|
token = resp.json()["pending_token"]
|
||||||
|
_pay(raw_client, edition, monkeypatch, checkout_id="co_1")
|
||||||
|
volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"]
|
||||||
|
|
||||||
client.delete(_form_url(edition, f"/payment-options/{option_id}"))
|
client.delete(_form_url(edition, f"/payment-options/{option_id}"))
|
||||||
|
|
||||||
@@ -294,11 +316,12 @@ def test_submitting_creates_a_volunteer(raw_client, client, edition, session):
|
|||||||
_form_url(edition, "/fields"),
|
_form_url(edition, "/fields"),
|
||||||
json={"fields": ["EMAIL", "PHONE", "AVAILABLE", "WILLING_TO_PAY_FEE"]},
|
json={"fields": ["EMAIL", "PHONE", "AVAILABLE", "WILLING_TO_PAY_FEE"]},
|
||||||
)
|
)
|
||||||
|
# Pas de payment_option_id : « je paierai sur place » — pas de paiement
|
||||||
|
# en ligne, le bénévole est créé tout de suite.
|
||||||
resp = _submit(
|
resp = _submit(
|
||||||
raw_client, edition,
|
raw_client, edition,
|
||||||
first_name="alex", last_name="martin", hcaptcha_token="x",
|
first_name="alex", last_name="martin", hcaptcha_token="x",
|
||||||
email="alex@example.org", phone="0600000000",
|
email="alex@example.org", phone="0600000000", available="true",
|
||||||
available="true", willing_to_pay_fee="true",
|
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
volunteer_id = resp.json()["volunteer_id"]
|
volunteer_id = resp.json()["volunteer_id"]
|
||||||
@@ -309,12 +332,32 @@ def test_submitting_creates_a_volunteer(raw_client, client, edition, session):
|
|||||||
assert fiche["email"] == "alex@example.org"
|
assert fiche["email"] == "alex@example.org"
|
||||||
assert fiche["self_declared_available"] is True
|
assert fiche["self_declared_available"] is True
|
||||||
assert fiche["willing_to_pay_fee"] is True
|
assert fiche["willing_to_pay_fee"] is True
|
||||||
|
assert fiche["fee_payment_status"] is None
|
||||||
|
|
||||||
# Le bénévole créé apparaît dans la liste de la coordination.
|
# Le bénévole créé apparaît dans la liste de la coordination.
|
||||||
volunteers = client.get(f"/editions/{edition.id}/volunteers").json()
|
volunteers = client.get(f"/editions/{edition.id}/volunteers").json()
|
||||||
assert any(v["id"] == volunteer_id for v in volunteers)
|
assert any(v["id"] == volunteer_id for v in volunteers)
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_sends_a_registration_confirmation_email(raw_client, client, edition, outbox):
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["EMAIL"]})
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
email="alex@example.org",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert len(outbox) == 1
|
||||||
|
assert outbox[0]["To"] == "alex@example.org"
|
||||||
|
assert "Alex" in plain_text(outbox[0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_without_an_email_sends_no_confirmation(raw_client, edition, outbox):
|
||||||
|
resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x")
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert outbox == []
|
||||||
|
|
||||||
|
|
||||||
def test_submitting_ignores_fields_not_enabled(raw_client, client, edition):
|
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.
|
# Aucun champ activé : email/téléphone envoyés quand même, ignorés.
|
||||||
resp = _submit(
|
resp = _submit(
|
||||||
@@ -329,6 +372,35 @@ def test_submitting_ignores_fields_not_enabled(raw_client, client, edition):
|
|||||||
assert fiche["phone"] is None
|
assert fiche["phone"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_without_an_email_is_rejected_when_email_enabled(raw_client, client, edition):
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["EMAIL"]})
|
||||||
|
resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x")
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "import.row.missing_required_field"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_without_a_phone_is_rejected_when_phone_enabled(raw_client, client, edition):
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["PHONE"]})
|
||||||
|
resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x")
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "import.row.missing_required_field"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_without_checking_available_is_rejected_when_enabled(
|
||||||
|
raw_client, client, edition
|
||||||
|
):
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["AVAILABLE"]})
|
||||||
|
resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x")
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "import.row.missing_required_field"
|
||||||
|
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x", available="true",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
def test_submitting_resolves_position_and_artist_wishes(
|
def test_submitting_resolves_position_and_artist_wishes(
|
||||||
raw_client, client, edition, session
|
raw_client, client, edition, session
|
||||||
):
|
):
|
||||||
@@ -482,10 +554,11 @@ def test_submitting_with_a_rejected_captcha(raw_client, edition, monkeypatch):
|
|||||||
assert resp.json()["detail"]["code"] == "form.captcha_failed"
|
assert resp.json()["detail"]["code"] == "form.captcha_failed"
|
||||||
|
|
||||||
|
|
||||||
def test_submitting_creates_a_sumup_checkout_when_a_payment_option_is_chosen(
|
def test_choosing_an_online_option_does_not_create_the_volunteer_yet(
|
||||||
raw_client, client, edition, monkeypatch
|
raw_client, client, edition, monkeypatch
|
||||||
):
|
):
|
||||||
option_id = _configure_sumup(client, edition)
|
option_id = _configure_sumup(client, edition)
|
||||||
|
before = len(client.get(f"/editions/{edition.id}/volunteers").json())
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"app.api.forms.create_checkout",
|
"app.api.forms.create_checkout",
|
||||||
lambda **kw: SumUpCheckout(
|
lambda **kw: SumUpCheckout(
|
||||||
@@ -499,16 +572,86 @@ def test_submitting_creates_a_sumup_checkout_when_a_payment_option_is_chosen(
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
|
assert body["volunteer_id"] is None
|
||||||
|
assert body["pending_token"] is not None
|
||||||
assert body["sumup_checkout_url"] == "https://checkout.sumup.com/pay/co_123"
|
assert body["sumup_checkout_url"] == "https://checkout.sumup.com/pay/co_123"
|
||||||
|
|
||||||
fiche = client.get(f"/editions/{edition.id}/volunteers/{body['volunteer_id']}").json()
|
# Toujours rien en base tant que le paiement n'est pas confirmé.
|
||||||
assert fiche["fee_payment_status"] == "PENDING"
|
after = len(client.get(f"/editions/{edition.id}/volunteers").json())
|
||||||
|
assert after == before
|
||||||
|
|
||||||
|
status = _pending_status(raw_client, edition, body["pending_token"])
|
||||||
|
assert status["status"] == "PENDING"
|
||||||
|
assert status["label"] == "Cotisation classique"
|
||||||
|
assert status["amount"] == 12.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_paying_promotes_the_pending_submission_to_a_volunteer(
|
||||||
|
raw_client, client, edition, monkeypatch
|
||||||
|
):
|
||||||
|
option_id = _configure_sumup(client, edition)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.forms.create_checkout",
|
||||||
|
lambda **kw: SumUpCheckout(id="co_123", status="PENDING", hosted_checkout_url="https://x"),
|
||||||
|
)
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
payment_option_id=option_id,
|
||||||
|
)
|
||||||
|
token = resp.json()["pending_token"]
|
||||||
|
|
||||||
|
webhook_resp = _pay(raw_client, edition, monkeypatch, checkout_id="co_123")
|
||||||
|
assert webhook_resp.status_code == 200
|
||||||
|
|
||||||
|
status = _pending_status(raw_client, edition, token)
|
||||||
|
assert status["status"] == "PAID"
|
||||||
|
volunteer_id = status["volunteer_id"]
|
||||||
|
assert volunteer_id is not None
|
||||||
|
|
||||||
|
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||||
|
assert fiche["first_name"] == "Alex"
|
||||||
|
assert fiche["fee_payment_status"] == "PAID"
|
||||||
assert fiche["fee_payment_label"] == "Cotisation classique"
|
assert fiche["fee_payment_label"] == "Cotisation classique"
|
||||||
assert fiche["fee_amount"] == 12.5
|
assert fiche["fee_amount"] == 12.5
|
||||||
|
assert fiche["fee_paid_at"] is not None
|
||||||
assert fiche["willing_to_pay_fee"] is True
|
assert fiche["willing_to_pay_fee"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_submitting_without_choosing_an_option_does_not_create_a_checkout(
|
def test_payment_confirmation_sends_an_email_on_promotion(
|
||||||
|
raw_client, client, edition, monkeypatch, outbox
|
||||||
|
):
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE", "EMAIL"]})
|
||||||
|
client.patch(
|
||||||
|
_form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY}
|
||||||
|
)
|
||||||
|
option_id = client.post(
|
||||||
|
_form_url(edition, "/payment-options"),
|
||||||
|
json={"label": "Cotisation classique", "amount": 12.5},
|
||||||
|
).json()["id"]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.forms.create_checkout",
|
||||||
|
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
|
||||||
|
)
|
||||||
|
_submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
email="alex@example.org",
|
||||||
|
payment_option_id=option_id,
|
||||||
|
)
|
||||||
|
assert outbox == [] # pas encore payé : pas d'email à ce stade
|
||||||
|
|
||||||
|
_pay(raw_client, edition, monkeypatch, checkout_id="co_1")
|
||||||
|
|
||||||
|
assert len(outbox) == 1
|
||||||
|
assert outbox[0]["To"] == "alex@example.org"
|
||||||
|
body = plain_text(outbox[0])
|
||||||
|
assert "Alex" in body
|
||||||
|
assert "Cotisation classique" in body
|
||||||
|
assert "12.5" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_choosing_to_pay_on_site_creates_the_volunteer_immediately(
|
||||||
raw_client, client, edition, monkeypatch
|
raw_client, client, edition, monkeypatch
|
||||||
):
|
):
|
||||||
_configure_sumup(client, edition)
|
_configure_sumup(client, edition)
|
||||||
@@ -518,9 +661,16 @@ def test_submitting_without_choosing_an_option_does_not_create_a_checkout(
|
|||||||
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
|
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
assert resp.json()["sumup_checkout_url"] is None
|
body = resp.json()
|
||||||
|
assert body["volunteer_id"] is not None
|
||||||
|
assert body["pending_token"] is None
|
||||||
|
assert body["sumup_checkout_url"] is None
|
||||||
assert called == []
|
assert called == []
|
||||||
|
|
||||||
|
fiche = client.get(f"/editions/{edition.id}/volunteers/{body['volunteer_id']}").json()
|
||||||
|
assert fiche["willing_to_pay_fee"] is True
|
||||||
|
assert fiche["fee_payment_status"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_submitting_with_an_unknown_payment_option_is_rejected(raw_client, client, edition):
|
def test_submitting_with_an_unknown_payment_option_is_rejected(raw_client, client, edition):
|
||||||
_configure_sumup(client, edition)
|
_configure_sumup(client, edition)
|
||||||
@@ -533,7 +683,7 @@ def test_submitting_with_an_unknown_payment_option_is_rejected(raw_client, clien
|
|||||||
assert resp.json()["detail"]["code"] == "form.unknown_payment_option"
|
assert resp.json()["detail"]["code"] == "form.unknown_payment_option"
|
||||||
|
|
||||||
|
|
||||||
def test_a_failed_checkout_call_does_not_block_registration(
|
def test_a_failed_checkout_call_can_be_retried_without_resubmitting(
|
||||||
raw_client, client, edition, monkeypatch
|
raw_client, client, edition, monkeypatch
|
||||||
):
|
):
|
||||||
option_id = _configure_sumup(client, edition)
|
option_id = _configure_sumup(client, edition)
|
||||||
@@ -546,36 +696,151 @@ def test_a_failed_checkout_call_does_not_block_registration(
|
|||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["sumup_checkout_url"] is None
|
assert body["sumup_checkout_url"] is None
|
||||||
fiche = client.get(f"/editions/{edition.id}/volunteers/{body['volunteer_id']}").json()
|
token = body["pending_token"]
|
||||||
assert fiche["fee_payment_status"] is None
|
assert _pending_status(raw_client, edition, token)["status"] == "PENDING"
|
||||||
|
|
||||||
|
# SumUp remarche : la relance réutilise les infos déjà saisies.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.forms.create_checkout",
|
||||||
|
lambda **kw: SumUpCheckout(id="co_retry", status="PENDING", hosted_checkout_url="https://x"),
|
||||||
|
)
|
||||||
|
retry_resp = raw_client.post(_form_url(edition, f"/public/pending/{token}/retry-payment"))
|
||||||
|
assert retry_resp.status_code == 200
|
||||||
|
assert retry_resp.json()["sumup_checkout_url"] == "https://x"
|
||||||
|
|
||||||
|
_pay(raw_client, edition, monkeypatch, checkout_id="co_retry")
|
||||||
|
assert _pending_status(raw_client, edition, token)["status"] == "PAID"
|
||||||
|
|
||||||
|
|
||||||
def test_sumup_webhook_updates_the_payment_status(raw_client, client, edition, monkeypatch):
|
def test_retrying_after_an_expired_checkout(raw_client, client, edition, monkeypatch):
|
||||||
option_id = _configure_sumup(client, edition)
|
option_id = _configure_sumup(client, edition)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"app.api.forms.create_checkout",
|
"app.api.forms.create_checkout",
|
||||||
lambda **kw: SumUpCheckout(id="co_123", status="PENDING", hosted_checkout_url="https://x"),
|
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
|
||||||
)
|
)
|
||||||
resp = _submit(
|
resp = _submit(
|
||||||
raw_client, edition,
|
raw_client, edition,
|
||||||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
payment_option_id=option_id,
|
payment_option_id=option_id,
|
||||||
)
|
)
|
||||||
volunteer_id = resp.json()["volunteer_id"]
|
token = resp.json()["pending_token"]
|
||||||
|
|
||||||
|
_pay(raw_client, edition, monkeypatch, checkout_id="co_1", status_after="EXPIRED")
|
||||||
|
assert _pending_status(raw_client, edition, token)["status"] == "EXPIRED"
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"app.api.forms.get_checkout",
|
"app.api.forms.create_checkout",
|
||||||
lambda **kw: SumUpCheckout(id="co_123", status="PAID", hosted_checkout_url=None),
|
lambda **kw: SumUpCheckout(id="co_2", status="PENDING", hosted_checkout_url="https://y"),
|
||||||
)
|
)
|
||||||
webhook_resp = raw_client.post(
|
retry_resp = raw_client.post(_form_url(edition, f"/public/pending/{token}/retry-payment"))
|
||||||
_form_url(edition, "/public/sumup-webhook"),
|
assert retry_resp.status_code == 200
|
||||||
json={"event_type": "CHECKOUT_STATUS_CHANGED", "id": "co_123"},
|
|
||||||
)
|
|
||||||
assert webhook_resp.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrying_an_already_paid_pending_submission_is_rejected(
|
||||||
|
raw_client, client, edition, monkeypatch
|
||||||
|
):
|
||||||
|
option_id = _configure_sumup(client, edition)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.forms.create_checkout",
|
||||||
|
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
|
||||||
|
)
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
payment_option_id=option_id,
|
||||||
|
)
|
||||||
|
token = resp.json()["pending_token"]
|
||||||
|
_pay(raw_client, edition, monkeypatch, checkout_id="co_1")
|
||||||
|
|
||||||
|
retry_resp = raw_client.post(_form_url(edition, f"/public/pending/{token}/retry-payment"))
|
||||||
|
assert retry_resp.status_code == 422
|
||||||
|
assert retry_resp.json()["detail"]["code"] == "form.pending_already_paid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_pending_token_is_a_404(raw_client, edition):
|
||||||
|
resp = raw_client.get(
|
||||||
|
_form_url(edition, "/public/pending/00000000-0000-0000-0000-000000000000")
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_choosing_a_mecenat_option_requires_the_address_step_first(
|
||||||
|
raw_client, client, edition, monkeypatch
|
||||||
|
):
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
|
||||||
|
client.patch(
|
||||||
|
_form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY}
|
||||||
|
)
|
||||||
|
option_id = client.post(
|
||||||
|
_form_url(edition, "/payment-options"),
|
||||||
|
json={"label": "Mécénat", "amount": 50, "requires_mecenat_info": True},
|
||||||
|
).json()["id"]
|
||||||
|
|
||||||
|
called = []
|
||||||
|
monkeypatch.setattr("app.api.forms.create_checkout", lambda **kw: called.append(kw))
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
payment_option_id=option_id,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = resp.json()
|
||||||
|
assert body["requires_mecenat_info"] is True
|
||||||
|
assert body["sumup_checkout_url"] is None
|
||||||
|
assert called == [] # pas de checkout avant d'avoir l'adresse
|
||||||
|
|
||||||
|
token = body["pending_token"]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.forms.create_checkout",
|
||||||
|
lambda **kw: SumUpCheckout(id="co_don", status="PENDING", hosted_checkout_url="https://don"),
|
||||||
|
)
|
||||||
|
info_resp = raw_client.post(
|
||||||
|
_form_url(edition, f"/public/pending/{token}/mecenat-info"),
|
||||||
|
json={
|
||||||
|
"address": "1 rue du Festival",
|
||||||
|
"city": "Rennes",
|
||||||
|
"postal_code": "35000",
|
||||||
|
"country": "France",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert info_resp.status_code == 200
|
||||||
|
assert info_resp.json()["sumup_checkout_url"] == "https://don"
|
||||||
|
|
||||||
|
_pay(raw_client, edition, monkeypatch, checkout_id="co_don")
|
||||||
|
volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"]
|
||||||
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||||
assert fiche["fee_payment_status"] == "PAID"
|
assert fiche["mecenat_address"] == "1 rue du Festival"
|
||||||
assert fiche["fee_paid_at"] is not None
|
assert fiche["mecenat_city"] == "Rennes"
|
||||||
|
assert fiche["mecenat_postal_code"] == "35000"
|
||||||
|
assert fiche["mecenat_country"] == "France"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mecenat_info_requires_every_field(raw_client, client, edition, monkeypatch):
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
|
||||||
|
client.patch(
|
||||||
|
_form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY}
|
||||||
|
)
|
||||||
|
option_id = client.post(
|
||||||
|
_form_url(edition, "/payment-options"),
|
||||||
|
json={"label": "Mécénat", "amount": 50, "requires_mecenat_info": True},
|
||||||
|
).json()["id"]
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
payment_option_id=option_id,
|
||||||
|
)
|
||||||
|
token = resp.json()["pending_token"]
|
||||||
|
|
||||||
|
info_resp = raw_client.post(
|
||||||
|
_form_url(edition, f"/public/pending/{token}/mecenat-info"),
|
||||||
|
json={
|
||||||
|
"address": "1 rue du Festival",
|
||||||
|
"city": "Rennes",
|
||||||
|
"postal_code": "",
|
||||||
|
"country": "France",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert info_resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_sumup_webhook_ignores_an_unknown_checkout(raw_client, client, edition, monkeypatch):
|
def test_sumup_webhook_ignores_an_unknown_checkout(raw_client, client, edition, monkeypatch):
|
||||||
@@ -591,6 +856,27 @@ def test_sumup_webhook_ignores_an_unknown_checkout(raw_client, client, edition,
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_pending_submissions_are_pruned(raw_client, client, edition, session, monkeypatch):
|
||||||
|
option_id = _configure_sumup(client, edition)
|
||||||
|
monkeypatch.setattr("app.api.forms.create_checkout", lambda **kw: None)
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
payment_option_id=option_id,
|
||||||
|
)
|
||||||
|
token = resp.json()["pending_token"]
|
||||||
|
|
||||||
|
pending = session.get(PendingFormSubmission, uuid.UUID(token))
|
||||||
|
pending.created_at = local_now() - dt.timedelta(hours=49)
|
||||||
|
session.add(pending)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# N'importe quelle nouvelle soumission déclenche le nettoyage paresseux.
|
||||||
|
_submit(raw_client, edition, first_name="Sam", last_name="Dupuis", hcaptcha_token="x")
|
||||||
|
|
||||||
|
assert raw_client.get(_form_url(edition, f"/public/pending/{token}")).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_submitting_is_rate_limited_per_edition(raw_client, edition):
|
def test_submitting_is_rate_limited_per_edition(raw_client, edition):
|
||||||
for _ in range(form_submission_limiter.max_attempts):
|
for _ in range(form_submission_limiter.max_attempts):
|
||||||
form_submission_limiter.hit(str(edition.id))
|
form_submission_limiter.hit(str(edition.id))
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ EXPECTED_TABLES = {
|
|||||||
"form_template",
|
"form_template",
|
||||||
"form_field",
|
"form_field",
|
||||||
"form_payment_option",
|
"form_payment_option",
|
||||||
|
"pending_form_submission",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ async function checkPublicForm() {
|
|||||||
first_name: 'Test',
|
first_name: 'Test',
|
||||||
last_name: 'Smoke',
|
last_name: 'Smoke',
|
||||||
hcaptcha_token: 'dev',
|
hcaptcha_token: 'dev',
|
||||||
|
email: 'test.smoke@example.org',
|
||||||
artist_wish_ids: [sameDay[0].id, sameDay[1].id],
|
artist_wish_ids: [sameDay[0].id, sameDay[1].id],
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -154,6 +155,7 @@ async function checkPublicForm() {
|
|||||||
first_name: 'Test',
|
first_name: 'Test',
|
||||||
last_name: 'Smoke',
|
last_name: 'Smoke',
|
||||||
hcaptcha_token: 'dev',
|
hcaptcha_token: 'dev',
|
||||||
|
email: 'test.smoke@example.org',
|
||||||
position_wish_ids: positions.slice(0, 3).map((p) => p.id),
|
position_wish_ids: positions.slice(0, 3).map((p) => p.id),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -161,6 +163,8 @@ async function checkPublicForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Paiement en ligne choisi : rien en base tant que la « banque » (mock) ne
|
||||||
|
// confirme pas — voir CLAUDE.md § 2, Paiement de la cotisation.
|
||||||
const result = await api.forms.submitPublic(e, {
|
const result = await api.forms.submitPublic(e, {
|
||||||
first_name: 'test',
|
first_name: 'test',
|
||||||
last_name: 'smoke',
|
last_name: 'smoke',
|
||||||
@@ -171,7 +175,22 @@ async function checkPublicForm() {
|
|||||||
artist_wish_ids: artists[0] ? [artists[0].id] : [],
|
artist_wish_ids: artists[0] ? [artists[0].id] : [],
|
||||||
payment_option_id: classique.id,
|
payment_option_id: classique.id,
|
||||||
});
|
});
|
||||||
const created = await api.volunteers.get(e, result.volunteer_id);
|
if (result.volunteer_id || !result.pending_token) {
|
||||||
|
throw new Error('formulaire public : un paiement en ligne ne devrait pas créer le bénévole tout de suite');
|
||||||
|
}
|
||||||
|
const midCount = (await api.volunteers.list(e)).length;
|
||||||
|
if (midCount !== beforeCount) {
|
||||||
|
throw new Error('formulaire public : le bénévole ne devrait pas encore compter en base');
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = await api.forms.getPendingStatus(e, result.pending_token);
|
||||||
|
if (status.status === 'PENDING') {
|
||||||
|
status = await api.forms.getPendingStatus(e, result.pending_token);
|
||||||
|
}
|
||||||
|
if (status.status !== 'PAID' || !status.volunteer_id) {
|
||||||
|
throw new Error('formulaire public : le paiement simulé ne se confirme pas');
|
||||||
|
}
|
||||||
|
const created = await api.volunteers.get(e, status.volunteer_id);
|
||||||
const afterCount = (await api.volunteers.list(e)).length;
|
const afterCount = (await api.volunteers.list(e)).length;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import type {
|
|||||||
FormPaymentOptionUpdate,
|
FormPaymentOptionUpdate,
|
||||||
FormTemplateUpdate,
|
FormTemplateUpdate,
|
||||||
ImportMapping,
|
ImportMapping,
|
||||||
|
MecenatInfoSubmit,
|
||||||
PlanningNotificationRequest,
|
PlanningNotificationRequest,
|
||||||
PositionCreate,
|
PositionCreate,
|
||||||
PositionSheetsRequest,
|
PositionSheetsRequest,
|
||||||
@@ -384,6 +385,15 @@ export function useDeleteVolunteer() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Export Excel de la liste des bénévoles ; ne fait que retourner le
|
||||||
|
* fichier (le composant appelant déclenche le téléchargement, voir
|
||||||
|
* `useGenerateBadges`). */
|
||||||
|
export function useExportVolunteers() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => api.volunteers.exportXlsx(edition),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Remplace toutes les fenêtres de disponibilité d'un bénévole. */
|
/** Remplace toutes les fenêtres de disponibilité d'un bénévole. */
|
||||||
export function useReplaceAvailability() {
|
export function useReplaceAvailability() {
|
||||||
const invalidate = useInvalidate();
|
const invalidate = useInvalidate();
|
||||||
@@ -713,3 +723,31 @@ export function useSubmitPublicForm() {
|
|||||||
mutationFn: (body: PublicFormSubmission) => api.forms.submitPublic(edition, body),
|
mutationFn: (body: PublicFormSubmission) => api.forms.submitPublic(edition, body),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSubmitMecenatInfo() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ token, body }: { token: string; body: MecenatInfoSubmit }) =>
|
||||||
|
api.forms.submitMecenatInfo(edition, token, body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Statut d'une inscription en attente de paiement en ligne — interrogé en
|
||||||
|
* direct (voir CLAUDE.md § 2, Paiement de la cotisation) : la page de
|
||||||
|
* confirmation reste en attente tant que `status` vaut `PENDING`. */
|
||||||
|
export function usePendingStatus(token: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['pendingStatus', edition, token],
|
||||||
|
queryFn: () => api.forms.getPendingStatus(edition, token as string),
|
||||||
|
enabled: token !== null,
|
||||||
|
// Arrête la vérification automatique après ~2 min (40 x 3 s) : au-delà,
|
||||||
|
// un bouton manuel dans PaymentConfirmationView permet de réessayer.
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
query.state.data?.status === 'PENDING' && query.state.dataUpdateCount < 40 ? 3000 : false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRetryPendingPayment() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (token: string) => api.forms.retryPendingPayment(edition, token),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
ImportMapping,
|
ImportMapping,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
MagicLinkRequest,
|
MagicLinkRequest,
|
||||||
|
MecenatInfoSubmit,
|
||||||
PlanningNotificationRequest,
|
PlanningNotificationRequest,
|
||||||
PositionCreate,
|
PositionCreate,
|
||||||
PositionSheetsRequest,
|
PositionSheetsRequest,
|
||||||
@@ -151,6 +152,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
planning: (editionId, id) =>
|
planning: (editionId, id) =>
|
||||||
request(`${base(editionId)}/volunteers/${id}/planning`),
|
request(`${base(editionId)}/volunteers/${id}/planning`),
|
||||||
photoBlob: (editionId, id) => requestBlob(`${base(editionId)}/volunteers/${id}/photo`),
|
photoBlob: (editionId, id) => requestBlob(`${base(editionId)}/volunteers/${id}/photo`),
|
||||||
|
exportXlsx: async (editionId) => {
|
||||||
|
const blob = await requestBlob(`${base(editionId)}/volunteers/export`);
|
||||||
|
if (!blob) throw new ApiError(500, 'error.generic', []);
|
||||||
|
return blob;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
accounts: {
|
accounts: {
|
||||||
list: (editionId) => request(`${base(editionId)}/accounts`),
|
list: (editionId) => request(`${base(editionId)}/accounts`),
|
||||||
@@ -295,9 +301,6 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
if (body.comment) form.append('comment', body.comment);
|
if (body.comment) form.append('comment', body.comment);
|
||||||
if (body.companion_wish_note) form.append('companion_wish_note', body.companion_wish_note);
|
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.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.payment_option_id) form.append('payment_option_id', body.payment_option_id);
|
if (body.payment_option_id) form.append('payment_option_id', body.payment_option_id);
|
||||||
if (body.position_wish_ids) {
|
if (body.position_wish_ids) {
|
||||||
form.append('position_wish_ids', JSON.stringify(body.position_wish_ids));
|
form.append('position_wish_ids', JSON.stringify(body.position_wish_ids));
|
||||||
@@ -323,6 +326,17 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
remove: (editionId, id) =>
|
remove: (editionId, id) =>
|
||||||
request(`${base(editionId)}/form/payment-options/${id}`, { method: 'DELETE' }),
|
request(`${base(editionId)}/form/payment-options/${id}`, { method: 'DELETE' }),
|
||||||
},
|
},
|
||||||
|
submitMecenatInfo: (editionId, token, body: MecenatInfoSubmit) =>
|
||||||
|
request(`${base(editionId)}/form/public/pending/${token}/mecenat-info`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
getPendingStatus: (editionId, token) =>
|
||||||
|
request(`${base(editionId)}/form/public/pending/${token}`),
|
||||||
|
retryPendingPayment: (editionId, token) =>
|
||||||
|
request(`${base(editionId)}/form/public/pending/${token}/retry-payment`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
positionSheets: {
|
positionSheets: {
|
||||||
generate: async (editionId, body: PositionSheetsRequest) => {
|
generate: async (editionId, body: PositionSheetsRequest) => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
BadgeFieldRead,
|
BadgeFieldRead,
|
||||||
DayPartRead,
|
DayPartRead,
|
||||||
EditionRead,
|
EditionRead,
|
||||||
|
FeePaymentStatus,
|
||||||
FormFieldType,
|
FormFieldType,
|
||||||
FormPaymentOptionRead,
|
FormPaymentOptionRead,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
@@ -16,6 +17,37 @@ import type {
|
|||||||
VolunteerRead,
|
VolunteerRead,
|
||||||
} from '../schema';
|
} from '../schema';
|
||||||
|
|
||||||
|
/** Inscription en attente de paiement en ligne (mode démonstration) — voir
|
||||||
|
* `PendingFormSubmission` côté back-end (CLAUDE.md § 2, Paiement de la
|
||||||
|
* cotisation). `pollCount` simule le délai de confirmation réel (webhook
|
||||||
|
* asynchrone) : la première vérification reste `PENDING`, la suivante
|
||||||
|
* confirme — sans vrai réseau ni vrai SumUp en mode mock. */
|
||||||
|
export interface MockPendingSubmission {
|
||||||
|
id: string;
|
||||||
|
editionId: string;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
comment: string | null;
|
||||||
|
companionWishNote: string | null;
|
||||||
|
available: boolean;
|
||||||
|
positionIds: string[];
|
||||||
|
artistIds: string[];
|
||||||
|
photo: string | null; // data URL
|
||||||
|
paymentLabel: string;
|
||||||
|
paymentAmount: number;
|
||||||
|
requiresMecenatInfo: boolean;
|
||||||
|
mecenatAddress: string | null;
|
||||||
|
mecenatCity: string | null;
|
||||||
|
mecenatPostalCode: string | null;
|
||||||
|
mecenatCountry: string | null;
|
||||||
|
checkoutId: string | null;
|
||||||
|
status: FeePaymentStatus | null;
|
||||||
|
promotedVolunteerId: string | null;
|
||||||
|
pollCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
const PART_LABELS: Record<string, string> = { M: 'Matin', A: 'Après-midi', S: 'Soirée', N: 'Nuit' };
|
const PART_LABELS: Record<string, string> = { M: 'Matin', A: 'Après-midi', S: 'Soirée', N: 'Nuit' };
|
||||||
const PART_CODES: Record<string, string> = { M: 'MATIN', A: 'APRES_MIDI', S: 'SOIREE', N: 'NUIT' };
|
const PART_CODES: Record<string, string> = { M: 'MATIN', A: 'APRES_MIDI', S: 'SOIREE', N: 'NUIT' };
|
||||||
const hhmm = (h: number) => `${String(h % 24).padStart(2, '0')}:00:00`;
|
const hhmm = (h: number) => `${String(h % 24).padStart(2, '0')}:00:00`;
|
||||||
@@ -61,6 +93,9 @@ export interface MockState {
|
|||||||
sumupApiKey: string | null;
|
sumupApiKey: string | null;
|
||||||
};
|
};
|
||||||
formPaymentOptions: FormPaymentOptionRead[];
|
formPaymentOptions: FormPaymentOptionRead[];
|
||||||
|
/** Inscriptions en attente de paiement en ligne (voir CLAUDE.md § 2,
|
||||||
|
* Paiement de la cotisation) — clé : jeton public (id). */
|
||||||
|
pendingSubmissions: Map<string, MockPendingSubmission>;
|
||||||
/** volunteerId -> photo (formulaire public), data URL. */
|
/** volunteerId -> photo (formulaire public), data URL. */
|
||||||
photos: Map<string, string>;
|
photos: Map<string, string>;
|
||||||
seq: number;
|
seq: number;
|
||||||
@@ -278,6 +313,10 @@ export function buildMockState(): MockState {
|
|||||||
willing_to_pay_fee: false,
|
willing_to_pay_fee: false,
|
||||||
fee_payment_label: null,
|
fee_payment_label: null,
|
||||||
fee_amount: null,
|
fee_amount: null,
|
||||||
|
mecenat_address: null,
|
||||||
|
mecenat_city: null,
|
||||||
|
mecenat_postal_code: null,
|
||||||
|
mecenat_country: null,
|
||||||
fee_payment_status: null,
|
fee_payment_status: null,
|
||||||
fee_paid_at: null,
|
fee_paid_at: null,
|
||||||
created_at: NOW,
|
created_at: NOW,
|
||||||
@@ -458,6 +497,7 @@ export function buildMockState(): MockState {
|
|||||||
sumupMerchantCode: null, sumupApiKey: null,
|
sumupMerchantCode: null, sumupApiKey: null,
|
||||||
},
|
},
|
||||||
formPaymentOptions: [],
|
formPaymentOptions: [],
|
||||||
|
pendingSubmissions: new Map(),
|
||||||
photos: new Map(),
|
photos: new Map(),
|
||||||
seq,
|
seq,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import type {
|
|||||||
ImportReport,
|
ImportReport,
|
||||||
ImportRowReport,
|
ImportRowReport,
|
||||||
MatrixResponse,
|
MatrixResponse,
|
||||||
|
MecenatInfoSubmit,
|
||||||
|
PendingSubmissionStatus,
|
||||||
PlanningNotificationRequest,
|
PlanningNotificationRequest,
|
||||||
PositionSheetsRequest,
|
PositionSheetsRequest,
|
||||||
PublicFormConfig,
|
PublicFormConfig,
|
||||||
@@ -45,7 +47,7 @@ import type {
|
|||||||
} from '../schema';
|
} from '../schema';
|
||||||
import { getAuthToken } from '../session';
|
import { getAuthToken } from '../session';
|
||||||
import { ApiError, type PlanningApi } from '../types';
|
import { ApiError, type PlanningApi } from '../types';
|
||||||
import { buildMockState, type MockState } from './dataset';
|
import { buildMockState, type MockPendingSubmission, type MockState } from './dataset';
|
||||||
|
|
||||||
// Implémentation en mémoire de `PlanningApi`. Un délai réseau simulé rend le
|
// Implémentation en mémoire de `PlanningApi`. Un délai réseau simulé rend le
|
||||||
// comportement (chargements, invalidations) proche du back-end réel.
|
// comportement (chargements, invalidations) proche du back-end réel.
|
||||||
@@ -159,6 +161,14 @@ const MOCK_PDF_BLOB = () =>
|
|||||||
{ type: 'application/pdf' },
|
{ type: 'application/pdf' },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Contenu factice (pas un vrai classeur) — la génération réelle (openpyxl)
|
||||||
|
// est réservée au back-end ; en mode démonstration, seul le déclenchement
|
||||||
|
// du téléchargement est simulé.
|
||||||
|
const MOCK_XLSX_BLOB = () =>
|
||||||
|
new Blob(['Export bénévoles (démonstration)'], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
});
|
||||||
|
|
||||||
const touch = () => new Date().toISOString().replace(/\.\d+Z$/, '');
|
const touch = () => new Date().toISOString().replace(/\.\d+Z$/, '');
|
||||||
|
|
||||||
const addDays = (day: string, n: number): string => {
|
const addDays = (day: string, n: number): string => {
|
||||||
@@ -197,6 +207,74 @@ function resolveWishes<T extends { id: string }>(
|
|||||||
return wanted;
|
return wanted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Comme `resolveWishes` mais silencieux : filtre les ids toujours valides
|
||||||
|
* sans jamais lever — utilisé à la promotion d'une inscription en attente
|
||||||
|
* (le paiement a déjà eu lieu, voir `promotePending`). */
|
||||||
|
function filterKnownIds<T extends { id: string }>(pool: T[], ids: string[]): string[] {
|
||||||
|
const known = new Set(pool.map((x) => x.id));
|
||||||
|
return [...new Set(ids)].filter((id) => known.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paiement confirmé : crée enfin le `Volunteer` (voir le docstring de
|
||||||
|
* `PendingFormSubmission` côté back-end) puis vide les champs nominatifs de
|
||||||
|
* l'inscription en attente. */
|
||||||
|
function promotePending(pending: MockPendingSubmission): VolunteerRead {
|
||||||
|
const now = touch();
|
||||||
|
const volunteer: VolunteerRead = {
|
||||||
|
id: `v${state.seq++}`,
|
||||||
|
edition_id: state.edition.id,
|
||||||
|
first_name: pending.firstName ?? '',
|
||||||
|
last_name: pending.lastName ?? '',
|
||||||
|
email: pending.email,
|
||||||
|
phone: pending.phone,
|
||||||
|
comment: pending.comment,
|
||||||
|
artist_ids: filterKnownIds(state.artists, pending.artistIds),
|
||||||
|
wished_position_ids: filterKnownIds(state.positions, pending.positionIds),
|
||||||
|
wished_companion_ids: [],
|
||||||
|
has_photo: pending.photo !== null,
|
||||||
|
companion_wish_note: pending.companionWishNote,
|
||||||
|
self_declared_available: pending.available,
|
||||||
|
willing_to_pay_fee: true,
|
||||||
|
fee_payment_label: pending.paymentLabel,
|
||||||
|
fee_amount: pending.paymentAmount,
|
||||||
|
mecenat_address: pending.mecenatAddress,
|
||||||
|
mecenat_city: pending.mecenatCity,
|
||||||
|
mecenat_postal_code: pending.mecenatPostalCode,
|
||||||
|
mecenat_country: pending.mecenatCountry,
|
||||||
|
fee_payment_status: 'PAID',
|
||||||
|
fee_paid_at: now,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
if (pending.photo) state.photos.set(volunteer.id, pending.photo);
|
||||||
|
state.volunteers.push(volunteer);
|
||||||
|
state.availability.set(volunteer.id, []);
|
||||||
|
|
||||||
|
pending.promotedVolunteerId = volunteer.id;
|
||||||
|
pending.status = 'PAID';
|
||||||
|
pending.firstName = null;
|
||||||
|
pending.lastName = null;
|
||||||
|
pending.email = null;
|
||||||
|
pending.phone = null;
|
||||||
|
pending.comment = null;
|
||||||
|
pending.companionWishNote = null;
|
||||||
|
pending.photo = null;
|
||||||
|
pending.mecenatAddress = null;
|
||||||
|
pending.mecenatCity = null;
|
||||||
|
pending.mecenatPostalCode = null;
|
||||||
|
pending.mecenatCountry = null;
|
||||||
|
return volunteer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simule la création (ou relance) d'un checkout SumUp — pas de vrai appel
|
||||||
|
* réseau en mode démonstration, voir `app/api/forms.py:_create_pending_checkout`. */
|
||||||
|
function createPendingCheckout(pending: MockPendingSubmission): string {
|
||||||
|
pending.checkoutId = `co-mock-${state.seq++}`;
|
||||||
|
pending.status = 'PENDING';
|
||||||
|
pending.pollCount = 0;
|
||||||
|
return `https://checkout.sumup.com/pay/mock-${pending.checkoutId}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Vœux de compagnon : comme `resolveWishes`, plus le rejet de soi-même. */
|
/** Vœux de compagnon : comme `resolveWishes`, plus le rejet de soi-même. */
|
||||||
function resolveCompanionWishes(ids: string[], selfId?: string): string[] {
|
function resolveCompanionWishes(ids: string[], selfId?: string): string[] {
|
||||||
const resolved = resolveWishes(state.volunteers, ids, 'volunteer.unknown');
|
const resolved = resolveWishes(state.volunteers, ids, 'volunteer.unknown');
|
||||||
@@ -872,6 +950,10 @@ export function createMockApi(): PlanningApi {
|
|||||||
willing_to_pay_fee: false,
|
willing_to_pay_fee: false,
|
||||||
fee_payment_label: null,
|
fee_payment_label: null,
|
||||||
fee_amount: null,
|
fee_amount: null,
|
||||||
|
mecenat_address: null,
|
||||||
|
mecenat_city: null,
|
||||||
|
mecenat_postal_code: null,
|
||||||
|
mecenat_country: null,
|
||||||
fee_payment_status: null,
|
fee_payment_status: null,
|
||||||
fee_paid_at: null,
|
fee_paid_at: null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@@ -935,6 +1017,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
if (!dataUrl) return null;
|
if (!dataUrl) return null;
|
||||||
return (await fetch(dataUrl)).blob();
|
return (await fetch(dataUrl)).blob();
|
||||||
},
|
},
|
||||||
|
exportXlsx: () => delay(MOCK_XLSX_BLOB()),
|
||||||
planning: (_edition, id) => {
|
planning: (_edition, id) => {
|
||||||
if (!state.volunteers.some((v) => v.id === id)) return notFound();
|
if (!state.volunteers.some((v) => v.id === id)) return notFound();
|
||||||
const entries = state.assignments
|
const entries = state.assignments
|
||||||
@@ -1211,6 +1294,10 @@ export function createMockApi(): PlanningApi {
|
|||||||
willing_to_pay_fee: false,
|
willing_to_pay_fee: false,
|
||||||
fee_payment_label: null,
|
fee_payment_label: null,
|
||||||
fee_amount: null,
|
fee_amount: null,
|
||||||
|
mecenat_address: null,
|
||||||
|
mecenat_city: null,
|
||||||
|
mecenat_postal_code: null,
|
||||||
|
mecenat_country: null,
|
||||||
fee_payment_status: null,
|
fee_payment_status: null,
|
||||||
fee_paid_at: null,
|
fee_paid_at: null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@@ -1545,6 +1632,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
id: o.id,
|
id: o.id,
|
||||||
label: o.label,
|
label: o.label,
|
||||||
amount: o.amount,
|
amount: o.amount,
|
||||||
|
requires_mecenat_info: o.requires_mecenat_info,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -1563,6 +1651,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
label,
|
label,
|
||||||
amount: body.amount,
|
amount: body.amount,
|
||||||
order: body.order ?? 0,
|
order: body.order ?? 0,
|
||||||
|
requires_mecenat_info: body.requires_mecenat_info ?? false,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -1585,6 +1674,9 @@ export function createMockApi(): PlanningApi {
|
|||||||
label,
|
label,
|
||||||
...(body.amount !== undefined ? { amount: body.amount } : {}),
|
...(body.amount !== undefined ? { amount: body.amount } : {}),
|
||||||
...(body.order !== undefined ? { order: body.order } : {}),
|
...(body.order !== undefined ? { order: body.order } : {}),
|
||||||
|
...(body.requires_mecenat_info !== undefined
|
||||||
|
? { requires_mecenat_info: body.requires_mecenat_info }
|
||||||
|
: {}),
|
||||||
updated_at: touch(),
|
updated_at: touch(),
|
||||||
});
|
});
|
||||||
return delay<FormPaymentOptionRead>(option);
|
return delay<FormPaymentOptionRead>(option);
|
||||||
@@ -1604,8 +1696,18 @@ export function createMockApi(): PlanningApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fields = new Set(state.formTemplate.fields);
|
const fields = new Set(state.formTemplate.fields);
|
||||||
|
const email = body.email?.trim() || null;
|
||||||
|
const phone = body.phone?.trim() || null;
|
||||||
|
const available = Boolean(body.available);
|
||||||
|
if (fields.has('EMAIL') && !email) throw new ApiError(422, 'import.row.missing_required_field');
|
||||||
|
if (fields.has('PHONE') && !phone) throw new ApiError(422, 'import.row.missing_required_field');
|
||||||
|
if (fields.has('AVAILABLE') && !available) {
|
||||||
|
throw new ApiError(422, 'import.row.missing_required_field');
|
||||||
|
}
|
||||||
|
|
||||||
const positionIds = fields.has('POSITION_WISHES') ? (body.position_wish_ids ?? []) : [];
|
const positionIds = fields.has('POSITION_WISHES') ? (body.position_wish_ids ?? []) : [];
|
||||||
if (positionIds.length > 2) throw new ApiError(422, 'form.too_many_position_wishes');
|
if (positionIds.length > 2) throw new ApiError(422, 'form.too_many_position_wishes');
|
||||||
|
if (positionIds.length) resolveWishes(state.positions, positionIds, 'position.unknown');
|
||||||
const artistIds = fields.has('ARTIST_WISHES') ? (body.artist_wish_ids ?? []) : [];
|
const artistIds = fields.has('ARTIST_WISHES') ? (body.artist_wish_ids ?? []) : [];
|
||||||
const artistDates = artistIds.map(
|
const artistDates = artistIds.map(
|
||||||
(id) => state.artists.find((a) => a.id === id)?.performance_date,
|
(id) => state.artists.find((a) => a.id === id)?.performance_date,
|
||||||
@@ -1613,6 +1715,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
if (new Set(artistDates).size !== artistDates.length) {
|
if (new Set(artistDates).size !== artistDates.length) {
|
||||||
throw new ApiError(422, 'form.too_many_artists_same_day');
|
throw new ApiError(422, 'form.too_many_artists_same_day');
|
||||||
}
|
}
|
||||||
|
if (artistIds.length) resolveWishes(state.artists, artistIds, 'artist.unknown');
|
||||||
|
|
||||||
let paymentOption: FormPaymentOptionRead | null = null;
|
let paymentOption: FormPaymentOptionRead | null = null;
|
||||||
if (fields.has('WILLING_TO_PAY_FEE') && body.payment_option_id) {
|
if (fields.has('WILLING_TO_PAY_FEE') && body.payment_option_id) {
|
||||||
@@ -1620,53 +1723,157 @@ export function createMockApi(): PlanningApi {
|
|||||||
if (!paymentOption) throw new ApiError(422, 'form.unknown_payment_option');
|
if (!paymentOption) throw new ApiError(422, 'form.unknown_payment_option');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const comment = fields.has('COMMENT') ? body.comment?.trim() || null : null;
|
||||||
|
const companionWishNote = fields.has('COMPANION_WISH')
|
||||||
|
? body.companion_wish_note?.trim() || null
|
||||||
|
: null;
|
||||||
|
const photo =
|
||||||
|
fields.has('PHOTO') && body.photo ? await fileToDataUrl(body.photo) : null;
|
||||||
|
|
||||||
|
if (paymentOption === null) {
|
||||||
|
// Pas de paiement en ligne (case désactivée, ou « je paierai sur
|
||||||
|
// place ») : rien à attendre, le bénévole est créé tout de suite.
|
||||||
const now = touch();
|
const now = touch();
|
||||||
const volunteer: VolunteerRead = {
|
const volunteer: VolunteerRead = {
|
||||||
id: `v${state.seq++}`,
|
id: `v${state.seq++}`,
|
||||||
edition_id: state.edition.id,
|
edition_id: state.edition.id,
|
||||||
first_name: titleCase(body.first_name.trim()),
|
first_name: titleCase(body.first_name.trim()),
|
||||||
last_name: titleCase(body.last_name.trim()),
|
last_name: titleCase(body.last_name.trim()),
|
||||||
email: fields.has('EMAIL') ? body.email?.trim() || null : null,
|
email: fields.has('EMAIL') ? email : null,
|
||||||
phone: fields.has('PHONE') ? body.phone?.trim() || null : null,
|
phone: fields.has('PHONE') ? phone : null,
|
||||||
comment: fields.has('COMMENT') ? body.comment?.trim() || null : null,
|
comment,
|
||||||
artist_ids: resolveWishes(state.artists, artistIds, 'artist.unknown'),
|
artist_ids: resolveWishes(state.artists, artistIds, 'artist.unknown'),
|
||||||
wished_position_ids: resolveWishes(state.positions, positionIds, 'position.unknown'),
|
wished_position_ids: resolveWishes(state.positions, positionIds, 'position.unknown'),
|
||||||
wished_companion_ids: [],
|
wished_companion_ids: [],
|
||||||
has_photo: false,
|
has_photo: photo !== null,
|
||||||
companion_wish_note: fields.has('COMPANION_WISH')
|
companion_wish_note: companionWishNote,
|
||||||
? body.companion_wish_note?.trim() || null
|
self_declared_available: fields.has('AVAILABLE') ? available : false,
|
||||||
: null,
|
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'),
|
||||||
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) || paymentOption !== null
|
|
||||||
: false,
|
|
||||||
fee_payment_label: null,
|
fee_payment_label: null,
|
||||||
fee_amount: null,
|
fee_amount: null,
|
||||||
|
mecenat_address: null,
|
||||||
|
mecenat_city: null,
|
||||||
|
mecenat_postal_code: null,
|
||||||
|
mecenat_country: null,
|
||||||
fee_payment_status: null,
|
fee_payment_status: null,
|
||||||
fee_paid_at: null,
|
fee_paid_at: null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
if (photo) state.photos.set(volunteer.id, photo);
|
||||||
if (fields.has('PHOTO') && body.photo) {
|
|
||||||
volunteer.has_photo = true;
|
|
||||||
state.photos.set(volunteer.id, await fileToDataUrl(body.photo));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simule la création d'un checkout SumUp (pas de vrai appel réseau
|
|
||||||
// en mode démonstration) — voir `app/api/forms.py:_create_fee_checkout`.
|
|
||||||
let checkoutUrl: string | null = null;
|
|
||||||
if (paymentOption && state.formTemplate.sumupMerchantCode && state.formTemplate.sumupApiKey) {
|
|
||||||
volunteer.fee_payment_label = paymentOption.label;
|
|
||||||
volunteer.fee_amount = paymentOption.amount;
|
|
||||||
volunteer.fee_payment_status = 'PENDING';
|
|
||||||
checkoutUrl = `https://checkout.sumup.com/pay/mock-${volunteer.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.volunteers.push(volunteer);
|
state.volunteers.push(volunteer);
|
||||||
state.availability.set(volunteer.id, []);
|
state.availability.set(volunteer.id, []);
|
||||||
return delay<FormSubmissionResult>({
|
return delay<FormSubmissionResult>({
|
||||||
volunteer_id: volunteer.id,
|
volunteer_id: volunteer.id,
|
||||||
|
pending_token: null,
|
||||||
|
requires_mecenat_info: false,
|
||||||
|
sumup_checkout_url: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paiement en ligne choisi : rien en base tant que le paiement n'est
|
||||||
|
// pas confirmé — voir `PendingFormSubmission` côté back-end.
|
||||||
|
const pending: MockPendingSubmission = {
|
||||||
|
id: `pend${state.seq++}`,
|
||||||
|
editionId: state.edition.id,
|
||||||
|
firstName: titleCase(body.first_name.trim()),
|
||||||
|
lastName: titleCase(body.last_name.trim()),
|
||||||
|
email: fields.has('EMAIL') ? email : null,
|
||||||
|
phone: fields.has('PHONE') ? phone : null,
|
||||||
|
comment,
|
||||||
|
companionWishNote,
|
||||||
|
available: fields.has('AVAILABLE') ? available : false,
|
||||||
|
positionIds,
|
||||||
|
artistIds,
|
||||||
|
photo,
|
||||||
|
paymentLabel: paymentOption.label,
|
||||||
|
paymentAmount: paymentOption.amount,
|
||||||
|
requiresMecenatInfo: paymentOption.requires_mecenat_info,
|
||||||
|
mecenatAddress: null,
|
||||||
|
mecenatCity: null,
|
||||||
|
mecenatPostalCode: null,
|
||||||
|
mecenatCountry: null,
|
||||||
|
checkoutId: null,
|
||||||
|
status: null,
|
||||||
|
promotedVolunteerId: null,
|
||||||
|
pollCount: 0,
|
||||||
|
};
|
||||||
|
state.pendingSubmissions.set(pending.id, pending);
|
||||||
|
|
||||||
|
if (pending.requiresMecenatInfo) {
|
||||||
|
return delay<FormSubmissionResult>({
|
||||||
|
volunteer_id: null,
|
||||||
|
pending_token: pending.id,
|
||||||
|
requires_mecenat_info: true,
|
||||||
|
sumup_checkout_url: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const checkoutUrl = createPendingCheckout(pending);
|
||||||
|
return delay<FormSubmissionResult>({
|
||||||
|
volunteer_id: null,
|
||||||
|
pending_token: pending.id,
|
||||||
|
requires_mecenat_info: false,
|
||||||
|
sumup_checkout_url: checkoutUrl,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
submitMecenatInfo: (_edition, token, body: MecenatInfoSubmit) => {
|
||||||
|
const pending = state.pendingSubmissions.get(token) ?? notFound();
|
||||||
|
if (pending.promotedVolunteerId !== null) {
|
||||||
|
throw new ApiError(422, 'form.pending_already_paid');
|
||||||
|
}
|
||||||
|
if (!pending.requiresMecenatInfo) {
|
||||||
|
throw new ApiError(422, 'form.pending_not_awaiting_info');
|
||||||
|
}
|
||||||
|
const address = body.address.trim();
|
||||||
|
const city = body.city.trim();
|
||||||
|
const postalCode = body.postal_code.trim();
|
||||||
|
const country = body.country.trim();
|
||||||
|
if (!address || !city || !postalCode || !country) {
|
||||||
|
throw new ApiError(422, 'import.row.missing_required_field');
|
||||||
|
}
|
||||||
|
pending.mecenatAddress = address;
|
||||||
|
pending.mecenatCity = city;
|
||||||
|
pending.mecenatPostalCode = postalCode;
|
||||||
|
pending.mecenatCountry = country;
|
||||||
|
const checkoutUrl = createPendingCheckout(pending);
|
||||||
|
return delay<FormSubmissionResult>({
|
||||||
|
volunteer_id: null,
|
||||||
|
pending_token: pending.id,
|
||||||
|
requires_mecenat_info: false,
|
||||||
|
sumup_checkout_url: checkoutUrl,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getPendingStatus: (_edition, token) => {
|
||||||
|
const pending = state.pendingSubmissions.get(token) ?? notFound();
|
||||||
|
// Simule le délai de confirmation réel (webhook asynchrone) : la
|
||||||
|
// première vérification reste PENDING, la suivante confirme.
|
||||||
|
if (pending.promotedVolunteerId === null && pending.checkoutId !== null) {
|
||||||
|
if (pending.pollCount === 0) {
|
||||||
|
pending.pollCount += 1;
|
||||||
|
} else {
|
||||||
|
promotePending(pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return delay<PendingSubmissionStatus>({
|
||||||
|
status: pending.promotedVolunteerId !== null ? 'PAID' : (pending.status ?? 'PENDING'),
|
||||||
|
label: pending.paymentLabel,
|
||||||
|
amount: pending.paymentAmount,
|
||||||
|
volunteer_id: pending.promotedVolunteerId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
retryPendingPayment: (_edition, token) => {
|
||||||
|
const pending = state.pendingSubmissions.get(token) ?? notFound();
|
||||||
|
if (pending.promotedVolunteerId !== null) {
|
||||||
|
throw new ApiError(422, 'form.pending_already_paid');
|
||||||
|
}
|
||||||
|
if (pending.requiresMecenatInfo && pending.mecenatAddress === null) {
|
||||||
|
throw new ApiError(422, 'form.pending_not_awaiting_info');
|
||||||
|
}
|
||||||
|
const checkoutUrl = createPendingCheckout(pending);
|
||||||
|
return delay<FormSubmissionResult>({
|
||||||
|
volunteer_id: null,
|
||||||
|
pending_token: pending.id,
|
||||||
|
requires_mecenat_info: false,
|
||||||
sumup_checkout_url: checkoutUrl,
|
sumup_checkout_url: checkoutUrl,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -121,6 +121,12 @@ export interface VolunteerRead {
|
|||||||
fee_amount: number | null;
|
fee_amount: number | null;
|
||||||
fee_payment_status: FeePaymentStatus | null;
|
fee_payment_status: FeePaymentStatus | null;
|
||||||
fee_paid_at: string | null;
|
fee_paid_at: string | null;
|
||||||
|
/** Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un type
|
||||||
|
* de paiement « mécénat ». */
|
||||||
|
mecenat_address: string | null;
|
||||||
|
mecenat_city: string | null;
|
||||||
|
mecenat_postal_code: string | null;
|
||||||
|
mecenat_country: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -606,6 +612,9 @@ export interface FormPaymentOptionRead {
|
|||||||
label: string;
|
label: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
order: number;
|
order: number;
|
||||||
|
/** Type « mécénat » : le bénévole passe par une étape d'adresse postale
|
||||||
|
* avant le paiement (reçu fiscal / cerfa). */
|
||||||
|
requires_mecenat_info: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -614,12 +623,14 @@ export interface FormPaymentOptionCreate {
|
|||||||
label: string;
|
label: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
order?: number;
|
order?: number;
|
||||||
|
requires_mecenat_info?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormPaymentOptionUpdate {
|
export interface FormPaymentOptionUpdate {
|
||||||
label?: string;
|
label?: string;
|
||||||
amount?: number;
|
amount?: number;
|
||||||
order?: number;
|
order?: number;
|
||||||
|
requires_mecenat_info?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicPositionOption {
|
export interface PublicPositionOption {
|
||||||
@@ -639,6 +650,7 @@ export interface PublicPaymentOption {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
requires_mecenat_info: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tout ce qu'il faut pour afficher le formulaire public — jamais plus. */
|
/** Tout ce qu'il faut pour afficher le formulaire public — jamais plus. */
|
||||||
@@ -655,12 +667,40 @@ export interface PublicFormConfig {
|
|||||||
payment_options: PublicPaymentOption[];
|
payment_options: PublicPaymentOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Trois formes possibles, selon le choix de paiement (voir
|
||||||
|
* `app/api/forms.py:submit_public_form`) :
|
||||||
|
* - aucun paiement en ligne (case désactivée, ou « je paierai sur place ») :
|
||||||
|
* `volunteer_id` seul, le bénévole est déjà en base ;
|
||||||
|
* - paiement en ligne, type « mécénat » : `pending_token` +
|
||||||
|
* `requires_mecenat_info=true`, rien en base encore — direction l'étape
|
||||||
|
* d'adresse (`submitMecenatInfo`) ;
|
||||||
|
* - paiement en ligne, type simple : `pending_token` + `sumup_checkout_url`,
|
||||||
|
* direction la page de paiement SumUp. */
|
||||||
export interface FormSubmissionResult {
|
export interface FormSubmissionResult {
|
||||||
volunteer_id: string;
|
volunteer_id: string | null;
|
||||||
/** Lien de paiement hébergé SumUp, si un checkout a pu être créé. */
|
pending_token: string | null;
|
||||||
|
requires_mecenat_info: boolean;
|
||||||
sumup_checkout_url: string | null;
|
sumup_checkout_url: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Étape d'adresse postale d'un type de paiement « mécénat » — tous
|
||||||
|
* obligatoires, nécessaires à l'édition d'un reçu fiscal (cerfa). */
|
||||||
|
export interface MecenatInfoSubmit {
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
postal_code: string;
|
||||||
|
country: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Réponse de la vérification en direct du paiement (page de confirmation) —
|
||||||
|
* interrogée jusqu'à ce que le statut ne soit plus `PENDING`. */
|
||||||
|
export interface PendingSubmissionStatus {
|
||||||
|
status: FeePaymentStatus;
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
volunteer_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Champs du formulaire public — envoyés en `multipart/form-data` (photo
|
/** Champs du formulaire public — envoyés en `multipart/form-data` (photo
|
||||||
* possible). `position_wish_ids`/`artist_wish_ids` : JSON stringifié
|
* possible). `position_wish_ids`/`artist_wish_ids` : JSON stringifié
|
||||||
* (`'["id1","id2"]'`), pas de tableau natif possible en multipart. */
|
* (`'["id1","id2"]'`), pas de tableau natif possible en multipart. */
|
||||||
@@ -673,9 +713,8 @@ export interface PublicFormSubmission {
|
|||||||
comment?: string;
|
comment?: string;
|
||||||
companion_wish_note?: string;
|
companion_wish_note?: string;
|
||||||
available?: boolean;
|
available?: boolean;
|
||||||
willing_to_pay_fee?: boolean;
|
/** Type de paiement choisi (voir `PublicPaymentOption`) — absent = « je
|
||||||
/** Type de paiement choisi (voir `PublicPaymentOption`) — `undefined` =
|
* paierai sur place » (aucun paiement en ligne). */
|
||||||
* aucun (le bénévole ne participe pas à la cotisation). */
|
|
||||||
payment_option_id?: string;
|
payment_option_id?: string;
|
||||||
position_wish_ids?: string[];
|
position_wish_ids?: string[];
|
||||||
artist_wish_ids?: string[];
|
artist_wish_ids?: string[];
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import type {
|
|||||||
LoginRequest,
|
LoginRequest,
|
||||||
MagicLinkRequest,
|
MagicLinkRequest,
|
||||||
MatrixResponse,
|
MatrixResponse,
|
||||||
|
MecenatInfoSubmit,
|
||||||
|
PendingSubmissionStatus,
|
||||||
PlanningNotificationRequest,
|
PlanningNotificationRequest,
|
||||||
PlanningNotificationResult,
|
PlanningNotificationResult,
|
||||||
PositionCreate,
|
PositionCreate,
|
||||||
@@ -89,6 +91,8 @@ export interface PlanningApi {
|
|||||||
planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>;
|
planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>;
|
||||||
/** Photo transmise via le formulaire public, `null` si absente. */
|
/** Photo transmise via le formulaire public, `null` si absente. */
|
||||||
photoBlob(editionId: string, id: string): Promise<Blob | null>;
|
photoBlob(editionId: string, id: string): Promise<Blob | null>;
|
||||||
|
/** Export Excel de la liste des bénévoles (COORDINATION). */
|
||||||
|
exportXlsx(editionId: string): Promise<Blob>;
|
||||||
};
|
};
|
||||||
accounts: {
|
accounts: {
|
||||||
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
|
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
|
||||||
@@ -187,6 +191,15 @@ export interface PlanningApi {
|
|||||||
): Promise<FormPaymentOptionRead>;
|
): Promise<FormPaymentOptionRead>;
|
||||||
remove(editionId: string, id: string): Promise<void>;
|
remove(editionId: string, id: string): Promise<void>;
|
||||||
};
|
};
|
||||||
|
/** Inscription en attente de paiement en ligne — publics, aucune
|
||||||
|
* authentification (voir CLAUDE.md § 2, Paiement de la cotisation). */
|
||||||
|
submitMecenatInfo(
|
||||||
|
editionId: string,
|
||||||
|
token: string,
|
||||||
|
body: MecenatInfoSubmit,
|
||||||
|
): Promise<FormSubmissionResult>;
|
||||||
|
getPendingStatus(editionId: string, token: string): Promise<PendingSubmissionStatus>;
|
||||||
|
retryPendingPayment(editionId: string, token: string): Promise<FormSubmissionResult>;
|
||||||
};
|
};
|
||||||
positionSheets: {
|
positionSheets: {
|
||||||
/** Feuilles de poste PDF (une page par poste et par jour). */
|
/** Feuilles de poste PDF (une page par poste et par jour). */
|
||||||
|
|||||||
@@ -221,6 +221,8 @@
|
|||||||
"config.rules.roleVolunteerDesc": "Ses seules données, lien magique par email",
|
"config.rules.roleVolunteerDesc": "Ses seules données, lien magique par email",
|
||||||
"config.vol.addButton": "AJOUTER MANUELLEMENT",
|
"config.vol.addButton": "AJOUTER MANUELLEMENT",
|
||||||
"config.vol.importButton2": "IMPORTER UN FICHIER",
|
"config.vol.importButton2": "IMPORTER UN FICHIER",
|
||||||
|
"config.vol.exportButton": "EXPORTER EN EXCEL",
|
||||||
|
"config.vol.exporting": "EXPORT…",
|
||||||
"config.vol.search": "Rechercher un nom, un email…",
|
"config.vol.search": "Rechercher un nom, un email…",
|
||||||
"config.vol.emptyList": "Aucun bénévole pour l'instant. Ajoutez-en un ou importez un fichier.",
|
"config.vol.emptyList": "Aucun bénévole pour l'instant. Ajoutez-en un ou importez un fichier.",
|
||||||
"config.vol.noMatch": "Aucun bénévole ne correspond à la recherche.",
|
"config.vol.noMatch": "Aucun bénévole ne correspond à la recherche.",
|
||||||
@@ -385,6 +387,7 @@
|
|||||||
"config.vol.feeStatus.paid": "Cotisation payée ✓",
|
"config.vol.feeStatus.paid": "Cotisation payée ✓",
|
||||||
"config.vol.feeStatus.failed": "Cotisation : paiement échoué",
|
"config.vol.feeStatus.failed": "Cotisation : paiement échoué",
|
||||||
"config.vol.feeStatus.expired": "Cotisation : lien de paiement expiré",
|
"config.vol.feeStatus.expired": "Cotisation : lien de paiement expiré",
|
||||||
|
"config.vol.mecenatAddress": "Adresse (cerfa)",
|
||||||
"form.field.photo": "Une magnifique photo de toi :",
|
"form.field.photo": "Une magnifique photo de toi :",
|
||||||
"form.field.comment": "Commentaire",
|
"form.field.comment": "Commentaire",
|
||||||
"form.link.title": "Lien du formulaire",
|
"form.link.title": "Lien du formulaire",
|
||||||
@@ -415,6 +418,8 @@
|
|||||||
"form.customize.paymentOptions.title": "Types de paiement",
|
"form.customize.paymentOptions.title": "Types de paiement",
|
||||||
"form.customize.paymentOptions.label": "Libellé (ex. Cotisation classique, Mécénat…)",
|
"form.customize.paymentOptions.label": "Libellé (ex. Cotisation classique, Mécénat…)",
|
||||||
"form.customize.paymentOptions.add": "Ajouter",
|
"form.customize.paymentOptions.add": "Ajouter",
|
||||||
|
"form.customize.paymentOptions.mecenat": "Mécénat",
|
||||||
|
"form.customize.paymentOptions.mecenat.help": "Avant de payer, le bénévole passe par une étape d'adresse postale (reçu fiscal / cerfa).",
|
||||||
|
|
||||||
"publicForm.title": "Inscription bénévole",
|
"publicForm.title": "Inscription bénévole",
|
||||||
"publicForm.positionWishes.count": "{count} / {max}",
|
"publicForm.positionWishes.count": "{count} / {max}",
|
||||||
@@ -422,11 +427,9 @@
|
|||||||
"publicForm.field.available": "Je suis bénévole les deux jours (vendredi 30 et samedi 31 oct.)",
|
"publicForm.field.available": "Je suis bénévole les deux jours (vendredi 30 et samedi 31 oct.)",
|
||||||
"publicForm.field.willingToPayFee": "Je participe à la cotisation bénévole",
|
"publicForm.field.willingToPayFee": "Je participe à la cotisation bénévole",
|
||||||
"publicForm.field.paymentOption": "Cotisation",
|
"publicForm.field.paymentOption": "Cotisation",
|
||||||
"publicForm.paymentOption.none": "Je ne participe pas",
|
"publicForm.paymentOption.onSite": "Je paierai sur place",
|
||||||
"publicForm.photo.squareHint": "Recadrée automatiquement en carré.",
|
"publicForm.photo.squareHint": "Recadrée automatiquement en carré.",
|
||||||
"publicForm.sumup.afterSubmitHint": "Un lien de paiement en ligne (SumUp) te sera proposé juste après l'envoi de ton inscription.",
|
"publicForm.sumup.afterSubmitHint": "Un lien de paiement en ligne (SumUp) te sera proposé juste après l'envoi de ton inscription.",
|
||||||
"publicForm.sumup.hint": "Le paiement se fait en ligne, via SumUp.",
|
|
||||||
"publicForm.sumup.cta": "Payer ma cotisation ↗",
|
|
||||||
"publicForm.captcha.disabled": "(Vérification anti-robot non configurée sur cette instance.)",
|
"publicForm.captcha.disabled": "(Vérification anti-robot non configurée sur cette instance.)",
|
||||||
"publicForm.submitting": "Envoi…",
|
"publicForm.submitting": "Envoi…",
|
||||||
"publicForm.submit": "Envoyer mon inscription",
|
"publicForm.submit": "Envoyer mon inscription",
|
||||||
@@ -434,6 +437,25 @@
|
|||||||
"publicForm.success.body": "Merci ! Ton inscription a bien été enregistrée. La coordination du festival prendra contact avec toi.",
|
"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.title": "Inscriptions fermées",
|
||||||
"publicForm.closed.body": "Les inscriptions bénévoles ne sont pas ouvertes pour le moment. Reviens un peu plus tard !",
|
"publicForm.closed.body": "Les inscriptions bénévoles ne sont pas ouvertes pour le moment. Reviens un peu plus tard !",
|
||||||
|
|
||||||
|
"publicForm.mecenat.title": "Reçu fiscal (mécénat)",
|
||||||
|
"publicForm.mecenat.hint": "Ces informations servent uniquement à établir ton reçu fiscal (cerfa) une fois le paiement effectué.",
|
||||||
|
"publicForm.mecenat.afterSubmitHint": "Une étape supplémentaire (adresse) te sera proposée juste après l'envoi de ton inscription, pour établir ton reçu fiscal.",
|
||||||
|
"publicForm.mecenat.address": "Adresse",
|
||||||
|
"publicForm.mecenat.city": "Ville",
|
||||||
|
"publicForm.mecenat.postalCode": "Code postal",
|
||||||
|
"publicForm.mecenat.country": "Pays",
|
||||||
|
"publicForm.mecenat.submit": "Continuer vers le paiement",
|
||||||
|
|
||||||
|
"publicForm.retry.title": "Paiement indisponible",
|
||||||
|
"publicForm.retry.body": "Le paiement en ligne n'a pas pu être initié pour le moment. Tes informations sont conservées : réessaie dans quelques instants.",
|
||||||
|
"publicForm.retry.cta": "Réessayer le paiement",
|
||||||
|
"publicForm.retry.stillUnavailable": "Toujours indisponible pour le moment, réessaie un peu plus tard.",
|
||||||
|
|
||||||
|
"publicForm.confirmation.title": "Confirmation du paiement",
|
||||||
|
"publicForm.confirmation.pending": "Vérification du paiement en cours…",
|
||||||
|
"publicForm.confirmation.paid": "Merci ! Cotisation « {label} » de {amount} € payée. Ton inscription est enregistrée.",
|
||||||
|
"publicForm.confirmation.failed": "Le paiement n'a pas abouti (refusé ou expiré).",
|
||||||
"form.closed": "Les inscriptions sont actuellement fermées.",
|
"form.closed": "Les inscriptions sont actuellement fermées.",
|
||||||
"form.captcha_failed": "Vérification anti-robot invalide, réessaie.",
|
"form.captcha_failed": "Vérification anti-robot invalide, réessaie.",
|
||||||
"form.invalid_payload": "Données du formulaire invalides.",
|
"form.invalid_payload": "Données du formulaire invalides.",
|
||||||
@@ -445,6 +467,8 @@
|
|||||||
"form.invalid_sumup_amount": "Le montant doit être supérieur à 0.",
|
"form.invalid_sumup_amount": "Le montant doit être supérieur à 0.",
|
||||||
"form.unknown_payment_option": "Ce type de paiement n'existe pas pour cette édition.",
|
"form.unknown_payment_option": "Ce type de paiement n'existe pas pour cette édition.",
|
||||||
"form.duplicate_payment_option_label": "Un type de paiement porte déjà ce libellé.",
|
"form.duplicate_payment_option_label": "Un type de paiement porte déjà ce libellé.",
|
||||||
|
"form.pending_already_paid": "Cette inscription est déjà payée.",
|
||||||
|
"form.pending_not_awaiting_info": "Cette inscription n'attend pas ces informations.",
|
||||||
"form.too_many_position_wishes": "Deux vœux de poste au maximum.",
|
"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.",
|
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.",
|
||||||
"position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.",
|
"position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.",
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ function PaymentOptionsForm() {
|
|||||||
const remove = useDeleteFormPaymentOption();
|
const remove = useDeleteFormPaymentOption();
|
||||||
const [label, setLabel] = useState('');
|
const [label, setLabel] = useState('');
|
||||||
const [amount, setAmount] = useState('');
|
const [amount, setAmount] = useState('');
|
||||||
|
const [requiresMecenatInfo, setRequiresMecenatInfo] = useState(false);
|
||||||
|
|
||||||
if (options.isLoading) return <div className="font-mono text-[10px] opacity-60">…</div>;
|
if (options.isLoading) return <div className="font-mono text-[10px] opacity-60">…</div>;
|
||||||
if (options.error) return <ErrorNote error={options.error} />;
|
if (options.error) return <ErrorNote error={options.error} />;
|
||||||
@@ -363,6 +364,20 @@ function PaymentOptionsForm() {
|
|||||||
className="w-[90px] border-2 border-ink bg-paper px-1 py-1 font-mono text-[11px]"
|
className="w-[90px] border-2 border-ink bg-paper px-1 py-1 font-mono text-[11px]"
|
||||||
/>
|
/>
|
||||||
<span className="font-mono text-[10px] opacity-60">€</span>
|
<span className="font-mono text-[10px] opacity-60">€</span>
|
||||||
|
<label
|
||||||
|
className="flex flex-none cursor-pointer items-center gap-1 font-mono text-[9.5px] opacity-70"
|
||||||
|
title={t('form.customize.paymentOptions.mecenat.help')}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={o.requires_mecenat_info}
|
||||||
|
onChange={(e) =>
|
||||||
|
update.mutate({ id: o.id, body: { requires_mecenat_info: e.target.checked } })
|
||||||
|
}
|
||||||
|
className="h-[13px] w-[13px] accent-ink"
|
||||||
|
/>
|
||||||
|
{t('form.customize.paymentOptions.mecenat')}
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => remove.mutate(o.id)}
|
onClick={() => remove.mutate(o.id)}
|
||||||
@@ -391,13 +406,36 @@ function PaymentOptionsForm() {
|
|||||||
className="w-[90px] border-2 border-ink bg-paper px-1 py-1 font-mono text-[11px]"
|
className="w-[90px] border-2 border-ink bg-paper px-1 py-1 font-mono text-[11px]"
|
||||||
/>
|
/>
|
||||||
<span className="font-mono text-[10px] opacity-60">€</span>
|
<span className="font-mono text-[10px] opacity-60">€</span>
|
||||||
|
<label
|
||||||
|
className="flex flex-none cursor-pointer items-center gap-1 font-mono text-[9.5px] opacity-70"
|
||||||
|
title={t('form.customize.paymentOptions.mecenat.help')}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={requiresMecenatInfo}
|
||||||
|
onChange={(e) => setRequiresMecenatInfo(e.target.checked)}
|
||||||
|
className="h-[13px] w-[13px] accent-ink"
|
||||||
|
/>
|
||||||
|
{t('form.customize.paymentOptions.mecenat')}
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!label.trim() || !(Number(amount) > 0) || create.isPending}
|
disabled={!label.trim() || !(Number(amount) > 0) || create.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
create.mutate(
|
create.mutate(
|
||||||
{ label: label.trim(), amount: Number(amount), order: rows.length },
|
{
|
||||||
{ onSuccess: () => { setLabel(''); setAmount(''); } },
|
label: label.trim(),
|
||||||
|
amount: Number(amount),
|
||||||
|
order: rows.length,
|
||||||
|
requires_mecenat_info: requiresMecenatInfo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setLabel('');
|
||||||
|
setAmount('');
|
||||||
|
setRequiresMecenatInfo(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="h-[26px] flex-none cursor-pointer border-2 border-ink bg-ink px-2 font-display text-[10px] font-extrabold uppercase text-cream hover:bg-yellow hover:text-ink disabled:opacity-50"
|
className="h-[26px] flex-none cursor-pointer border-2 border-ink bg-ink px-2 font-display text-[10px] font-extrabold uppercase text-cream hover:bg-yellow hover:text-ink disabled:opacity-50"
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { ApiError } from '@/api';
|
import { ApiError } from '@/api';
|
||||||
import { useFormLogoUrl, usePublicFormConfig, useSubmitPublicForm } from '@/api/hooks';
|
import {
|
||||||
|
useFormLogoUrl,
|
||||||
|
usePendingStatus,
|
||||||
|
usePublicFormConfig,
|
||||||
|
useRetryPendingPayment,
|
||||||
|
useSubmitMecenatInfo,
|
||||||
|
useSubmitPublicForm,
|
||||||
|
} from '@/api/hooks';
|
||||||
import type { PublicArtistOption } from '@/api/schema';
|
import type { PublicArtistOption } from '@/api/schema';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
import { Btn, FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui';
|
import { Btn, FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui';
|
||||||
@@ -125,7 +132,209 @@ function FormLogo({ url }: { url: string | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Entrée du formulaire public quand l'URL porte `?paiement=<jeton>` (retour
|
||||||
|
* du bénévole depuis la page de paiement SumUp) — sinon le formulaire
|
||||||
|
* classique (`PublicSignupForm`). Un seul hook ici (lecture de l'URL, figée
|
||||||
|
* au montage) : chaque vue gère ensuite ses propres requêtes. */
|
||||||
export function PublicSignupPage() {
|
export function PublicSignupPage() {
|
||||||
|
// `typeof window` : rendu aussi en SSR (voir scripts/smoke.tsx), où
|
||||||
|
// `window` n'existe pas — contrairement aux autres pages, rien ici ne
|
||||||
|
// dépend d'abord du chargement d'une requête pour en différer l'accès.
|
||||||
|
const token = useMemo(
|
||||||
|
() =>
|
||||||
|
typeof window === 'undefined'
|
||||||
|
? null
|
||||||
|
: new URLSearchParams(window.location.search).get('paiement'),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
if (token) return <PaymentConfirmationView token={token} />;
|
||||||
|
return <PublicSignupForm />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interrogée en direct (voir CLAUDE.md § 2, Paiement de la cotisation)
|
||||||
|
* jusqu'à confirmation — le bénévole n'est créé en base qu'à ce moment-là,
|
||||||
|
* jamais avant. */
|
||||||
|
function PaymentConfirmationView({ token }: { token: string }) {
|
||||||
|
const status = usePendingStatus(token);
|
||||||
|
const retry = useRetryPendingPayment();
|
||||||
|
const [retryError, setRetryError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleRetry = async () => {
|
||||||
|
setRetryError(null);
|
||||||
|
try {
|
||||||
|
const result = await retry.mutateAsync(token);
|
||||||
|
if (result.sumup_checkout_url) {
|
||||||
|
window.location.href = result.sumup_checkout_url;
|
||||||
|
} else {
|
||||||
|
setRetryError(t('publicForm.retry.stillUnavailable'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setRetryError(errorMessage(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader>{t('publicForm.confirmation.title')}</PanelHeader>
|
||||||
|
<div className="flex flex-col gap-3 p-4">
|
||||||
|
{status.isLoading ? <Loading /> : null}
|
||||||
|
{status.error ? <ErrorNote error={status.error} /> : null}
|
||||||
|
{status.data?.status === 'PENDING' ? (
|
||||||
|
<p className="font-mono text-[12px] leading-relaxed">
|
||||||
|
{t('publicForm.confirmation.pending')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{status.data?.status === 'PAID' ? (
|
||||||
|
<p className="font-mono text-[12px] leading-relaxed">
|
||||||
|
{t('publicForm.confirmation.paid', {
|
||||||
|
label: status.data.label,
|
||||||
|
amount: status.data.amount,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{status.data && (status.data.status === 'FAILED' || status.data.status === 'EXPIRED') ? (
|
||||||
|
<>
|
||||||
|
<p className="font-mono text-[12px] leading-relaxed">
|
||||||
|
{t('publicForm.confirmation.failed')}
|
||||||
|
</p>
|
||||||
|
{retryError ? (
|
||||||
|
<p className="font-mono text-[11px] font-bold text-orange-dark">{retryError}</p>
|
||||||
|
) : null}
|
||||||
|
<Btn onClick={handleRetry} disabled={retry.isPending}>
|
||||||
|
{retry.isPending ? t('publicForm.submitting') : t('publicForm.retry.cta')}
|
||||||
|
</Btn>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Étape d'adresse postale d'un type de paiement « mécénat », entre la
|
||||||
|
* soumission du formulaire principal et le paiement lui-même — nécessaire à
|
||||||
|
* l'édition d'un reçu fiscal (cerfa) ultérieurement. */
|
||||||
|
function MecenatInfoStep({
|
||||||
|
token,
|
||||||
|
logoUrl,
|
||||||
|
onCheckoutUrl,
|
||||||
|
onUnavailable,
|
||||||
|
}: {
|
||||||
|
token: string;
|
||||||
|
logoUrl: string | null;
|
||||||
|
onCheckoutUrl: (url: string) => void;
|
||||||
|
onUnavailable: () => void;
|
||||||
|
}) {
|
||||||
|
const submitMecenat = useSubmitMecenatInfo();
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [city, setCity] = useState('');
|
||||||
|
const [postalCode, setPostalCode] = useState('');
|
||||||
|
const [country, setCountry] = useState('France');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
address.trim().length > 0 &&
|
||||||
|
city.trim().length > 0 &&
|
||||||
|
postalCode.trim().length > 0 &&
|
||||||
|
country.trim().length > 0 &&
|
||||||
|
!submitMecenat.isPending;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await submitMecenat.mutateAsync({
|
||||||
|
token,
|
||||||
|
body: { address, city, postal_code: postalCode, country },
|
||||||
|
});
|
||||||
|
if (result.sumup_checkout_url) onCheckoutUrl(result.sumup_checkout_url);
|
||||||
|
else onUnavailable();
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||||
|
<FormLogo url={logoUrl} />
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader>{t('publicForm.mecenat.title')}</PanelHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 p-4">
|
||||||
|
<p className="font-mono text-[11px] leading-relaxed opacity-70">
|
||||||
|
{t('publicForm.mecenat.hint')}
|
||||||
|
</p>
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('publicForm.mecenat.address')}</FieldLabel>
|
||||||
|
<TextInput value={address} onChange={setAddress} />
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('publicForm.mecenat.postalCode')}</FieldLabel>
|
||||||
|
<TextInput mono value={postalCode} onChange={setPostalCode} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('publicForm.mecenat.city')}</FieldLabel>
|
||||||
|
<TextInput value={city} onChange={setCity} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('publicForm.mecenat.country')}</FieldLabel>
|
||||||
|
<TextInput value={country} onChange={setCountry} />
|
||||||
|
</label>
|
||||||
|
{error ? (
|
||||||
|
<p className="font-mono text-[11px] font-bold text-orange-dark">{error}</p>
|
||||||
|
) : null}
|
||||||
|
<Btn type="submit" disabled={!canSubmit}>
|
||||||
|
{submitMecenat.isPending ? t('publicForm.submitting') : t('publicForm.mecenat.submit')}
|
||||||
|
</Btn>
|
||||||
|
</form>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Le checkout SumUp n'a pas pu être créé (panne réseau/API au moment de
|
||||||
|
* l'appel) — les informations déjà saisies restent gardées, pas besoin de
|
||||||
|
* tout ressaisir pour relancer. */
|
||||||
|
function CheckoutRetryPanel({ token, logoUrl }: { token: string; logoUrl: string | null }) {
|
||||||
|
const retry = useRetryPendingPayment();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleRetry = async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await retry.mutateAsync(token);
|
||||||
|
if (result.sumup_checkout_url) {
|
||||||
|
window.location.href = result.sumup_checkout_url;
|
||||||
|
} else {
|
||||||
|
setError(t('publicForm.retry.stillUnavailable'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||||
|
<FormLogo url={logoUrl} />
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader>{t('publicForm.retry.title')}</PanelHeader>
|
||||||
|
<div className="flex flex-col gap-3 p-4">
|
||||||
|
<p className="font-mono text-[12px] leading-relaxed">{t('publicForm.retry.body')}</p>
|
||||||
|
{error ? (
|
||||||
|
<p className="font-mono text-[11px] font-bold text-orange-dark">{error}</p>
|
||||||
|
) : null}
|
||||||
|
<Btn onClick={handleRetry} disabled={retry.isPending}>
|
||||||
|
{retry.isPending ? t('publicForm.submitting') : t('publicForm.retry.cta')}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PublicSignupForm() {
|
||||||
const config = usePublicFormConfig();
|
const config = usePublicFormConfig();
|
||||||
const submit = useSubmitPublicForm();
|
const submit = useSubmitPublicForm();
|
||||||
const logoUrl = useFormLogoUrl(config.data?.has_logo ?? false, config.dataUpdatedAt);
|
const logoUrl = useFormLogoUrl(config.data?.has_logo ?? false, config.dataUpdatedAt);
|
||||||
@@ -146,7 +355,8 @@ export function PublicSignupPage() {
|
|||||||
const [captchaToken, setCaptchaToken] = useState('');
|
const [captchaToken, setCaptchaToken] = useState('');
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [volunteerId, setVolunteerId] = useState<string | null>(null);
|
const [volunteerId, setVolunteerId] = useState<string | null>(null);
|
||||||
const [sumupCheckoutUrl, setSumupCheckoutUrl] = useState<string | null>(null);
|
const [pendingToken, setPendingToken] = useState<string | null>(null);
|
||||||
|
const [needsMecenatInfo, setNeedsMecenatInfo] = useState(false);
|
||||||
|
|
||||||
const artistsByDay = useMemo(
|
const artistsByDay = useMemo(
|
||||||
() => groupArtistsByDay(config.data?.artists ?? []),
|
() => groupArtistsByDay(config.data?.artists ?? []),
|
||||||
@@ -182,29 +392,31 @@ export function PublicSignupPage() {
|
|||||||
<FormLogo url={logoUrl} />
|
<FormLogo url={logoUrl} />
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeader>{t('publicForm.success.title')}</PanelHeader>
|
<PanelHeader>{t('publicForm.success.title')}</PanelHeader>
|
||||||
<div className="flex flex-col gap-3 p-4">
|
<p className="p-4 font-mono text-[12px] leading-relaxed">{t('publicForm.success.body')}</p>
|
||||||
<p className="font-mono text-[12px] leading-relaxed">{t('publicForm.success.body')}</p>
|
|
||||||
{sumupCheckoutUrl ? (
|
|
||||||
<div className="flex flex-wrap items-center gap-[10px] border-2 border-ink bg-yellow-soft px-[12px] py-[10px]">
|
|
||||||
<span className="min-w-0 flex-1 font-mono text-[11px] leading-relaxed">
|
|
||||||
{t('publicForm.sumup.hint')}
|
|
||||||
</span>
|
|
||||||
<a
|
|
||||||
href={sumupCheckoutUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex flex-none cursor-pointer items-center justify-center gap-2 border-2 border-ink bg-yellow px-3 py-2 font-display text-[11px] font-extrabold uppercase tracking-wide shadow-brutal-sm hover:bg-green hover:text-white"
|
|
||||||
>
|
|
||||||
{t('publicForm.sumup.cta')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pendingToken && needsMecenatInfo) {
|
||||||
|
return (
|
||||||
|
<MecenatInfoStep
|
||||||
|
token={pendingToken}
|
||||||
|
logoUrl={logoUrl}
|
||||||
|
onCheckoutUrl={(url) => {
|
||||||
|
window.location.href = url;
|
||||||
|
}}
|
||||||
|
onUnavailable={() => setNeedsMecenatInfo(false)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingToken) {
|
||||||
|
// Le checkout n'a pas pu être créé (formulaire principal ou étape
|
||||||
|
// mécénat) — rien n'a été perdu, on propose de réessayer.
|
||||||
|
return <CheckoutRetryPanel token={pendingToken} logoUrl={logoUrl} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!config.data.is_open) {
|
if (!config.data.is_open) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||||
@@ -237,9 +449,15 @@ export function PublicSignupPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Tout champ activé par la coordination est obligatoire — le formulaire
|
||||||
|
// public n'est plus une simple collecte d'infos facultatives.
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
firstName.trim().length > 0 &&
|
firstName.trim().length > 0 &&
|
||||||
lastName.trim().length > 0 &&
|
lastName.trim().length > 0 &&
|
||||||
|
(!fields.has('EMAIL') || email.trim().length > 0) &&
|
||||||
|
(!fields.has('PHONE') || phone.trim().length > 0) &&
|
||||||
|
(!fields.has('AVAILABLE') || available) &&
|
||||||
|
(!fields.has('WILLING_TO_PAY_FEE') || config.data.sumup_enabled || willingToPayFee) &&
|
||||||
(toggleEnabled ? captchaToken.length > 0 : true) &&
|
(toggleEnabled ? captchaToken.length > 0 : true) &&
|
||||||
!submit.isPending;
|
!submit.isPending;
|
||||||
|
|
||||||
@@ -256,7 +474,6 @@ export function PublicSignupPage() {
|
|||||||
comment: fields.has('COMMENT') ? comment : undefined,
|
comment: fields.has('COMMENT') ? comment : undefined,
|
||||||
companion_wish_note: fields.has('COMPANION_WISH') ? companionNote : undefined,
|
companion_wish_note: fields.has('COMPANION_WISH') ? companionNote : undefined,
|
||||||
available: fields.has('AVAILABLE') ? available : undefined,
|
available: fields.has('AVAILABLE') ? available : undefined,
|
||||||
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE') ? willingToPayFee : undefined,
|
|
||||||
payment_option_id:
|
payment_option_id:
|
||||||
fields.has('WILLING_TO_PAY_FEE') && config.data.sumup_enabled
|
fields.has('WILLING_TO_PAY_FEE') && config.data.sumup_enabled
|
||||||
? paymentOptionId || undefined
|
? paymentOptionId || undefined
|
||||||
@@ -265,8 +482,18 @@ export function PublicSignupPage() {
|
|||||||
artist_wish_ids: fields.has('ARTIST_WISHES') ? Object.values(artistByDay) : undefined,
|
artist_wish_ids: fields.has('ARTIST_WISHES') ? Object.values(artistByDay) : undefined,
|
||||||
photo: fields.has('PHOTO') ? photo : undefined,
|
photo: fields.has('PHOTO') ? photo : undefined,
|
||||||
});
|
});
|
||||||
|
if (result.volunteer_id) {
|
||||||
setVolunteerId(result.volunteer_id);
|
setVolunteerId(result.volunteer_id);
|
||||||
setSumupCheckoutUrl(result.sumup_checkout_url);
|
} else if (result.pending_token) {
|
||||||
|
setPendingToken(result.pending_token);
|
||||||
|
if (result.requires_mecenat_info) {
|
||||||
|
setNeedsMecenatInfo(true);
|
||||||
|
} else if (result.sumup_checkout_url) {
|
||||||
|
window.location.href = result.sumup_checkout_url;
|
||||||
|
}
|
||||||
|
// Sinon (checkout indisponible) : le rendu bascule sur
|
||||||
|
// CheckoutRetryPanel au prochain rendu, via `pendingToken`.
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSubmitError(errorMessage(err));
|
setSubmitError(errorMessage(err));
|
||||||
}
|
}
|
||||||
@@ -410,7 +637,7 @@ export function PublicSignupPage() {
|
|||||||
onChange={() => setPaymentOptionId('')}
|
onChange={() => setPaymentOptionId('')}
|
||||||
className="h-[15px] w-[15px] accent-ink"
|
className="h-[15px] w-[15px] accent-ink"
|
||||||
/>
|
/>
|
||||||
{t('publicForm.paymentOption.none')}
|
{t('publicForm.paymentOption.onSite')}
|
||||||
</label>
|
</label>
|
||||||
{config.data.payment_options.map((o) => (
|
{config.data.payment_options.map((o) => (
|
||||||
<label
|
<label
|
||||||
@@ -430,7 +657,10 @@ export function PublicSignupPage() {
|
|||||||
</div>
|
</div>
|
||||||
{paymentOptionId ? (
|
{paymentOptionId ? (
|
||||||
<p className="mt-[6px] font-mono text-[10.5px] opacity-70">
|
<p className="mt-[6px] font-mono text-[10.5px] opacity-70">
|
||||||
{t('publicForm.sumup.afterSubmitHint')}
|
{config.data.payment_options.find((o) => o.id === paymentOptionId)
|
||||||
|
?.requires_mecenat_info
|
||||||
|
? t('publicForm.mecenat.afterSubmitHint')
|
||||||
|
: t('publicForm.sumup.afterSubmitHint')}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
useDeleteVolunteer,
|
useDeleteVolunteer,
|
||||||
useEdition,
|
useEdition,
|
||||||
useExecuteImport,
|
useExecuteImport,
|
||||||
|
useExportVolunteers,
|
||||||
usePositions,
|
usePositions,
|
||||||
useReplaceAvailability,
|
useReplaceAvailability,
|
||||||
useUpdateAccount,
|
useUpdateAccount,
|
||||||
@@ -187,7 +188,7 @@ function PublicFormAnswers({
|
|||||||
volunteer.has_photo ||
|
volunteer.has_photo ||
|
||||||
Boolean(volunteer.companion_wish_note) ||
|
Boolean(volunteer.companion_wish_note) ||
|
||||||
volunteer.self_declared_available ||
|
volunteer.self_declared_available ||
|
||||||
volunteer.willing_to_pay_fee;
|
volunteer.fee_payment_status !== null;
|
||||||
if (!hasAnything) return null;
|
if (!hasAnything) return null;
|
||||||
|
|
||||||
const feeStatusKey = volunteer.fee_payment_status
|
const feeStatusKey = volunteer.fee_payment_status
|
||||||
@@ -230,15 +231,6 @@ function PublicFormAnswers({
|
|||||||
/>
|
/>
|
||||||
<span className="font-mono text-[11px]">{t('form.field.available')}</span>
|
<span className="font-mono text-[11px]">{t('form.field.available')}</span>
|
||||||
</label>
|
</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>
|
|
||||||
{feeStatusKey ? (
|
{feeStatusKey ? (
|
||||||
<span className="border-2 border-ink bg-cream px-[8px] py-[3px] font-mono text-[10.5px] font-bold">
|
<span className="border-2 border-ink bg-cream px-[8px] py-[3px] font-mono text-[10.5px] font-bold">
|
||||||
{volunteer.fee_payment_label ? `${volunteer.fee_payment_label} (${volunteer.fee_amount} €) — ` : ''}
|
{volunteer.fee_payment_label ? `${volunteer.fee_payment_label} (${volunteer.fee_amount} €) — ` : ''}
|
||||||
@@ -246,6 +238,13 @@ function PublicFormAnswers({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{volunteer.mecenat_address ? (
|
||||||
|
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
|
||||||
|
{t('config.vol.mecenatAddress')} : {volunteer.mecenat_address}, {volunteer.mecenat_postal_code}{' '}
|
||||||
|
{volunteer.mecenat_city}, {volunteer.mecenat_country}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -489,6 +488,7 @@ export function ConfigVolunteers() {
|
|||||||
const positions = usePositions();
|
const positions = usePositions();
|
||||||
const updateVolunteer = useUpdateVolunteer();
|
const updateVolunteer = useUpdateVolunteer();
|
||||||
const deleteVolunteer = useDeleteVolunteer();
|
const deleteVolunteer = useDeleteVolunteer();
|
||||||
|
const exportVolunteers = useExportVolunteers();
|
||||||
|
|
||||||
const [mode, setMode] = useState<Mode>('list');
|
const [mode, setMode] = useState<Mode>('list');
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
@@ -522,6 +522,19 @@ export function ConfigVolunteers() {
|
|||||||
const rows = filtered.slice(0, 60);
|
const rows = filtered.slice(0, 60);
|
||||||
const patch = (body: Parameters<typeof updateVolunteer.mutate>[0]['body']) =>
|
const patch = (body: Parameters<typeof updateVolunteer.mutate>[0]['body']) =>
|
||||||
selected && updateVolunteer.mutate({ id: selected.id, body });
|
selected && updateVolunteer.mutate({ id: selected.id, body });
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
const blob = await exportVolunteers.mutateAsync();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'benevoles.xlsx';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
// Erreur exposée via exportVolunteers.isError / .error, affichée plus bas.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (mode === 'add') {
|
if (mode === 'add') {
|
||||||
return <AddVolunteerForm onClose={() => setMode('list')} />;
|
return <AddVolunteerForm onClose={() => setMode('list')} />;
|
||||||
@@ -556,7 +569,18 @@ export function ConfigVolunteers() {
|
|||||||
>
|
>
|
||||||
↓ {t('config.vol.importButton2')}
|
↓ {t('config.vol.importButton2')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={exportVolunteers.isPending}
|
||||||
|
className="cursor-pointer border-[3px] border-ink bg-cream px-[14px] py-3 font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-yellow disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
↑ {exportVolunteers.isPending ? t('config.vol.exporting') : t('config.vol.exportButton')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{exportVolunteers.isError ? (
|
||||||
|
<ErrorNote error={exportVolunteers.error} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-none border-[3px] border-ink bg-paper px-[14px] py-3 shadow-brutal-lg">
|
<div className="flex-none border-[3px] border-ink bg-paper px-[14px] py-3 shadow-brutal-lg">
|
||||||
<div className="font-mono text-[24px] font-bold">{total}</div>
|
<div className="font-mono text-[24px] font-bold">{total}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user