Lien d'inscription protégé par jeton, reçus fiscaux mécénat, infobulles et revue de sécurité
ci-cd / build-backend (push) Successful in 1m12s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s

- Lien d'inscription protégé par un jeton (FormTemplate.access_token,
  /inscription/<token>), avec régénération depuis l'écran de configuration.
- Reçus fiscaux mécénat (cerfa 11580*05) : un PDF par bénévole ayant payé en
  mécénat, donateur/montant (chiffres et toutes lettres en français,
  app/mecenat/number_words.py)/date complétés par-dessus le modèle officiel.
- Jeu de démonstration : bénévoles avec cotisation classique ou mécénat payée.
- Infobulles (HelpTip et détail de cellule de la grille des besoins)
  repositionnées en position: fixed via JS, bornées à l'écran.
- Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une
  URL (fuyait le jeton d'inscription), sujet retiré du log de remise des
  emails (pouvait porter un nom de bénévole), photo du formulaire public
  revalidée par décodage réel avant stockage, export Excel protégé contre
  l'injection de formule, sondage du statut de paiement limité en
  fréquence, pyjwt mis à jour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-02 07:47:17 +02:00
co-authored by Claude Sonnet 5
parent a2b748566b
commit 096b7cd26a
44 changed files with 1228 additions and 156 deletions
+57 -11
View File
@@ -203,9 +203,40 @@ les tables sauf les tables d'association.
sur l'écran de configuration des bénévoles (`ConfigVolunteers.tsx`), même 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 → mécanique de téléchargement que les badges/feuilles de poste (blob →
`<a download>` généré côté client, voir `BadgesPage.tsx`). `<a download>` généré côté client, voir `BadgesPage.tsx`).
- **Reçus fiscaux mécénat** (PDF, `app/mecenat/pdf.py` + `GET
/editions/{id}/volunteers/mecenat-receipts`, COORDINATION) : un cerfa
11580*05 officiel (`app/mecenat/cerfa-mecenat-particuliers.pdf`, PDF sans
champ AcroForm — les informations sont donc dessinées par-dessus la page 2
du modèle, aux coordonnées mesurées sur ce document précis) par bénévole
ayant payé un type de paiement « mécénat » (repérés par
`Volunteer.fee_payment_status == PAID` et `mecenat_address` renseigné —
voir `Paiement de la cotisation` plus bas), regroupés dans un seul PDF
(2 pages par reçu, comme le cerfa). Seuls l'encadré « Donateur » (nom,
prénom, adresse), le montant (en chiffres **et en toutes lettres**,
`app/mecenat/number_words.py:amount_in_words` — conversion française pure,
testée séparément des coordonnées PDF vu le nombre d'exceptions
orthographiques : accord de « quatre-vingts »/« cents », « et onze »
irrégulier à 71) et la date du versement sont complétés ; l'encadré
« Organisme bénéficiaire » (page 1) reste vierge, à la main,
volontairement — pas de données de l'association en base à ce jour.
`pypdf` (nouvelle dépendance) fusionne l'overlay ReportLab sur la page du
modèle. Bouton « Reçus mécénat (PDF) » sur l'écran de configuration des
bénévoles, même mécanique de téléchargement que l'export Excel ;
422 `mecenat.no_receipts` si aucun bénévole ne qualifie.
- **`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. `access_token`
(aléatoire, `secrets.token_urlsafe(16)` à la création) protège l'URL du
formulaire public : celle-ci est `/inscription/{access_token}`, jamais
`/inscription` seul — sans le jeton, impossible de deviner ou de tomber
sur le lien (`GET .../form/public`, `GET .../form/logo` et
`POST .../form/public/submit` l'exigent en plus de `edition_id`,
comparaison en temps constant via `secrets.compare_digest`, 404 générique
sinon). `POST .../form/regenerate-token` (COORDINATION) le renouvelle —
l'ancien lien cesse aussitôt de fonctionner, à repartager. Les endpoints
liés à une soumission en attente de paiement (`submit_mecenat_info`,
`get_pending_status`, `retry_pending_payment`, le webhook SumUp) ne sont
pas concernés : déjà protégés par le jeton propre à chaque soumission. `sumup_merchant_code`/
`sumup_api_key` sont les identifiants du compte marchand SumUp pour le `sumup_api_key` sont les identifiants du compte marchand SumUp pour le
paiement de la cotisation (`app/sumup/`) — `sumup_api_key` est un secret, paiement de la cotisation (`app/sumup/`) — `sumup_api_key` est un secret,
jamais renvoyé par l'API (`FormTemplateRead.sumup_api_key_set: bool` à la jamais renvoyé par l'API (`FormTemplateRead.sumup_api_key_set: bool` à la
@@ -423,9 +454,12 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
clients mail possible). Construite par `build_message` clients mail possible). Construite par `build_message`
(`app/email/sender.py`), pour **tous** les emails envoyés, sans exception. (`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 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` — public (`app/email/branding.py:edition_logo`, `FormTemplate.logo`) ; à
lien vers `GET .../form/logo`, public) ; à défaut, un bandeau texte avec défaut, un bandeau texte avec le sujet. Intégré en pièce jointe « inline »
le sujet. (Content-ID, `LOGO_CID`) plutôt que via une URL vers `GET .../form/logo` :
cette route exige désormais le `access_token` du formulaire public (§ 2,
`FormTemplate`) — une URL externe dans un email l'aurait exposé en clair à
quiconque lit la source du message, pas seulement à son destinataire.
- **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
@@ -570,12 +604,18 @@ positionnement implicite (auto-flow) recréerait la même classe de bug.
Vérifié par `scripts/smoke.tsx:checkGridLayout` (inspecte les `grid-row` Vérifié par `scripts/smoke.tsx:checkGridLayout` (inspecte les `grid-row`
rendus, pas seulement l'absence de crash — un décalage CSS n'est pas visible rendus, pas seulement l'absence de crash — un décalage CSS n'est pas visible
dans un simple rendu SSR). Hiérarchie de `z-index` volontairement large et dans un simple rendu SSR). Hiérarchie de `z-index` volontairement large et
sans égalité (coins figés 20/21 > en-têtes jour/heure 12/11 > libellés de sans égalité entre les éléments sticky de la grille (coins figés 20/21 >
poste 10 > cellules de créneau, chacune isolée dans son propre contexte en-têtes jour/heure 12/11 > libellés de poste 10) : deux éléments sticky à
d'empilement — `relative z-0` — pour que son infobulle au survol, `z-20`, égalité de `z-index` peuvent se chevaucher de façon imprévisible pendant un
ne rivalise jamais avec les en-têtes figés) : deux éléments sticky à égalité défilement selon le navigateur. L'infobulle au survol d'une cellule (détail
de `z-index` peuvent se chevaucher de façon imprévisible pendant un du créneau) est en `position: fixed`, positionnée par JS
défilement selon le navigateur. Largeur adaptative par jour (`columnWidthPx`, (`lib/useTooltipPosition.ts`, réutilisé par `HelpTip`) plutôt qu'ancrée en
`absolute` sur la cellule : un ancrage `absolute` la découpait au bord de la
zone de défilement (`overflow-auto`) et la coinçait sous les en-têtes figés
quel que soit son propre `z-index` (un enfant ne dépasse jamais le contexte
d'empilement de son parent) — bug corrigé une fois (infobulle invisible,
« sous tout »), un ancrage `absolute`/`z-index` recréerait la même classe de
bug. Largeur adaptative par jour (`columnWidthPx`,
en fonction du nombre de tranches) : peu de tranches ⇒ colonnes larges, en fonction du nombre de tranches) : peu de tranches ⇒ colonnes larges,
beaucoup ⇒ largeur compacte avec défilement horizontal — jamais de colonnes beaucoup ⇒ largeur compacte avec défilement horizontal — jamais de colonnes
étroites par défaut. étroites par défaut.
@@ -657,6 +697,7 @@ l'étape 21.)*
│ │ ├── solver/ # suggestions OR-Tools (models, engine, loader) │ │ ├── solver/ # suggestions OR-Tools (models, engine, loader)
│ │ ├── badges/ # génération PDF des planches (pdf.py, ReportLab) │ │ ├── badges/ # génération PDF des planches (pdf.py, ReportLab)
│ │ ├── sheets/ # génération PDF des feuilles de poste (pdf.py) │ │ ├── sheets/ # génération PDF des feuilles de poste (pdf.py)
│ │ ├── mecenat/ # reçus fiscaux cerfa (pdf.py, pypdf + ReportLab) + le cerfa officiel
│ │ ├── forms/ # hCaptcha (captcha.py) — voir api/forms.py │ │ ├── forms/ # hCaptcha (captcha.py) — voir api/forms.py
│ │ ├── sumup/ # client API SumUp (client.py) — paiement de la cotisation │ │ ├── sumup/ # client API SumUp (client.py) — paiement de la cotisation
│ │ ├── bootstrap.py # instance vierge : édition + 1er compte │ │ ├── bootstrap.py # instance vierge : édition + 1er compte
@@ -769,12 +810,17 @@ Aucune dépendance locale requise hors Docker.
| 18 | Gestion des référents depuis la fiche bénévole : `PATCH /accounts` (rôle LEAD ↔ VOLUNTEER), `lead_account_id` vérifié (doit être un compte référent de l'édition) | à valider | | 18 | Gestion des référents depuis la fiche bénévole : `PATCH /accounts` (rôle LEAD ↔ VOLUNTEER), `lead_account_id` vérifié (doit être un compte référent de l'édition) | à valider |
| 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend, limiteur de tentatives sur `/auth/login` et `/auth/magic-link` | à valider | | 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend, limiteur de tentatives sur `/auth/login` et `/auth/magic-link` | à valider |
| 20 | Planches de badges bénévoles (PDF) : recto personnalisable (fond + champs nom/poste(s)/catégorie/rôle, éditeur glisser-déposer), verso fixe (même image pour tous), écran « Badges » dédié (`/badges`, COORDINATION) | à valider | | 20 | Planches de badges bénévoles (PDF) : recto personnalisable (fond + champs nom/poste(s)/catégorie/rôle, éditeur glisser-déposer), verso fixe (même image pour tous), écran « Badges » dédié (`/badges`, COORDINATION) | à valider |
| 21 | Formulaire public d'inscription (`/inscription`, sans authentification) : champs activables par la coordination (email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir, vœu de compagnon en texte libre, disponibilité/cotisation déclaratives, photo, commentaire), captcha hCaptcha, ouverture/fermeture, lien à partager, écran de configuration dédié (`/inscriptions`, COORDINATION) — les bénévoles créés rejoignent directement la liste | à valider | | 21 | Formulaire public d'inscription (`/inscription/{access_token}`, sans authentification — voir étape 27) : champs activables par la coordination (email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir, vœu de compagnon en texte libre, disponibilité/cotisation déclaratives, photo, commentaire), captcha hCaptcha, ouverture/fermeture, lien à partager, écran de configuration dédié (`/inscriptions`, COORDINATION) — les bénévoles créés rejoignent directement la liste | à valider |
| 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 »), 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 | | 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 | | 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 |
| 27 | Lien d'inscription protégé par jeton (`FormTemplate.access_token`, voir § 2) : l'URL du formulaire public devient `/inscription/{access_token}` (`/inscription` seul ne mène plus nulle part), bouton « Régénérer » sur l'écran de configuration (`/inscriptions`) pour révoquer un lien qui aurait fuité | à valider |
| 28 | Reçus fiscaux mécénat (`GET /editions/{id}/volunteers/mecenat-receipts`, COORDINATION, `app/mecenat/pdf.py`) : un cerfa 11580*05 officiel par bénévole ayant payé en mécénat, donateur/montant (chiffres et toutes lettres en français, `number_words.py`)/date complétés par-dessus le modèle (`pypdf` + ReportLab), organisme bénéficiaire volontairement laissé vierge — bouton dédié sur l'écran de configuration des bénévoles | à valider |
| 29 | Jeu de démonstration : 8 bénévoles avec une cotisation classique payée, 4 avec une cotisation mécénat payée (adresse complète) — `app/seed.py:_add_demo_payments` — pour que l'export Excel et les reçus fiscaux mécénat (étape 28) aient de quoi montrer quelque chose d'emblée | à valider |
| 30 | Infobulles (`HelpTip` et détail de cellule de la grille des besoins) repositionnées en `position: fixed` via JS (`lib/useTooltipPosition.ts`), bornées à l'intérieur de l'écran et basculant de côté si la place manque — corrige un centrage `absolute` qui les faisait déborder de l'écran près d'un bord, et une isolation `z-index` qui rendait celle de la grille invisible (coincée sous les en-têtes figés) | à valider |
| 31 | Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une URL (fuyait le `access_token` du formulaire public à tout destinataire — régression de l'étape 27, aucun test ne couvrait le rendu HTML du logo), sujet retiré du log de remise des emails (pouvait porter le nom d'un bénévole via un modèle personnalisé), image du formulaire public revalidée par décodage réel (`PIL`, pas seulement le `Content-Type` déclaré) avant stockage — évite qu'une fausse image fasse échouer la génération des badges/feuilles de poste, export Excel des bénévoles protégé contre l'injection de formule (CWE-1236, `app/exporting/xlsx.py:_safe_text`), sondage du statut de paiement en attente limité en fréquence par jeton (`pending_status_limiter`, évite un abus de l'API SumUp), `pyjwt` mis à jour (CVE listées non exploitables ici — HS256 à secret statique uniquement, jamais de JWKS — mais autant ne pas rester sur une version vulnérable) | à 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 +
+2 -2
View File
@@ -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 edition_logo_url, render_magic_link, send from app.email import edition_logo, 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"])
@@ -144,7 +144,7 @@ def request_magic_link(
minutes=settings.magic_link_ttl_minutes, minutes=settings.magic_link_ttl_minutes,
) )
background.add_task( background.add_task(
send, account.email, subject, body, logo_url=edition_logo_url(session, edition_id) send, account.email, subject, body, logo=edition_logo(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é.
+73 -13
View File
@@ -3,11 +3,15 @@
Deux familles d'endpoints sous le même routeur : Deux familles d'endpoints sous le même routeur :
- configuration (COORDINATION) : quels champs sont activés, formulaire - configuration (COORDINATION) : quels champs sont activés, formulaire
ouvert ou fermé, identifiants SumUp de paiement de la cotisation ; ouvert ou fermé, identifiants SumUp de paiement de la cotisation ;
- publics (**aucune authentification**) : consultation de la configuration - publics (**aucune authentification**, mais — sauf le webhook — protégés
et soumission (protégées par hCaptcha, `app/forms/captcha.py`, et un filet par un `token` requis en plus de `edition_id`, voir `_require_access_token`
de sécurité global, `app/auth/rate_limit.py:form_submission_limiter`), et et `FormTemplate.access_token` : `/inscription` seul, sans le jeton donné
le webhook SumUp (`/public/sumup-webhook`, appelé par SumUp lui-même — pas par la coordination, ne mène nulle part) : consultation de la
de hCaptcha possible serveur à serveur, voir `sumup_webhook` plus bas). configuration et soumission (protégées aussi par hCaptcha,
`app/forms/captcha.py`, et un filet de sécurité global,
`app/auth/rate_limit.py:form_submission_limiter`), et le webhook SumUp
(`/public/sumup-webhook`, appelé par SumUp lui-même — pas de hCaptcha ni
de jeton possible serveur à serveur, voir `sumup_webhook` plus bas).
Nom et prénom sont toujours collectés (indispensables à la création du Nom et prénom sont toujours collectés (indispensables à la création du
bénévole) : ils ne font pas partie des champs activables, contrairement aux bénévole) : ils ne font pas partie des champs activables, contrairement aux
@@ -21,8 +25,10 @@ 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 datetime as dt
import io
import json import json
import logging import logging
import secrets
import uuid import uuid
from fastapi import ( from fastapi import (
@@ -36,6 +42,7 @@ from fastapi import (
UploadFile, UploadFile,
status, status,
) )
from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete from sqlalchemy import delete
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -64,11 +71,11 @@ from app.api.schemas import (
SumUpWebhookPayload, SumUpWebhookPayload,
) )
from app.auth.deps import require_coordinator 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, pending_status_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 ( from app.email import (
edition_logo_url, edition_logo,
render_registration_confirmation, render_registration_confirmation,
render_registration_paid_confirmation, render_registration_paid_confirmation,
send, send,
@@ -154,9 +161,20 @@ def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormT
description=template.description, description=template.description,
sumup_merchant_code=template.sumup_merchant_code, sumup_merchant_code=template.sumup_merchant_code,
sumup_api_key_set=bool(template.sumup_api_key), sumup_api_key_set=bool(template.sumup_api_key),
access_token=template.access_token,
) )
def _require_access_token(template: FormTemplate, token: str) -> None:
"""`/inscription` seul, sans le bon jeton, ne doit rien révéler —
404 générique (`error.not_found`), jamais un message distinguant
« mauvais jeton » de « aucun formulaire ». Comparaison à temps constant
(`secrets.compare_digest`) : un jeton de longueur correcte mais faux ne
doit pas se deviner caractère par caractère via le temps de réponse."""
if not secrets.compare_digest(token, template.access_token):
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
@router.get("", response_model=FormTemplateRead) @router.get("", response_model=FormTemplateRead)
def get_form( def get_form(
edition: Edition = Depends(get_edition), edition: Edition = Depends(get_edition),
@@ -200,6 +218,23 @@ def update_form(
return _template_read(template, _enabled_fields(session, edition.id)) return _template_read(template, _enabled_fields(session, edition.id))
@router.post("/regenerate-token", response_model=FormTemplateRead)
def regenerate_access_token(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> FormTemplateRead:
"""Révoque le lien d'inscription partagé jusqu'ici : un nouveau jeton
remplace l'ancien, qui cesse aussitôt de fonctionner (utile si le lien a
fuité). Rien d'autre n'est affecté — les inscriptions déjà reçues, en
attente de paiement ou en cours de paiement restent valables."""
template = _get_or_create_template(session, edition.id)
template.access_token = secrets.token_urlsafe(16)
session.add(template)
session.commit()
return _template_read(template, _enabled_fields(session, edition.id))
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Types de paiement (COORDINATION) — voir `FormPaymentOption` # # Types de paiement (COORDINATION) — voir `FormPaymentOption` #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -323,12 +358,15 @@ async def set_logo(
@router.get("/logo") @router.get("/logo")
def get_logo( def get_logo(
token: str,
edition: Edition = Depends(get_edition), edition: Edition = Depends(get_edition),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> Response: ) -> Response:
"""Public (aucune authentification) : affiché sur `/inscription`, comme """Public (aucune authentification, mais protégé par `token` — voir
`_require_access_token`) : affiché sur `/inscription/<token>`, comme
`GET .../form/public` — voir le commentaire du module.""" `GET .../form/public` — voir le commentaire du module."""
template = _get_or_create_template(session, edition.id) template = _get_or_create_template(session, edition.id)
_require_access_token(template, token)
if template.logo is None: if template.logo is None:
raise HTTPException(status_code=404, detail={"code": "error.not_found"}) raise HTTPException(status_code=404, detail={"code": "error.not_found"})
return Response( return Response(
@@ -342,10 +380,12 @@ def get_logo(
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@router.get("/public", response_model=PublicFormConfig) @router.get("/public", response_model=PublicFormConfig)
def get_public_form( def get_public_form(
token: str,
edition: Edition = Depends(get_edition), edition: Edition = Depends(get_edition),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> PublicFormConfig: ) -> PublicFormConfig:
template = _get_or_create_template(session, edition.id) template = _get_or_create_template(session, edition.id)
_require_access_token(template, token)
fields = _enabled_fields(session, edition.id) fields = _enabled_fields(session, edition.id)
positions: list[PublicPositionOption] = [] positions: list[PublicPositionOption] = []
@@ -411,7 +451,15 @@ def _prune_stale_pending(session: Session, edition_id: uuid.UUID) -> None:
async def _read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None: async def _read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None:
"""Validation partagée par l'inscription immédiate et l'inscription en """Validation partagée par l'inscription immédiate et l'inscription en
attente de paiement — `None` si aucun fichier fourni.""" attente de paiement — `None` si aucun fichier fourni. `Content-Type` est
un en-tête fourni par l'appelant, jamais vérifié par lui-même (ce
endpoint est public, sans authentification) : un fichier qui ne se
décode pas comme une vraie image malgré un `Content-Type` correct
(`image/png`/`image/jpeg` revendiqué) est rejeté ici plutôt que de
planter plus tard la génération des badges/feuilles de poste
(`app/badges/pdf.py`/`app/sheets/pdf.py`, qui lisent ces octets sans
filet) — même validation que l'import d'image des badges
(`app/api/badges.py:_pixel_size`, réservé à la coordination)."""
if photo is None or not photo.filename: if photo is None or not photo.filename:
return None return None
content_type = photo.content_type or "" content_type = photo.content_type or ""
@@ -422,7 +470,16 @@ async def _read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None:
content = await photo.read() content = await photo.read()
if len(content) > _MAX_PHOTO_BYTES: if len(content) > _MAX_PHOTO_BYTES:
raise HTTPException(status_code=413, detail={"code": "form.photo_too_large"}) raise HTTPException(status_code=413, detail={"code": "form.photo_too_large"})
return (content, content_type) if content else None if not content:
return None
try:
with Image.open(io.BytesIO(content)) as img:
img.verify()
except (UnidentifiedImageError, OSError) as exc:
raise HTTPException(
status_code=422, detail={"code": "form.photo_type_not_supported"}
) from exc
return content, content_type
def _filter_owned_ids( def _filter_owned_ids(
@@ -498,6 +555,7 @@ async def submit_public_form(
background: BackgroundTasks, background: BackgroundTasks,
edition: Edition = Depends(get_edition), edition: Edition = Depends(get_edition),
session: Session = Depends(get_session), session: Session = Depends(get_session),
access_token: str = Form(...),
first_name: str = Form(...), first_name: str = Form(...),
last_name: str = Form(...), last_name: str = Form(...),
hcaptcha_token: str = Form(...), hcaptcha_token: str = Form(...),
@@ -517,6 +575,7 @@ async def submit_public_form(
_prune_stale_pending(session, edition.id) _prune_stale_pending(session, edition.id)
template = _get_or_create_template(session, edition.id) template = _get_or_create_template(session, edition.id)
_require_access_token(template, access_token)
if not template.is_open: if not template.is_open:
raise HTTPException(status_code=422, detail={"code": "form.closed"}) raise HTTPException(status_code=422, detail={"code": "form.closed"})
@@ -622,7 +681,7 @@ async def submit_public_form(
volunteer.email, volunteer.email,
subject, subject,
body, body,
logo_url=edition_logo_url(session, edition.id), logo=edition_logo(session, edition.id),
) )
return FormSubmissionResult(volunteer_id=volunteer.id) return FormSubmissionResult(volunteer_id=volunteer.id)
@@ -682,7 +741,7 @@ def _create_pending_checkout(
checkout_reference=f"{pending.id}-{uuid.uuid4().hex[:8]}", checkout_reference=f"{pending.id}-{uuid.uuid4().hex[:8]}",
description=f"{pending.payment_label} — édition {edition.year}", description=f"{pending.payment_label} — édition {edition.year}",
return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook", return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook",
redirect_url=f"{base}/inscription?paiement={pending.id}", redirect_url=f"{base}/inscription/{template.access_token}?paiement={pending.id}",
) )
if checkout is None: if checkout is None:
return None return None
@@ -773,7 +832,7 @@ def _promote_pending(
volunteer.email, volunteer.email,
subject, subject,
body, body,
logo_url=edition_logo_url(session, edition.id), logo=edition_logo(session, edition.id),
) )
return volunteer return volunteer
@@ -861,6 +920,7 @@ def get_pending_status(
`PublicSignupPage.tsx`) jusqu'à ce que le statut ne soit plus `PENDING` — `PublicSignupPage.tsx`) jusqu'à ce que le statut ne soit plus `PENDING` —
relit toujours l'état réel auprès de SumUp à chaque appel, jamais relit toujours l'état réel auprès de SumUp à chaque appel, jamais
seulement la dernière valeur connue en base.""" seulement la dernière valeur connue en base."""
pending_status_limiter.hit(str(token))
pending = _get_pending_or_404(session, edition.id, token) pending = _get_pending_or_404(session, edition.id, token)
if pending.promoted_volunteer_id is None and pending.checkout_id: if pending.promoted_volunteer_id is None and pending.checkout_id:
template = _get_or_create_template(session, edition.id) template = _get_or_create_template(session, edition.id)
+2 -2
View File
@@ -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 edition_logo_url, render_planning, send_many from app.email import edition_logo, 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, logo_url=edition_logo_url(session, edition.id)) background.add_task(send_many, items, logo=edition_logo(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,
+4
View File
@@ -513,6 +513,10 @@ class FormTemplateRead(ApiModel):
# `sumup_api_key` (secret) n'est jamais renvoyé — seulement s'il est # `sumup_api_key` (secret) n'est jamais renvoyé — seulement s'il est
# défini, comme `has_logo` pour le logo. # défini, comme `has_logo` pour le logo.
sumup_api_key_set: bool sumup_api_key_set: bool
# Secret nécessaire pour atteindre le formulaire public
# (`/inscription/<access_token>`, voir CLAUDE.md § 2, `FormTemplate`) —
# exposé seulement ici (COORDINATION), jamais par `PublicFormConfig`.
access_token: str
class FormTemplateUpdate(ApiModel): class FormTemplateUpdate(ApiModel):
+50
View File
@@ -30,11 +30,13 @@ from app.auth.deps import (
) )
from app.db import get_session from app.db import get_session
from app.exporting import generate_volunteers_xlsx from app.exporting import generate_volunteers_xlsx
from app.mecenat import MecenatReceipt, generate_mecenat_receipts_pdf
from app.models import ( from app.models import (
Account, Account,
Artist, Artist,
Assignment, Assignment,
Edition, Edition,
FeePaymentStatus,
Position, Position,
Role, Role,
Shift, Shift,
@@ -220,6 +222,54 @@ def export_volunteers(
) )
@router.get("/mecenat-receipts")
def export_mecenat_receipts(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
"""Reçus fiscaux (cerfa 11580*05, `app/mecenat/pdf.py`) des bénévoles
ayant payé un type de paiement « mécénat » — repérés par
`mecenat_address` renseigné (voir CLAUDE.md § 2, Paiement de la
cotisation : ce champ n'est rempli que pour ce chemin-là). Déclaré avant
`/{volunteer_id}`, comme `/export` juste au-dessus."""
volunteers = list(
session.exec(
select(Volunteer)
.where(Volunteer.edition_id == edition.id)
.where(Volunteer.fee_payment_status == FeePaymentStatus.PAID)
.where(Volunteer.mecenat_address.is_not(None))
.order_by(Volunteer.last_name, Volunteer.first_name)
)
)
if not volunteers:
raise HTTPException(
status_code=422, detail={"code": "mecenat.no_receipts"}
)
receipts = [
MecenatReceipt(
last_name=v.last_name,
first_name=v.first_name,
address=v.mecenat_address or "",
postal_code=v.mecenat_postal_code or "",
city=v.mecenat_city or "",
country=v.mecenat_country or "",
amount=v.fee_amount or 0.0,
payment_date=(v.fee_paid_at or v.created_at).date(),
)
for v in volunteers
]
content = generate_mecenat_receipts_pdf(receipts)
return Response(
content=content,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="recus-mecenat-{edition.year}.pdf"'
},
)
@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,
+11
View File
@@ -67,3 +67,14 @@ magic_link_limiter = RateLimiter(max_attempts=3, window_seconds=300)
# n'est qu'un filet de sécurité global côté édition contre un emballement # n'est qu'un filet de sécurité global côté édition contre un emballement
# malgré tout (30 inscriptions/5 min est très large pour un usage réel). # malgré tout (30 inscriptions/5 min est très large pour un usage réel).
form_submission_limiter = RateLimiter(max_attempts=30, window_seconds=300) form_submission_limiter = RateLimiter(max_attempts=30, window_seconds=300)
# Interrogation du statut d'une inscription en attente de paiement
# (`GET .../form/public/pending/{token}`) : relit toujours l'état réel
# auprès de SumUp (voir `app/api/forms.py:get_pending_status`), donc chaque
# appel coûte une requête à l'API SumUp de l'édition. Clé par jeton (pas par
# édition, contrairement à `form_submission_limiter`) : plusieurs
# inscriptions en cours en même temps ne doivent pas s'épuiser mutuellement
# leur quota de sondage. 60/5 min couvre largement le sondage légitime (la
# page de confirmation interroge toutes les 3 s, jusqu'à 40 fois — voir
# `PublicSignupPage.tsx`), tout en bornant un sondage en boucle serrée qui
# n'aurait d'autre effet que de solliciter inutilement l'API SumUp.
pending_status_limiter = RateLimiter(max_attempts=60, window_seconds=300)
+2 -2
View File
@@ -4,7 +4,7 @@
(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.branding import edition_logo
from app.email.sender import send, send_many from app.email.sender import send, send_many
from app.email.templates import ( from app.email.templates import (
render_magic_link, render_magic_link,
@@ -14,7 +14,7 @@ from app.email.templates import (
) )
__all__ = [ __all__ = [
"edition_logo_url", "edition_logo",
"render_magic_link", "render_magic_link",
"render_planning", "render_planning",
"render_registration_confirmation", "render_registration_confirmation",
+12 -7
View File
@@ -1,20 +1,25 @@
"""URL publique du logo d'une édition, pour l'en-tête des emails (voir """Logo d'une édition pour l'en-tête des emails (voir `app/email/html.py`) —
`app/email/html.py`) — `None` tant qu'aucun logo n'a été importé sur le `None` tant qu'aucun logo n'a été importé sur le formulaire public
formulaire public (`FormTemplate.logo`, `app/api/forms.py:set_logo`).""" (`FormTemplate.logo`, `app/api/forms.py:set_logo`).
Renvoie les octets (intégrés en pièce jointe « inline », voir
`app/email/sender.py:build_message`), jamais une URL : `GET .../form/logo`
exige désormais le `access_token` du formulaire public
(`app/api/forms.py:_require_access_token`) — une URL externe dans un email
aurait exposé ce jeton à quiconque lit la source du message, pas seulement
la coordination."""
import uuid import uuid
from sqlmodel import Session, select from sqlmodel import Session, select
from app.config import get_settings
from app.models import FormTemplate from app.models import FormTemplate
def edition_logo_url(session: Session, edition_id: uuid.UUID) -> str | None: def edition_logo(session: Session, edition_id: uuid.UUID) -> tuple[bytes, str] | None:
template = session.exec( template = session.exec(
select(FormTemplate).where(FormTemplate.edition_id == edition_id) select(FormTemplate).where(FormTemplate.edition_id == edition_id)
).first() ).first()
if template is None or template.logo is None: if template is None or template.logo is None:
return None return None
base = get_settings().public_base_url.rstrip("/") return template.logo, template.logo_content_type or "image/png"
return f"{base}/api/editions/{edition_id}/form/logo"
+13 -6
View File
@@ -18,6 +18,13 @@ _YELLOW = "#FFD23F"
_FONT_DISPLAY = "Arial, Helvetica, sans-serif" _FONT_DISPLAY = "Arial, Helvetica, sans-serif"
_FONT_MONO = "'Courier New', Courier, monospace" _FONT_MONO = "'Courier New', Courier, monospace"
# Identifiant de la pièce jointe « inline » du logo (voir
# `app/email/sender.py:build_message`) — jamais une URL externe avec le
# `access_token` du formulaire public en requête : n'importe quel
# destinataire d'un email (lien magique, confirmation d'inscription…) aurait
# pu lire ce jeton depuis la source du message, pas seulement la coordination.
LOGO_CID = "edition-logo"
def _paragraphs(body: str) -> str: def _paragraphs(body: str) -> str:
"""`\n\n` sépare les paragraphes, un simple `\n` reste un retour à la """`\n\n` sépare les paragraphes, un simple `\n` reste un retour à la
@@ -30,13 +37,13 @@ def _paragraphs(body: str) -> str:
) )
def render_html(*, subject: str, body: str, logo_url: str | None) -> str: def render_html(*, subject: str, body: str, has_logo: bool) -> str:
"""`logo_url` : voir `app/email/branding.py:edition_logo_url` — `None` """`has_logo` : voir `app/email/branding.py:edition_logo` — `False` tant
tant que l'édition n'a pas importé de logo, l'en-tête retombe alors sur que l'édition n'a pas importé de logo, l'en-tête retombe alors sur un
un simple bandeau texte (le sujet, en capitales).""" simple bandeau texte (le sujet, en capitales)."""
if logo_url: if has_logo:
header = ( header = (
f'<img src="{_html.escape(logo_url)}" alt="" width="160" ' f'<img src="cid:{LOGO_CID}" alt="" width="160" '
'style="max-height:64px;width:auto;display:block;margin:0 auto;' 'style="max-height:64px;width:auto;display:block;margin:0 auto;'
'border:0;">' 'border:0;">'
) )
+28 -18
View File
@@ -12,7 +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 from app.email.html import LOGO_CID, render_html
logger = logging.getLogger("app.email") logger = logging.getLogger("app.email")
@@ -34,20 +34,32 @@ def _smtp_error_summary(exc: smtplib.SMTPException) -> str:
def build_message( def build_message(
to: str, subject: str, body: str, *, logo_url: str | None = None to: str, subject: str, body: str, *, logo: tuple[bytes, str] | None = None
) -> EmailMessage: ) -> EmailMessage:
"""`logo_url` (voir `app/email/branding.py:edition_logo_url`) : ajoute une """`logo` = (octets, content-type) (voir `app/email/branding.py:
version HTML au thème de l'appli (`app/email/html.py`) en alternative au edition_logo`) : ajoute une version HTML au thème de l'appli
texte brut — le texte reste la version de référence, envoyée telle (`app/email/html.py`) en alternative au texte brut — le texte reste la
quelle si aucun client ne préfère le HTML.""" version de référence, envoyée telle quelle si aucun client ne préfère le
HTML. Le logo, s'il y en a un, est intégré en pièce jointe « inline »
(Content-ID) référencée par la partie HTML — jamais une URL externe, qui
aurait exposé le jeton d'accès du formulaire public à quiconque lit la
source du message (voir `app/email/branding.py`)."""
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( message.add_alternative(
render_html(subject=subject, body=body, logo_url=logo_url), subtype="html" render_html(subject=subject, body=body, has_logo=logo is not None),
subtype="html",
) )
if logo is not None:
content, content_type = logo
maintype, _, subtype = content_type.partition("/")
html_part = message.get_payload()[-1]
html_part.add_related(
content, maintype or "image", subtype or "png", cid=f"<{LOGO_CID}>"
)
return message return message
@@ -112,22 +124,20 @@ def deliver_many(messages: list[EmailMessage]) -> None:
message["From"], message["From"],
_smtp_error_summary(exc), _smtp_error_summary(exc),
) )
# RGPD : ni destinataire ni corps, seulement le compte et le sujet. # RGPD : ni destinataire, ni corps, ni sujet — un sujet de récapitulatif
logger.info( # de planning (`app/api/notifications.py`) peut être personnalisé par la
"Emails remis : %d/%d — sujet=%r", # coordination avec le nom du bénévole (`{prenom}`/`{nom}`), ce ne serait
sent, # alors plus un simple texte d'interface.
len(messages), logger.info("Emails remis : %d/%d", sent, len(messages))
messages[0]["Subject"],
)
def send(to: str, subject: str, body: str, *, logo_url: str | None = None) -> None: def send(to: str, subject: str, body: str, *, logo: tuple[bytes, str] | None = None) -> None:
deliver_many([build_message(to, subject, body, logo_url=logo_url)]) deliver_many([build_message(to, subject, body, logo=logo)])
def send_many( def send_many(
items: Iterable[tuple[str, str, str]], *, logo_url: str | None = None items: Iterable[tuple[str, str, str]], *, logo: tuple[bytes, str] | None = None
) -> None: ) -> None:
"""`items` = itérable de `(destinataire, sujet, corps)` — même logo pour """`items` = itérable de `(destinataire, sujet, corps)` — même logo pour
tous (un seul appel par édition, voir `app/api/notifications.py`).""" tous (un seul appel par édition, voir `app/api/notifications.py`)."""
deliver_many([build_message(*item, logo_url=logo_url) for item in items]) deliver_many([build_message(*item, logo=logo) for item in items])
+24 -12
View File
@@ -43,13 +43,25 @@ _FEE_STATUS_LABELS = {
"EXPIRED": "Expirée", "EXPIRED": "Expirée",
} }
# Caractères qui font d'une cellule Excel/LibreOffice une formule plutôt
# qu'un texte littéral — injection de formule (CWE-1236) : la plupart de ces
# champs viennent du formulaire public, sans authentification (nom, email,
# téléphone, commentaire, note de compagnon, adresse de mécénat…). Un
# préfixe `'` force l'interprétation en texte, sans changer la valeur
# affichée.
_FORMULA_PREFIXES = ("=", "+", "-", "@")
def _safe_text(value: str) -> str:
return f"'{value}" if value.startswith(_FORMULA_PREFIXES) else value
def _yes_no(value: bool) -> str: def _yes_no(value: bool) -> str:
return "Oui" if value else "Non" return "Oui" if value else "Non"
def _join_names(ids: list[uuid.UUID], names: dict[uuid.UUID, str]) -> str: 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) return _safe_text(", ".join(names[i] for i in ids if i in names))
def _fmt_dt(value) -> str: def _fmt_dt(value) -> str:
@@ -76,24 +88,24 @@ def generate_volunteers_xlsx(
for v in volunteers: for v in volunteers:
sheet.append( sheet.append(
[ [
v.first_name, _safe_text(v.first_name),
v.last_name, _safe_text(v.last_name),
v.email or "", _safe_text(v.email or ""),
v.phone or "", _safe_text(v.phone or ""),
_yes_no(v.self_declared_available), _yes_no(v.self_declared_available),
_join_names(v.wished_position_ids, position_names), _join_names(v.wished_position_ids, position_names),
_join_names(v.artist_ids, artist_names), _join_names(v.artist_ids, artist_names),
_join_names(v.wished_companion_ids, volunteer_names), _join_names(v.wished_companion_ids, volunteer_names),
v.companion_wish_note or "", _safe_text(v.companion_wish_note or ""),
v.fee_payment_label or "", _safe_text(v.fee_payment_label or ""),
v.fee_amount if v.fee_amount is not None else "", v.fee_amount if v.fee_amount is not None else "",
_FEE_STATUS_LABELS.get(v.fee_payment_status, ""), _FEE_STATUS_LABELS.get(v.fee_payment_status, ""),
_fmt_dt(v.fee_paid_at), _fmt_dt(v.fee_paid_at),
v.mecenat_address or "", _safe_text(v.mecenat_address or ""),
v.mecenat_city or "", _safe_text(v.mecenat_city or ""),
v.mecenat_postal_code or "", _safe_text(v.mecenat_postal_code or ""),
v.mecenat_country or "", _safe_text(v.mecenat_country or ""),
v.comment or "", _safe_text(v.comment or ""),
_fmt_dt(v.created_at), _fmt_dt(v.created_at),
] ]
) )
+2 -1
View File
@@ -94,5 +94,6 @@
"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.",
"position_sheets.no_shifts": "Aucun créneau à imprimer pour ces postes." "position_sheets.no_shifts": "Aucun créneau à imprimer pour ces postes.",
"mecenat.no_receipts": "Aucun bénévole n'a payé la cotisation en mécénat pour cette édition."
} }
+3
View File
@@ -0,0 +1,3 @@
from app.mecenat.pdf import MecenatReceipt, generate_mecenat_receipts_pdf
__all__ = ["MecenatReceipt", "generate_mecenat_receipts_pdf"]
Binary file not shown.
+84
View File
@@ -0,0 +1,84 @@
"""Montant en toutes lettres, en français — remplit l'encadré « Somme en
toutes lettres » du cerfa (voir `pdf.py`). Fonction pure, testée séparément
de la génération PDF : l'orthographe des nombres en français a suffisamment
d'exceptions (accord de "quatre-vingts"/"cents", "et un" irrégulier à 71)
pour mériter ses propres cas de test.
"""
_ONES = [
"zéro", "un", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf",
"dix", "onze", "douze", "treize", "quatorze", "quinze", "seize",
"dix-sept", "dix-huit", "dix-neuf",
]
_TENS_PREFIX = {2: "vingt", 3: "trente", 4: "quarante", 5: "cinquante", 6: "soixante"}
def _under_hundred(n: int) -> str:
"""0 <= n <= 99."""
if n < 20:
return _ONES[n]
if n < 70:
tens, unit = divmod(n, 10)
prefix = _TENS_PREFIX[tens]
if unit == 0:
return prefix
if unit == 1:
return f"{prefix} et un"
return f"{prefix}-{_ONES[unit]}"
if n < 80:
# 70-79 : soixante + dix..dix-neuf, « et onze » irrégulier à 71 (le
# seul cas des années 70 avec « et » — 72..79 n'en prennent pas).
rem = n - 60
if rem == 11:
return "soixante et onze"
return f"soixante-{_ONES[rem]}"
# 80-99 : quatre-vingt(s) + ... — jamais de « et », y compris à 91.
rem = n - 80
if rem == 0:
return "quatre-vingts"
return f"quatre-vingt-{_ONES[rem]}"
def _under_thousand(n: int) -> str:
"""0 <= n <= 999."""
if n < 100:
return _under_hundred(n)
hundreds, rem = divmod(n, 100)
prefix = "cent" if hundreds == 1 else f"{_ONES[hundreds]} cent"
if rem == 0:
# « cents » ne prend un accord au pluriel que sur un multiple exact
# (deux cents, mais deux cent un).
return f"{prefix}s" if hundreds > 1 else prefix
return f"{prefix} {_under_hundred(rem)}"
def _number_to_words(n: int) -> str:
"""0 <= n <= 999 999 999 — largement suffisant pour une cotisation ou un
don, jamais invariable au-delà (« mille » ne prend jamais de « s »)."""
if n == 0:
return "zéro"
parts = []
millions, n = divmod(n, 1_000_000)
if millions:
parts.append("un million" if millions == 1 else f"{_under_thousand(millions)} millions")
thousands, n = divmod(n, 1000)
if thousands:
parts.append("mille" if thousands == 1 else f"{_under_thousand(thousands)} mille")
if n:
parts.append(_under_thousand(n))
return " ".join(parts)
def amount_in_words(amount: float) -> str:
"""« Cent cinquante euros », « cinquante centimes », « un euro et dix
centimes »… — texte de l'encadré « Somme en toutes lettres » du cerfa, à
partir d'un montant en euros (`Volunteer.fee_amount`)."""
cents_total = round(amount * 100)
euros, cents = divmod(cents_total, 100)
parts = []
if euros or not cents:
parts.append(f"{_number_to_words(euros)} {'euro' if euros <= 1 else 'euros'}")
if cents:
parts.append(f"{_number_to_words(cents)} {'centime' if cents <= 1 else 'centimes'}")
text = " et ".join(parts)
return text[:1].upper() + text[1:]
+132
View File
@@ -0,0 +1,132 @@
"""Génération des reçus fiscaux (cerfa 11580*05) pour les dons « mécénat »
de la cotisation — voir `app/api/volunteers.py:generate_mecenat_receipts`.
Module isolé, fonction pure (`generate_mecenat_receipts_pdf`) : aucun accès
base ici. Le cerfa officiel (`cerfa-mecenat-particuliers.pdf`, à côté de ce
fichier) n'est pas un formulaire PDF interactif (aucun champ AcroForm) — les
informations du donateur, du montant et de la date sont donc dessinées
par-dessus la page 2 du modèle, aux coordonnées mesurées sur ce document
précis (mise en page fixe d'un cerfa officiel, jamais destinée à changer).
Les informations de l'organisme bénéficiaire (page 1, encadré « Organisme
bénéficiaire ») ne sont volontairement pas renseignées : à compléter à la
main par la coordination avant remise au bénévole.
"""
import datetime as dt
import io
from dataclasses import dataclass
from pathlib import Path
from pypdf import PageObject, PdfReader, PdfWriter
from reportlab.lib.colors import black
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen import canvas
from app.mecenat.number_words import amount_in_words
_TEMPLATE_PATH = Path(__file__).parent / "cerfa-mecenat-particuliers.pdf"
_PAGE_W = 595.0
_PAGE_H = 842.0
# `bottom` : ligne de base du champ imprimé (pointillés) sur lequel écrire,
# en points depuis le HAUT de la page (repère pdfplumber, utilisé pour
# mesurer ces coordonnées) ; `_NUDGE` replace le texte juste au-dessus de
# cette ligne plutôt que dessus, une fois converti en repère PDF (origine en
# bas — voir `_draw`).
_NUDGE = 1.5
@dataclass(frozen=True)
class _Field:
x: float
bottom: float
max_width: float
font_size: float
bold: bool = False
_LAST_NAME = _Field(x=66, bottom=250.5, max_width=230, font_size=9)
_FIRST_NAME = _Field(x=352, bottom=250.5, max_width=190, font_size=9)
_ADDRESS = _Field(x=136, bottom=281.2, max_width=405, font_size=9)
_POSTAL_CODE = _Field(x=99, bottom=295.5, max_width=33, font_size=8)
_CITY = _Field(x=232, bottom=295.5, max_width=310, font_size=9)
_COUNTRY = _Field(x=67, bottom=310.5, max_width=125, font_size=9)
_AMOUNT = _Field(x=44, bottom=365.2, max_width=110, font_size=9)
_AMOUNT_IN_WORDS = _Field(x=356, bottom=363.1, max_width=190, font_size=8)
_PAYMENT_DATE = _Field(x=184, bottom=380.9, max_width=76, font_size=9)
@dataclass(frozen=True)
class MecenatReceipt:
"""Un reçu = un bénévole ayant payé un type de paiement « mécénat » —
voir `Volunteer.mecenat_*`/`fee_*` (CLAUDE.md § 2, Paiement de la
cotisation)."""
last_name: str
first_name: str
address: str
postal_code: str
city: str
country: str
amount: float
payment_date: dt.date
def _fitted_font_size(text: str, max_width: float, font_size: float, bold: bool) -> float:
"""Réduit la taille de police jusqu'à ce que `text` tienne dans
`max_width` (plancher 6 pt, en dessous le texte reste simplement
tronqué par le tracé plutôt que rendu illisible)."""
font = "Helvetica-Bold" if bold else "Helvetica"
size = font_size
while size > 6 and stringWidth(text, font, size) > max_width:
size -= 0.5
return size
def _draw(c: canvas.Canvas, text: str, field: _Field) -> None:
if not text:
return
font_size = _fitted_font_size(text, field.max_width, field.font_size, field.bold)
c.setFont("Helvetica-Bold" if field.bold else "Helvetica", font_size)
c.setFillColor(black)
c.drawString(field.x, _PAGE_H - field.bottom + _NUDGE, text)
def _overlay_page(receipt: MecenatReceipt) -> PageObject:
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=(_PAGE_W, _PAGE_H))
_draw(c, receipt.last_name, _LAST_NAME)
_draw(c, receipt.first_name, _FIRST_NAME)
_draw(c, receipt.address, _ADDRESS)
_draw(c, receipt.postal_code, _POSTAL_CODE)
_draw(c, receipt.city, _CITY)
_draw(c, receipt.country, _COUNTRY)
_draw(c, f"{receipt.amount:.2f}".replace(".", ","), _AMOUNT)
_draw(c, amount_in_words(receipt.amount), _AMOUNT_IN_WORDS)
_draw(c, receipt.payment_date.strftime("%d/%m/%Y"), _PAYMENT_DATE)
c.showPage()
c.save()
buffer.seek(0)
return PdfReader(buffer).pages[0]
def generate_mecenat_receipts_pdf(receipts: list[MecenatReceipt]) -> bytes:
"""Un reçu = les 2 pages du cerfa officiel, page 2 complétée avec les
informations du donateur, du montant et de la date — voir le docstring
du module. Retourne les octets du PDF concaténant tous les reçus, un
bénévole après l'autre. Le modèle est relu à chaque itération : `merge_page`
mutant la page en place, réutiliser le même objet d'un tour à l'autre
accumulerait les tracés d'un bénévole sur la page du suivant."""
writer = PdfWriter()
for receipt in receipts:
base = PdfReader(_TEMPLATE_PATH)
writer.add_page(base.pages[0])
# `add_page` renvoie la page telle qu'attachée au writer — fusionner
# l'overlay sur ce retour plutôt que sur `base.pages[1]` (page encore
# détachée d'un writer, `merge_page` dépréciée dans ce cas).
donor_page = writer.add_page(base.pages[1])
donor_page.merge_page(_overlay_page(receipt))
buffer = io.BytesIO()
writer.write(buffer)
return buffer.getvalue()
@@ -0,0 +1,44 @@
"""Jeton d'accès au formulaire public (lien non devinable)
Revision ID: 0019_form_access_token
Revises: 0018_pending_submissions_mecenat
Create Date: 2026-09-30
`form_template.access_token` : secret partagé désormais nécessaire pour
atteindre le formulaire public (`/inscription/<jeton>`) — voir CLAUDE.md § 2,
`FormTemplate`, et `app/api/forms.py:_require_access_token`. `/inscription`
seul, sans jeton dans l'URL, ne mène plus nulle part.
Rétrocompatibilité : chaque `form_template` déjà en base reçoit un jeton
généré aléatoirement — les liens déjà partagés cessent de fonctionner, la
coordination doit en repartager un nouveau (« Copier le lien » sur l'écran
Inscriptions) — c'est précisément le but du changement.
"""
import secrets
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0019_form_access_token"
down_revision: str | None = "0018_pending_submissions_mecenat"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"form_template", sa.Column("access_token", sa.String(), nullable=True)
)
conn = op.get_bind()
for (row_id,) in conn.execute(sa.text("SELECT id FROM form_template")):
conn.execute(
sa.text("UPDATE form_template SET access_token = :token WHERE id = :id"),
{"token": secrets.token_urlsafe(16), "id": row_id},
)
op.alter_column("form_template", "access_token", nullable=False)
def downgrade() -> None:
op.drop_column("form_template", "access_token")
+9
View File
@@ -7,6 +7,7 @@ au niveau base (`ondelete="CASCADE"`) ; les relations ORM utilisent
""" """
import datetime as dt import datetime as dt
import secrets
import uuid import uuid
from sqlalchemy import Boolean, CheckConstraint, Column, Integer, LargeBinary, Time from sqlalchemy import Boolean, CheckConstraint, Column, Integer, LargeBinary, Time
@@ -524,6 +525,14 @@ class FormTemplate(TimestampMixin, table=True):
# un secret, au même titre qu'un mot de passe SMTP. # un secret, au même titre qu'un mot de passe SMTP.
sumup_merchant_code: str | None = None sumup_merchant_code: str | None = None
sumup_api_key: str | None = None sumup_api_key: str | None = None
# Secret partagé nécessaire pour atteindre le formulaire public
# (`GET .../form/public`, `GET .../form/logo`, `POST .../form/public/submit`
# — voir `app/api/forms.py:_require_access_token`) : `/inscription` seul,
# sans ce jeton dans l'URL, ne mène nulle part. Généré aléatoirement à la
# création de la ligne (`secrets.token_urlsafe`) ; régénérable par la
# coordination (`POST .../form/regenerate-token`) pour révoquer un lien
# qui aurait fuité — l'ancien cesse alors immédiatement de fonctionner.
access_token: str = Field(default_factory=lambda: secrets.token_urlsafe(16))
class FormPaymentOption(TimestampMixin, table=True): class FormPaymentOption(TimestampMixin, table=True):
+54
View File
@@ -25,6 +25,7 @@ from app.models import (
Availability, Availability,
DayPart, DayPart,
Edition, Edition,
FeePaymentStatus,
Position, Position,
PositionCategory, PositionCategory,
Role, Role,
@@ -203,6 +204,14 @@ ARTIST_SLOTS: list[tuple[dt.time, dt.time]] = [
(dt.time(21, 30), dt.time(23, 0)), (dt.time(21, 30), dt.time(23, 0)),
] ]
# Adresses postales des bénévoles « mécénat » — voir `_add_demo_payments`.
MECENAT_ADDRESSES = [
("12 rue des Lilas", "75020", "Paris"),
("3 impasse du Petit Bois", "44000", "Nantes"),
("8 avenue de la République", "69003", "Lyon"),
("21 chemin des Vignes", "33000", "Bordeaux"),
]
@dataclass @dataclass
class Summary: class Summary:
@@ -216,6 +225,8 @@ class Summary:
position_wishes: int = 0 position_wishes: int = 0
artist_wishes: int = 0 artist_wishes: int = 0
companion_wishes: int = 0 companion_wishes: int = 0
fee_paid_classique: int = 0
fee_paid_mecenat: int = 0
warnings: Counter = field(default_factory=Counter) warnings: Counter = field(default_factory=Counter)
blocking: int = 0 blocking: int = 0
@@ -232,6 +243,8 @@ class Summary:
f" Vœux de poste déclarés : {self.position_wishes}", f" Vœux de poste déclarés : {self.position_wishes}",
f" Vœux d'artiste déclarés : {self.artist_wishes}", f" Vœux d'artiste déclarés : {self.artist_wishes}",
f" Vœux de compagnon déclarés : {self.companion_wishes}", f" Vœux de compagnon déclarés : {self.companion_wishes}",
f" Cotisation payée (classique) : {self.fee_paid_classique}",
f" Cotisation payée (mécénat) : {self.fee_paid_mecenat}",
f" Avertissements actifs : {sum(self.warnings.values())}", f" Avertissements actifs : {sum(self.warnings.values())}",
] ]
for code, count in sorted(self.warnings.items()): for code, count in sorted(self.warnings.items()):
@@ -512,6 +525,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
n_assignments += _add_demo_warnings( n_assignments += _add_demo_warnings(
session, edition, shifts, reserved, assigned, availabilities session, edition, shifts, reserved, assigned, availabilities
) )
n_fee_classique, n_fee_mecenat = _add_demo_payments(session, volunteers, rng)
session.flush() session.flush()
fill: Counter = Counter() fill: Counter = Counter()
@@ -537,6 +551,8 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
position_wishes=n_position_wishes, position_wishes=n_position_wishes,
artist_wishes=n_artist_wishes, artist_wishes=n_artist_wishes,
companion_wishes=n_companion_wishes, companion_wishes=n_companion_wishes,
fee_paid_classique=n_fee_classique,
fee_paid_mecenat=n_fee_mecenat,
) )
_verify(session, edition, summary) _verify(session, edition, summary)
return summary return summary
@@ -610,6 +626,44 @@ def _add_demo_warnings(
return added return added
def _add_demo_payments(
session: Session, volunteers: list[Volunteer], rng: random.Random
) -> tuple[int, int]:
"""Quelques bénévoles ayant payé leur cotisation via SumUp — voir
CLAUDE.md § 2, Paiement de la cotisation. `fee_payment_label`/
`fee_amount` sont une copie figée (comme en production) : aucun
`FormPaymentOption` n'a besoin d'exister pour autant. Pour que l'export
Excel et les reçus fiscaux mécénat (`/editions/{id}/volunteers/
mecenat-receipts`) aient de quoi montrer quelque chose en démonstration."""
pool = rng.sample(volunteers, k=12)
classique, mecenat = pool[:8], pool[8:]
for volunteer in classique:
volunteer.fee_payment_label = "Cotisation classique"
volunteer.fee_amount = 15.0
volunteer.fee_payment_status = FeePaymentStatus.PAID
volunteer.fee_paid_at = dt.datetime.combine(
START - dt.timedelta(days=rng.randint(5, 45)), dt.time(rng.randint(9, 21))
)
session.add(volunteer)
for i, volunteer in enumerate(mecenat):
address, postal_code, city = MECENAT_ADDRESSES[i % len(MECENAT_ADDRESSES)]
volunteer.fee_payment_label = "Mécénat"
volunteer.fee_amount = rng.choice([30.0, 50.0, 80.0, 120.0])
volunteer.fee_payment_status = FeePaymentStatus.PAID
volunteer.fee_paid_at = dt.datetime.combine(
START - dt.timedelta(days=rng.randint(5, 45)), dt.time(rng.randint(9, 21))
)
volunteer.mecenat_address = address
volunteer.mecenat_postal_code = postal_code
volunteer.mecenat_city = city
volunteer.mecenat_country = "France"
session.add(volunteer)
return len(classique), len(mecenat)
def _find_rest_pair(shifts: list[Shift]) -> tuple[Shift, Shift] | None: def _find_rest_pair(shifts: list[Shift]) -> tuple[Shift, Shift] | None:
plain = sorted(shifts, key=lambda s: s.starts_at) plain = sorted(shifts, key=lambda s: s.starts_at)
for i, first in enumerate(plain): for i, first in enumerate(plain):
+6 -1
View File
@@ -12,10 +12,15 @@ dependencies = [
"pydantic-settings>=2.6", "pydantic-settings>=2.6",
"python-multipart>=0.0.12", "python-multipart>=0.0.12",
"openpyxl>=3.1", "openpyxl>=3.1",
"pyjwt>=2.9", # >=2.15 : aucune des CVE corrigées (toutes autour de PyJWKClient/JWKS,
# jamais utilisés ici — seul HS256 à secret symétrique statique est en
# jeu, voir app/auth/tokens.py) n'est exploitable dans cette appli, mais
# autant ne pas rester sur une version listée vulnérable sans raison.
"pyjwt>=2.15",
"bcrypt>=4.1", "bcrypt>=4.1",
"ortools>=9.11", "ortools>=9.11",
"reportlab>=4.2", "reportlab>=4.2",
"pypdf>=5.1",
"pillow>=11.0", "pillow>=11.0",
"httpx>=0.27", "httpx>=0.27",
] ]
+2
View File
@@ -11,6 +11,7 @@ from app.auth.rate_limit import (
form_submission_limiter, form_submission_limiter,
login_limiter, login_limiter,
magic_link_limiter, magic_link_limiter,
pending_status_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
@@ -36,6 +37,7 @@ def _reset_rate_limiters():
login_limiter.reset() login_limiter.reset()
magic_link_limiter.reset() magic_link_limiter.reset()
form_submission_limiter.reset() form_submission_limiter.reset()
pending_status_limiter.reset()
yield yield
+97 -1
View File
@@ -3,9 +3,10 @@ import io
import uuid import uuid
from openpyxl import load_workbook from openpyxl import load_workbook
from pypdf import PdfReader
from sqlmodel import select from sqlmodel import select
from app.models import Assignment, Availability, Role, Volunteer from app.models import Assignment, Availability, FeePaymentStatus, Role, Volunteer
from tests.factories import ( from tests.factories import (
dt_at, dt_at,
make_artist, make_artist,
@@ -426,7 +427,102 @@ def test_export_volunteers_as_excel(client, edition, session):
assert row[6] == "Chilla" assert row[6] == "Chilla"
def test_export_neutralizes_formula_injection(client, edition, session):
# CWE-1236 : un bénévole inscrit via le formulaire public (sans
# authentification) contrôle son nom, son commentaire, etc. — ouverts
# tels quels dans Excel par la coordination, une valeur comme
# `=cmd|'/c calc'!A0` y serait exécutée comme une formule. Un préfixe
# `'` force l'interprétation en texte (voir `app/exporting/xlsx.py`).
volunteer = make_volunteer(
session, edition, first_name="=cmd|'/c calc'!A0", last_name="Martin"
)
client.patch(
_url(edition, f"/{volunteer.id}"),
json={"comment": "@SUM(1+1)*cmd|' /C calc'!A0"},
)
resp = client.get(_url(edition, "/export"))
workbook = load_workbook(io.BytesIO(resp.content))
row = list(workbook.active.iter_rows(values_only=True))[1]
assert row[0] == "'=cmd|'/c calc'!A0"
assert row[17] == "'@SUM(1+1)*cmd|' /C calc'!A0"
def test_export_volunteers_is_coordinator_only(edition, make_account, as_account): def test_export_volunteers_is_coordinator_only(edition, make_account, as_account):
lead = make_account(Role.LEAD, email="lead@e.fr") lead = make_account(Role.LEAD, email="lead@e.fr")
resp = as_account(lead).get(_url(edition, "/export")) resp = as_account(lead).get(_url(edition, "/export"))
assert resp.status_code == 403 assert resp.status_code == 403
def _mark_mecenat_paid(session, volunteer, *, amount=50.0):
"""Simule l'état d'un bénévole ayant payé un type de paiement « mécénat »
(voir `app/api/forms.py:_promote_pending`) — `mecenat_address` est le
marqueur qui distingue ce chemin d'un simple paiement classique."""
volunteer.fee_payment_status = FeePaymentStatus.PAID
volunteer.fee_amount = amount
volunteer.fee_paid_at = dt.datetime(2026, 3, 5, 10, 0)
volunteer.mecenat_address = "12 rue des Lilas"
volunteer.mecenat_city = "Paris"
volunteer.mecenat_postal_code = "75020"
volunteer.mecenat_country = "France"
session.add(volunteer)
session.commit()
def test_mecenat_receipts_pdf_includes_only_paid_mecenat_donors(
client, edition, session
):
paid_mecenat = make_volunteer(
session, edition, first_name="Alex", last_name="Martin"
)
_mark_mecenat_paid(session, paid_mecenat)
# Cotisation classique payée (pas de mécénat) : ne doit pas apparaître.
paid_classique = make_volunteer(
session, edition, first_name="Sam", last_name="Petit"
)
paid_classique.fee_payment_status = FeePaymentStatus.PAID
paid_classique.fee_amount = 15.0
paid_classique.fee_paid_at = dt.datetime(2026, 3, 5, 10, 0)
session.add(paid_classique)
session.commit()
# Mécénat choisi mais paiement non abouti : ne doit pas apparaître.
failed_mecenat = make_volunteer(
session, edition, first_name="Jo", last_name="Dupuis"
)
failed_mecenat.fee_payment_status = FeePaymentStatus.FAILED
failed_mecenat.mecenat_address = "1 rue X"
session.add(failed_mecenat)
session.commit()
resp = client.get(_url(edition, "/mecenat-receipts"))
assert resp.status_code == 200
assert resp.headers["content-type"] == "application/pdf"
assert f"recus-mecenat-{edition.year}.pdf" in resp.headers["content-disposition"]
reader = PdfReader(io.BytesIO(resp.content))
# Un reçu = 2 pages (le cerfa officiel) — un seul bénévole qualifie ici.
assert len(reader.pages) == 2
donor_page_text = reader.pages[1].extract_text()
assert "Martin" in donor_page_text
assert "Alex" in donor_page_text
assert "50,00" in donor_page_text
assert "05/03/2026" in donor_page_text
assert "Petit" not in donor_page_text
def test_mecenat_receipts_is_422_without_any_paid_donor(client, edition):
resp = client.get(_url(edition, "/mecenat-receipts"))
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "mecenat.no_receipts"
def test_mecenat_receipts_is_coordinator_only(
edition, session, make_account, as_account
):
volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
_mark_mecenat_paid(session, volunteer)
lead = make_account(Role.LEAD, email="lead@e.fr")
resp = as_account(lead).get(_url(edition, "/mecenat-receipts"))
assert resp.status_code == 403
+29
View File
@@ -106,3 +106,32 @@ def test_render_magic_link_uses_translations():
assert subject assert subject
assert "https://x/connexion?token=abc" in body assert "https://x/connexion?token=abc" in body
assert "30" in body assert "30" in body
def test_message_without_logo_has_no_inline_image():
message = sender.build_message("a@b.fr", "Sujet", "Corps")
html_part = message.get_body(preferencelist=("html",))
assert "cid:" not in html_part.get_content()
assert not any(part.get_content_type() == "image/png" for part in message.walk())
def test_logo_is_embedded_inline_never_as_an_external_url():
# Le logo est une pièce jointe « inline » (Content-ID), jamais une URL :
# `GET .../form/logo` exige le `access_token` du formulaire public
# depuis l'étape 27 — une URL externe dans l'email l'aurait exposé à
# quiconque lit la source du message (voir `app/email/branding.py`).
logo_bytes = b"\x89PNG\r\n\x1a\n" + b"fake-png-bytes"
message = sender.build_message(
"a@b.fr", "Sujet", "Corps", logo=(logo_bytes, "image/png")
)
html_part = message.get_body(preferencelist=("html",))
assert "cid:edition-logo" in html_part.get_content()
assert "access_token" not in html_part.get_content()
assert "http://" not in html_part.get_content()
assert "https://" not in html_part.get_content()
image_parts = [p for p in message.walk() if p.get_content_type() == "image/png"]
assert len(image_parts) == 1
assert image_parts[0].get_content() == logo_bytes
assert image_parts[0]["Content-ID"] == "<edition-logo>"
+131 -13
View File
@@ -6,14 +6,27 @@ import datetime as dt
import io import io
import uuid import uuid
from app.auth.rate_limit import form_submission_limiter import pytest
from app.models import PendingFormSubmission, Role, local_now
from app.auth.rate_limit import form_submission_limiter, pending_status_limiter
from app.models import FormTemplate, 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 from tests.mail import plain_text
_MERCHANT = "MXXXXX" _MERCHANT = "MXXXXX"
_API_KEY = "sup_sk_secret" _API_KEY = "sup_sk_secret"
# `FormTemplate.access_token` est aléatoire par défaut (voir
# `app/models/domain.py`) — fixé pour toute inscription en attente créée
# par ce module, pour ne pas avoir à le relire dynamiquement dans chaque
# test (voir `_fixed_access_token` plus bas).
_ACCESS_TOKEN = "test-access-token"
@pytest.fixture(autouse=True)
def _fixed_access_token(session, edition):
session.add(FormTemplate(edition_id=edition.id, access_token=_ACCESS_TOKEN))
session.commit()
def _configure_sumup(client, edition, amount=12.5, label="Cotisation classique"): def _configure_sumup(client, edition, amount=12.5, label="Cotisation classique"):
@@ -58,7 +71,14 @@ def _form_url(edition, suffix=""):
return f"/editions/{edition.id}/form{suffix}" return f"/editions/{edition.id}/form{suffix}"
def _public(edition, suffix):
"""`GET .../form/public` et `GET .../form/logo` exigent `?token=...` en
plus de l'édition (voir `app/api/forms.py:_require_access_token`)."""
return f"{_form_url(edition, suffix)}?token={_ACCESS_TOKEN}"
def _submit(client, edition, **fields): def _submit(client, edition, **fields):
fields.setdefault("access_token", _ACCESS_TOKEN)
return client.post(_form_url(edition, "/public/submit"), data=fields) return client.post(_form_url(edition, "/public/submit"), data=fields)
@@ -112,11 +132,55 @@ def test_clearing_description_with_an_explicit_null(client, edition):
assert resp.json()["description"] is None assert resp.json()["description"] is None
def test_public_form_requires_the_access_token(raw_client, edition):
resp = raw_client.get(_form_url(edition, "/public"))
assert resp.status_code == 422 # `token` absent
resp = raw_client.get(_form_url(edition, "/public?token=wrong"))
assert resp.status_code == 404
assert resp.json()["detail"]["code"] == "error.not_found"
def test_submitting_without_the_right_access_token_is_rejected(raw_client, edition):
resp = raw_client.post(
_form_url(edition, "/public/submit"),
data={
"first_name": "Alex",
"last_name": "Martin",
"hcaptcha_token": "x",
"access_token": "wrong",
},
)
assert resp.status_code == 404
assert resp.json()["detail"]["code"] == "error.not_found"
def test_regenerating_the_access_token_invalidates_the_old_link(
client, raw_client, edition
):
old_token = client.get(_form_url(edition)).json()["access_token"]
assert old_token == _ACCESS_TOKEN
resp = client.post(_form_url(edition, "/regenerate-token"))
assert resp.status_code == 200
new_token = resp.json()["access_token"]
assert new_token != old_token
assert raw_client.get(_form_url(edition, f"/public?token={old_token}")).status_code == 404
assert raw_client.get(_form_url(edition, f"/public?token={new_token}")).status_code == 200
def test_regenerate_token_is_coordinator_only(edition, make_account, as_account):
lead = make_account(Role.LEAD, email="lead@e.fr")
resp = as_account(lead).post(_form_url(edition, "/regenerate-token"))
assert resp.status_code == 403
def test_sumup_not_configured_by_default(client, raw_client, edition): def test_sumup_not_configured_by_default(client, raw_client, edition):
body = client.get(_form_url(edition)).json() body = client.get(_form_url(edition)).json()
assert body["sumup_merchant_code"] is None assert body["sumup_merchant_code"] is None
assert body["sumup_api_key_set"] is False assert body["sumup_api_key_set"] is False
public = raw_client.get(_form_url(edition, "/public")).json() public = raw_client.get(_public(edition, "/public")).json()
assert public["sumup_enabled"] is False assert public["sumup_enabled"] is False
assert public["payment_options"] == [] assert public["payment_options"] == []
@@ -134,10 +198,10 @@ def test_setting_sumup_credentials_never_echoes_the_api_key(client, raw_client,
# Identifiants seuls, sans aucun type de paiement : pas encore activable. # Identifiants seuls, sans aucun type de paiement : pas encore activable.
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]}) client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
assert raw_client.get(_form_url(edition, "/public")).json()["sumup_enabled"] is False assert raw_client.get(_public(edition, "/public")).json()["sumup_enabled"] is False
client.post(_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20}) client.post(_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20})
assert raw_client.get(_form_url(edition, "/public")).json()["sumup_enabled"] is True assert raw_client.get(_public(edition, "/public")).json()["sumup_enabled"] is True
def test_payment_options_start_empty(client, edition): def test_payment_options_start_empty(client, edition):
@@ -162,7 +226,7 @@ def test_creating_and_listing_payment_options(client, raw_client, edition):
assert [o["label"] for o in listed] == ["Cotisation classique", "Mécénat"] assert [o["label"] for o in listed] == ["Cotisation classique", "Mécénat"]
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]}) client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
public_options = raw_client.get(_form_url(edition, "/public")).json()["payment_options"] public_options = raw_client.get(_public(edition, "/public")).json()["payment_options"]
assert [o["label"] for o in public_options] == ["Cotisation classique", "Mécénat"] assert [o["label"] for o in public_options] == ["Cotisation classique", "Mécénat"]
assert [o["amount"] for o in public_options] == [20, 50] assert [o["amount"] for o in public_options] == [20, 50]
@@ -251,7 +315,7 @@ def test_logo_is_coordinator_only(edition, make_account, as_account):
def test_no_logo_by_default(client, raw_client, edition): def test_no_logo_by_default(client, raw_client, edition):
assert client.get(_form_url(edition)).json()["has_logo"] is False assert client.get(_form_url(edition)).json()["has_logo"] is False
resp = raw_client.get(_form_url(edition, "/logo")) resp = raw_client.get(_public(edition, "/logo"))
assert resp.status_code == 404 assert resp.status_code == 404
@@ -264,7 +328,7 @@ def test_uploading_a_logo_and_reading_it_back_publicly(client, raw_client, editi
assert client.get(_form_url(edition)).json()["has_logo"] is True assert client.get(_form_url(edition)).json()["has_logo"] is True
logo = raw_client.get(_form_url(edition, "/logo")) logo = raw_client.get(_public(edition, "/logo"))
assert logo.status_code == 200 assert logo.status_code == 200
assert logo.headers["content-type"] == "image/png" assert logo.headers["content-type"] == "image/png"
assert logo.content == _PNG assert logo.content == _PNG
@@ -285,7 +349,7 @@ def test_public_config_reports_the_logo_and_description(client, raw_client, edit
_form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")} _form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")}
) )
body = raw_client.get(_form_url(edition, "/public")).json() body = raw_client.get(_public(edition, "/public")).json()
assert body["description"] == "Bienvenue au festival !" assert body["description"] == "Bienvenue au festival !"
assert body["has_logo"] is True assert body["has_logo"] is True
@@ -296,7 +360,7 @@ def test_public_form_hides_positions_and_artists_unless_enabled(
make_position(session, edition, name="Bar central") make_position(session, edition, name="Bar central")
make_artist(session, edition, name="Chilla") make_artist(session, edition, name="Chilla")
resp = raw_client.get(_form_url(edition, "/public")) resp = raw_client.get(_public(edition, "/public"))
body = resp.json() body = resp.json()
assert body["positions"] == [] assert body["positions"] == []
assert body["artists"] == [] assert body["artists"] == []
@@ -305,7 +369,7 @@ def test_public_form_hides_positions_and_artists_unless_enabled(
_form_url(edition, "/fields"), _form_url(edition, "/fields"),
json={"fields": ["POSITION_WISHES", "ARTIST_WISHES"]}, json={"fields": ["POSITION_WISHES", "ARTIST_WISHES"]},
) )
resp = raw_client.get(_form_url(edition, "/public")) resp = raw_client.get(_public(edition, "/public"))
body = resp.json() body = resp.json()
assert [p["name"] for p in body["positions"]] == ["Bar central"] assert [p["name"] for p in body["positions"]] == ["Bar central"]
assert [a["name"] for a in body["artists"]] == ["Chilla"] assert [a["name"] for a in body["artists"]] == ["Chilla"]
@@ -429,7 +493,12 @@ def test_submitting_with_a_photo(raw_client, client, edition):
client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]}) client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]})
resp = raw_client.post( resp = raw_client.post(
_form_url(edition, "/public/submit"), _form_url(edition, "/public/submit"),
data={"first_name": "Alex", "last_name": "Martin", "hcaptcha_token": "x"}, data={
"first_name": "Alex",
"last_name": "Martin",
"hcaptcha_token": "x",
"access_token": _ACCESS_TOKEN,
},
files={"photo": ("selfie.png", io.BytesIO(_PNG), "image/png")}, files={"photo": ("selfie.png", io.BytesIO(_PNG), "image/png")},
) )
assert resp.status_code == 201 assert resp.status_code == 201
@@ -447,13 +516,41 @@ def test_submitting_an_unsupported_photo_type_is_rejected(raw_client, client, ed
client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]}) client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]})
resp = raw_client.post( resp = raw_client.post(
_form_url(edition, "/public/submit"), _form_url(edition, "/public/submit"),
data={"first_name": "Alex", "last_name": "Martin", "hcaptcha_token": "x"}, data={
"first_name": "Alex",
"last_name": "Martin",
"hcaptcha_token": "x",
"access_token": _ACCESS_TOKEN,
},
files={"photo": ("selfie.gif", b"not-a-real-gif", "image/gif")}, files={"photo": ("selfie.gif", b"not-a-real-gif", "image/gif")},
) )
assert resp.status_code == 422 assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "form.photo_type_not_supported" assert resp.json()["detail"]["code"] == "form.photo_type_not_supported"
def test_submitting_a_fake_photo_with_a_spoofed_content_type_is_rejected(
raw_client, client, edition
):
# Endpoint public, sans authentification : le `Content-Type` du fichier
# n'est qu'un en-tête fourni par l'appelant, jamais vérifié par
# lui-même — un fichier qui prétend être une image sans en être une
# planterait sinon la génération des badges/feuilles de poste plus tard
# (voir `app/api/forms.py:_read_photo`).
client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]})
resp = raw_client.post(
_form_url(edition, "/public/submit"),
data={
"first_name": "Alex",
"last_name": "Martin",
"hcaptcha_token": "x",
"access_token": _ACCESS_TOKEN,
},
files={"photo": ("selfie.png", b"this is not actually a png file", "image/png")},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "form.photo_type_not_supported"
def test_submitting_more_than_two_position_wishes_is_rejected( def test_submitting_more_than_two_position_wishes_is_rejected(
raw_client, client, edition, session raw_client, client, edition, session
): ):
@@ -884,3 +981,24 @@ def test_submitting_is_rate_limited_per_edition(raw_client, edition):
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 == 429 assert resp.status_code == 429
def test_pending_status_polling_is_rate_limited_per_token(
raw_client, client, edition, monkeypatch
):
# Chaque appel relit l'état réel auprès de SumUp (voir
# `get_pending_status`) — sans limite, un jeton connu (ex. une
# inscription abandonnée par son propre auteur) permettrait de
# solliciter l'API SumUp de l'édition indéfiniment.
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"]
for _ in range(pending_status_limiter.max_attempts):
pending_status_limiter.hit(token)
assert raw_client.get(_form_url(edition, f"/public/pending/{token}")).status_code == 429
@@ -0,0 +1,59 @@
from app.mecenat.number_words import amount_in_words
def test_zero_centimes_only_euros():
assert amount_in_words(15.0) == "Quinze euros"
def test_singular_euro_has_no_s():
assert amount_in_words(1.0) == "Un euro"
def test_et_un_forms():
assert amount_in_words(21.0) == "Vingt et un euros"
assert amount_in_words(31.0) == "Trente et un euros"
assert amount_in_words(61.0) == "Soixante et un euros"
def test_seventies_irregular_et_onze():
assert amount_in_words(70.0) == "Soixante-dix euros"
assert amount_in_words(71.0) == "Soixante et onze euros"
assert amount_in_words(72.0) == "Soixante-douze euros"
assert amount_in_words(79.0) == "Soixante-dix-neuf euros"
def test_eighties_no_et_ever():
assert amount_in_words(80.0) == "Quatre-vingts euros"
assert amount_in_words(81.0) == "Quatre-vingt-un euros"
assert amount_in_words(91.0) == "Quatre-vingt-onze euros"
assert amount_in_words(99.0) == "Quatre-vingt-dix-neuf euros"
def test_hundreds_agreement():
assert amount_in_words(100.0) == "Cent euros"
assert amount_in_words(101.0) == "Cent un euros"
assert amount_in_words(200.0) == "Deux cents euros"
assert amount_in_words(201.0) == "Deux cent un euros"
def test_thousands_are_invariable():
assert amount_in_words(1000.0) == "Mille euros"
assert amount_in_words(2000.0) == "Deux mille euros"
assert amount_in_words(1234.0) == "Mille deux cent trente-quatre euros"
def test_cents_only_when_no_euros():
assert amount_in_words(0.5) == "Cinquante centimes"
assert amount_in_words(0.01) == "Un centime"
def test_euros_and_cents_joined_with_et():
assert amount_in_words(1234.56) == (
"Mille deux cent trente-quatre euros et cinquante-six centimes"
)
assert amount_in_words(1.01) == "Un euro et un centime"
def test_rounds_to_the_nearest_cent():
# Erreurs de flottant (ex. 0.1 + 0.2) ne doivent jamais se voir.
assert amount_in_words(19.999999) == "Vingt euros"
+19 -1
View File
@@ -1,6 +1,6 @@
from sqlmodel import select from sqlmodel import select
from app.models import Edition, Volunteer from app.models import Edition, FeePaymentStatus, Volunteer
from app.seed import DEMO_EDITION_NAME, build from app.seed import DEMO_EDITION_NAME, build
@@ -21,6 +21,9 @@ def test_build_creates_a_realistic_dataset(session):
assert summary.position_wishes > 0 assert summary.position_wishes > 0
assert summary.artist_wishes > 0 assert summary.artist_wishes > 0
assert summary.companion_wishes > 0 assert summary.companion_wishes > 0
# Paiement de la cotisation : au moins un exemple de chaque chemin.
assert summary.fee_paid_classique > 0
assert summary.fee_paid_mecenat > 0
edition = session.exec( edition = session.exec(
select(Edition).where(Edition.name == DEMO_EDITION_NAME) select(Edition).where(Edition.name == DEMO_EDITION_NAME)
@@ -30,6 +33,19 @@ def test_build_creates_a_realistic_dataset(session):
).all() ).all()
assert len(volunteers) == 120 assert len(volunteers) == 120
paid = [v for v in volunteers if v.fee_payment_status == FeePaymentStatus.PAID]
assert len(paid) == summary.fee_paid_classique + summary.fee_paid_mecenat
mecenat = [v for v in paid if v.mecenat_address is not None]
assert len(mecenat) == summary.fee_paid_mecenat
for volunteer in mecenat:
assert volunteer.mecenat_city and volunteer.mecenat_postal_code
assert volunteer.mecenat_country == "France"
assert volunteer.fee_payment_label == "Mécénat"
classique = [v for v in paid if v.mecenat_address is None]
for volunteer in classique:
assert volunteer.fee_payment_label == "Cotisation classique"
assert volunteer.fee_amount == 15.0
def test_build_is_deterministic_and_idempotent(session): def test_build_is_deterministic_and_idempotent(session):
first = build(session) first = build(session)
@@ -47,6 +63,8 @@ def test_build_is_deterministic_and_idempotent(session):
assert second.position_wishes == first.position_wishes assert second.position_wishes == first.position_wishes
assert second.artist_wishes == first.artist_wishes assert second.artist_wishes == first.artist_wishes
assert second.companion_wishes == first.companion_wishes assert second.companion_wishes == first.companion_wishes
assert second.fee_paid_classique == first.fee_paid_classique
assert second.fee_paid_mecenat == first.fee_paid_mecenat
editions = session.exec( editions = session.exec(
select(Edition).where(Edition.name == DEMO_EDITION_NAME) select(Edition).where(Edition.name == DEMO_EDITION_NAME)
+15 -4
View File
@@ -113,6 +113,7 @@ dependencies = [
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg", extra = ["binary"] },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyjwt" }, { name = "pyjwt" },
{ name = "pypdf" },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "reportlab" }, { name = "reportlab" },
{ name = "sqlmodel" }, { name = "sqlmodel" },
@@ -137,7 +138,8 @@ requires-dist = [
{ name = "pillow", specifier = ">=11.0" }, { name = "pillow", specifier = ">=11.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
{ name = "pydantic-settings", specifier = ">=2.6" }, { name = "pydantic-settings", specifier = ">=2.6" },
{ name = "pyjwt", specifier = ">=2.9" }, { name = "pyjwt", specifier = ">=2.15" },
{ name = "pypdf", specifier = ">=5.1" },
{ name = "python-multipart", specifier = ">=0.0.12" }, { name = "python-multipart", specifier = ">=0.0.12" },
{ name = "reportlab", specifier = ">=4.2" }, { name = "reportlab", specifier = ">=4.2" },
{ name = "sqlmodel", specifier = ">=0.0.22" }, { name = "sqlmodel", specifier = ">=0.0.22" },
@@ -629,11 +631,20 @@ wheels = [
[[package]] [[package]]
name = "pyjwt" name = "pyjwt"
version = "2.13.0" version = "2.15.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515 } sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274 }, { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860 },
]
[[package]]
name = "pypdf"
version = "6.19.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/ac/63d71aaedb59acbcdef491e6ca6469165e3771c9c74358204818fd9bc5a6/pypdf-6.19.0.tar.gz", hash = "sha256:bbc43aca292369ccc6cbc8a921991ecf2538a3587ab5a116eff06c321d647155", size = 7033266 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl", hash = "sha256:7e5d6e730e7dae87d560a2cee218b852f6498c8be61966f3cd02ead971e48d14", size = 395480 },
] ]
[[package]] [[package]]
+6 -3
View File
@@ -9,9 +9,12 @@ server {
server_tokens off; server_tokens off;
# RGPD / sécurité : les liens magiques (`/connexion?token=…`, un jeton de # RGPD / sécurité : les liens magiques (`/connexion?token=…`, un jeton de
# connexion à usage unique) transitent par cette route. Pas de journal # connexion à usage unique) et le lien d'inscription partagé par la
# d'accès ici — un jeton en clair dans un log de conteneur serait un vrai # coordination (`/inscription/<access_token>`, voir CLAUDE.md § 2,
# moyen de se connecter à la place de quelqu'un pendant sa durée de vie. # `FormTemplate`) transitent tous les deux par cette route. Pas de
# journal d'accès ici — un jeton en clair dans un log de conteneur
# serait un vrai moyen de se connecter à la place de quelqu'un (ou de
# deviner le lien d'inscription) pendant sa durée de vie.
access_log off; access_log off;
# Postures de base pour une appli publique : pas de sniffing de type MIME, # Postures de base pour une appli publique : pas de sniffing de type MIME,
+11 -7
View File
@@ -94,7 +94,9 @@ async function checkPublicForm() {
await api.forms.setFields(e, { await api.forms.setFields(e, {
fields: ['EMAIL', 'POSITION_WISHES', 'ARTIST_WISHES', 'COMMENT', 'WILLING_TO_PAY_FEE'], fields: ['EMAIL', 'POSITION_WISHES', 'ARTIST_WISHES', 'COMMENT', 'WILLING_TO_PAY_FEE'],
}); });
const config = await api.forms.getPublicConfig(e); const template = await api.forms.getTemplate(e);
const formToken = template.access_token;
const config = await api.forms.getPublicConfig(e, formToken);
if (!config.is_open) throw new Error('formulaire public : devrait être ouvert par défaut'); if (!config.is_open) throw new Error('formulaire public : devrait être ouvert par défaut');
if (config.positions.length !== positions.length) { if (config.positions.length !== positions.length) {
throw new Error('formulaire public : postes manquants dans la config publique'); throw new Error('formulaire public : postes manquants dans la config publique');
@@ -107,7 +109,7 @@ async function checkPublicForm() {
// qu'exécuté ici) ; seul le texte d'introduction et la config SumUp sont // qu'exécuté ici) ; seul le texte d'introduction et la config SumUp sont
// vérifiables ici. // vérifiables ici.
await api.forms.setOpen(e, { description: 'Bienvenue au festival de test !' }); await api.forms.setOpen(e, { description: 'Bienvenue au festival de test !' });
const configWithDescription = await api.forms.getPublicConfig(e); const configWithDescription = await api.forms.getPublicConfig(e, formToken);
if (configWithDescription.description !== 'Bienvenue au festival de test !') { if (configWithDescription.description !== 'Bienvenue au festival de test !') {
throw new Error("formulaire public : le texte d'introduction ne revient pas correctement"); throw new Error("formulaire public : le texte d'introduction ne revient pas correctement");
} }
@@ -127,7 +129,7 @@ async function checkPublicForm() {
amount: 15, amount: 15,
}); });
await api.forms.paymentOptions.create(e, { label: 'Mécénat', amount: 50 }); await api.forms.paymentOptions.create(e, { label: 'Mécénat', amount: 50 });
const configWithSumup = await api.forms.getPublicConfig(e); const configWithSumup = await api.forms.getPublicConfig(e, formToken);
if (!configWithSumup.sumup_enabled || configWithSumup.payment_options.length !== 2) { if (!configWithSumup.sumup_enabled || configWithSumup.payment_options.length !== 2) {
throw new Error('formulaire public : la configuration SumUp ne revient pas correctement'); throw new Error('formulaire public : la configuration SumUp ne revient pas correctement');
} }
@@ -136,7 +138,7 @@ async function checkPublicForm() {
let tooManyArtistsRejectedWith = 'non'; let tooManyArtistsRejectedWith = 'non';
if (sameDay.length >= 2) { if (sameDay.length >= 2) {
try { try {
await api.forms.submitPublic(e, { await api.forms.submitPublic(e, formToken, {
first_name: 'Test', first_name: 'Test',
last_name: 'Smoke', last_name: 'Smoke',
hcaptcha_token: 'dev', hcaptcha_token: 'dev',
@@ -151,7 +153,7 @@ async function checkPublicForm() {
let tooManyPositionsRejectedWith = 'non'; let tooManyPositionsRejectedWith = 'non';
if (positions.length >= 3) { if (positions.length >= 3) {
try { try {
await api.forms.submitPublic(e, { await api.forms.submitPublic(e, formToken, {
first_name: 'Test', first_name: 'Test',
last_name: 'Smoke', last_name: 'Smoke',
hcaptcha_token: 'dev', hcaptcha_token: 'dev',
@@ -165,7 +167,7 @@ async function checkPublicForm() {
// Paiement en ligne choisi : rien en base tant que la « banque » (mock) ne // Paiement en ligne choisi : rien en base tant que la « banque » (mock) ne
// confirme pas — voir CLAUDE.md § 2, Paiement de la cotisation. // confirme pas — voir CLAUDE.md § 2, Paiement de la cotisation.
const result = await api.forms.submitPublic(e, { const result = await api.forms.submitPublic(e, formToken, {
first_name: 'test', first_name: 'test',
last_name: 'smoke', last_name: 'smoke',
hcaptcha_token: 'dev', hcaptcha_token: 'dev',
@@ -430,7 +432,9 @@ await checkGridLayout().catch((err) => fail('checkGridLayout', err));
renderPublic('/connexion'); renderPublic('/connexion');
renderPublic('/connexion/coordination'); renderPublic('/connexion/coordination');
renderPublic('/inscription'); // formulaire public, hors de <RequireAuth/> // formulaire public, hors de <RequireAuth/> — jeton fixe du mock (voir
// frontend/src/api/mock/dataset.ts, buildMockState).
renderPublic('/inscription/demo-access-token');
if (coordinator) { if (coordinator) {
setAuthToken(coordinatorToken); setAuthToken(coordinatorToken);
+40 -13
View File
@@ -394,6 +394,14 @@ export function useExportVolunteers() {
}); });
} }
/** Reçus fiscaux (cerfa) des bénévoles ayant payé en mécénat, un PDF pour
* tous les reçus ; même mécanique que `useExportVolunteers`. */
export function useExportMecenatReceipts() {
return useMutation({
mutationFn: () => api.volunteers.exportMecenatReceipts(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();
@@ -645,6 +653,16 @@ export function useUploadFormLogo() {
}); });
} }
/** Révoque le lien d'inscription partagé jusqu'ici (nouveau `access_token`)
* — voir `FormTemplateRead.access_token`. */
export function useRegenerateFormToken() {
const invalidate = useInvalidateFormTemplate();
return useMutation({
mutationFn: () => api.forms.regenerateToken(edition),
onSuccess: invalidate,
});
}
/** Types de paiement de la cotisation (COORDINATION) — voir `FormPaymentOptionRead`. */ /** Types de paiement de la cotisation (COORDINATION) — voir `FormPaymentOptionRead`. */
export function useFormPaymentOptions() { export function useFormPaymentOptions() {
return useQuery({ return useQuery({
@@ -684,19 +702,26 @@ export function useDeleteFormPaymentOption() {
} }
/** URL objet (aperçu) du logo du formulaire public, ou `null` si absent. /** URL objet (aperçu) du logo du formulaire public, ou `null` si absent.
* Utilisable sans compte connecté (`GET .../form/logo` n'est pas protégé — * `GET .../form/logo` exige `token` (voir `_require_access_token` côté
* affiché sur `/inscription`). `refreshKey` (ex. `dataUpdatedAt` de * back-end) — celui de l'édition pour l'écran de configuration
* `useFormTemplate`) force un nouveau fetch après un changement de logo. */ * (COORDINATION, `template.access_token`), celui de l'URL pour la page
export function useFormLogoUrl(hasLogo: boolean, refreshKey: number | string) { * publique (`/inscription/:token`) ; `null` tant qu'il n'est pas connu.
* `refreshKey` (ex. `dataUpdatedAt` de `useFormTemplate`) force un nouveau
* fetch après un changement de logo. */
export function useFormLogoUrl(
hasLogo: boolean,
refreshKey: number | string,
token: string | null,
) {
const [url, setUrl] = useState<string | null>(null); const [url, setUrl] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let objectUrl: string | null = null; let objectUrl: string | null = null;
let cancelled = false; let cancelled = false;
if (!hasLogo) { if (!hasLogo || !token) {
setUrl(null); setUrl(null);
return; return;
} }
api.forms.logoBlob(edition).then((blob) => { api.forms.logoBlob(edition, token).then((blob) => {
if (cancelled || !blob) return; if (cancelled || !blob) return;
objectUrl = URL.createObjectURL(blob); objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl); setUrl(objectUrl);
@@ -705,22 +730,24 @@ export function useFormLogoUrl(hasLogo: boolean, refreshKey: number | string) {
cancelled = true; cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl); if (objectUrl) URL.revokeObjectURL(objectUrl);
}; };
}, [hasLogo, refreshKey]); }, [hasLogo, refreshKey, token]);
return url; return url;
} }
/** Configuration publique du formulaire — utilisable sans compte connecté /** Configuration publique du formulaire — utilisable sans compte connecté
* (écran `/inscription`, hors de `RequireAuth`). */ * (écran `/inscription/:token`, hors de `RequireAuth`). `token` vient de
export function usePublicFormConfig() { * l'URL (voir `PublicSignupPage`) ; requis par le back-end en plus de
* l'édition (`_require_access_token`). */
export function usePublicFormConfig(token: string) {
return useQuery({ return useQuery({
queryKey: ['publicFormConfig', edition], queryKey: ['publicFormConfig', edition, token],
queryFn: () => api.forms.getPublicConfig(edition), queryFn: () => api.forms.getPublicConfig(edition, token),
}); });
} }
export function useSubmitPublicForm() { export function useSubmitPublicForm(token: string) {
return useMutation({ return useMutation({
mutationFn: (body: PublicFormSubmission) => api.forms.submitPublic(edition, body), mutationFn: (body: PublicFormSubmission) => api.forms.submitPublic(edition, token, body),
}); });
} }
+13 -3
View File
@@ -157,6 +157,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
if (!blob) throw new ApiError(500, 'error.generic', []); if (!blob) throw new ApiError(500, 'error.generic', []);
return blob; return blob;
}, },
exportMecenatReceipts: async (editionId) => {
const blob = await requestBlob(`${base(editionId)}/volunteers/mecenat-receipts`);
if (!blob) throw new ApiError(500, 'error.generic', []);
return blob;
},
}, },
accounts: { accounts: {
list: (editionId) => request(`${base(editionId)}/accounts`), list: (editionId) => request(`${base(editionId)}/accounts`),
@@ -289,10 +294,15 @@ export function createHttpApi(baseUrl: string): PlanningApi {
form.append('file', file); form.append('file', file);
return request(`${base(editionId)}/form/logo`, { method: 'PUT', body: form }); return request(`${base(editionId)}/form/logo`, { method: 'PUT', body: form });
}, },
getPublicConfig: (editionId) => request(`${base(editionId)}/form/public`), regenerateToken: (editionId) =>
logoBlob: (editionId) => requestBlob(`${base(editionId)}/form/logo`), request(`${base(editionId)}/form/regenerate-token`, { method: 'POST' }),
submitPublic: (editionId, body: PublicFormSubmission) => { getPublicConfig: (editionId, token) =>
request(`${base(editionId)}/form/public?token=${encodeURIComponent(token)}`),
logoBlob: (editionId, token) =>
requestBlob(`${base(editionId)}/form/logo?token=${encodeURIComponent(token)}`),
submitPublic: (editionId, token, body: PublicFormSubmission) => {
const form = new FormData(); const form = new FormData();
form.append('access_token', token);
form.append('first_name', body.first_name); form.append('first_name', body.first_name);
form.append('last_name', body.last_name); form.append('last_name', body.last_name);
form.append('hcaptcha_token', body.hcaptcha_token); form.append('hcaptcha_token', body.hcaptcha_token);
+2 -1
View File
@@ -91,6 +91,7 @@ export interface MockState {
description: string | null; description: string | null;
sumupMerchantCode: string | null; sumupMerchantCode: string | null;
sumupApiKey: string | null; sumupApiKey: string | null;
accessToken: string;
}; };
formPaymentOptions: FormPaymentOptionRead[]; formPaymentOptions: FormPaymentOptionRead[];
/** Inscriptions en attente de paiement en ligne (voir CLAUDE.md § 2, /** Inscriptions en attente de paiement en ligne (voir CLAUDE.md § 2,
@@ -494,7 +495,7 @@ export function buildMockState(): MockState {
}, },
formTemplate: { formTemplate: {
isOpen: true, fields: [], logo: null, logoType: null, description: null, isOpen: true, fields: [], logo: null, logoType: null, description: null,
sumupMerchantCode: null, sumupApiKey: null, sumupMerchantCode: null, sumupApiKey: null, accessToken: 'demo-access-token',
}, },
formPaymentOptions: [], formPaymentOptions: [],
pendingSubmissions: new Map(), pendingSubmissions: new Map(),
+18 -3
View File
@@ -142,6 +142,7 @@ const formTemplateRead = (): FormTemplateRead => ({
description: state.formTemplate.description, description: state.formTemplate.description,
sumup_merchant_code: state.formTemplate.sumupMerchantCode, sumup_merchant_code: state.formTemplate.sumupMerchantCode,
sumup_api_key_set: state.formTemplate.sumupApiKey !== null, sumup_api_key_set: state.formTemplate.sumupApiKey !== null,
access_token: state.formTemplate.accessToken,
}); });
const sortedPaymentOptions = (): FormPaymentOptionRead[] => const sortedPaymentOptions = (): FormPaymentOptionRead[] =>
@@ -1018,6 +1019,13 @@ export function createMockApi(): PlanningApi {
return (await fetch(dataUrl)).blob(); return (await fetch(dataUrl)).blob();
}, },
exportXlsx: () => delay(MOCK_XLSX_BLOB()), exportXlsx: () => delay(MOCK_XLSX_BLOB()),
exportMecenatReceipts: () => {
const paid = state.volunteers.some(
(v) => v.fee_payment_status === 'PAID' && v.mecenat_address !== null,
);
if (!paid) throw new ApiError(422, 'mecenat.no_receipts');
return delay(MOCK_PDF_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
@@ -1606,11 +1614,17 @@ export function createMockApi(): PlanningApi {
state.formTemplate.logoType = file.type; state.formTemplate.logoType = file.type;
return delay<FormTemplateRead>(formTemplateRead()); return delay<FormTemplateRead>(formTemplateRead());
}, },
logoBlob: async () => { regenerateToken: () => {
state.formTemplate.accessToken = `demo-access-token-${state.seq++}`;
return delay<FormTemplateRead>(formTemplateRead());
},
logoBlob: async (_edition, token) => {
if (token !== state.formTemplate.accessToken) notFound();
if (!state.formTemplate.logo) return null; if (!state.formTemplate.logo) return null;
return (await fetch(state.formTemplate.logo)).blob(); return (await fetch(state.formTemplate.logo)).blob();
}, },
getPublicConfig: (): Promise<PublicFormConfig> => { getPublicConfig: (_edition, token): Promise<PublicFormConfig> => {
if (token !== state.formTemplate.accessToken) notFound();
const fields = state.formTemplate.fields; const fields = state.formTemplate.fields;
return delay({ return delay({
is_open: state.formTemplate.isOpen, is_open: state.formTemplate.isOpen,
@@ -1688,7 +1702,8 @@ export function createMockApi(): PlanningApi {
return delay(undefined); return delay(undefined);
}, },
}, },
submitPublic: async (_edition, body: PublicFormSubmission) => { submitPublic: async (_edition, token, body: PublicFormSubmission) => {
if (token !== state.formTemplate.accessToken) notFound();
if (!state.formTemplate.isOpen) throw new ApiError(422, 'form.closed'); if (!state.formTemplate.isOpen) throw new ApiError(422, 'form.closed');
if (!body.hcaptcha_token) throw new ApiError(422, 'form.captcha_failed'); if (!body.hcaptcha_token) throw new ApiError(422, 'form.captcha_failed');
if (!body.first_name.trim() || !body.last_name.trim()) { if (!body.first_name.trim() || !body.last_name.trim()) {
+3
View File
@@ -592,6 +592,9 @@ export interface FormTemplateRead {
sumup_merchant_code: string | null; sumup_merchant_code: string | null;
/** `sumup_api_key` (secret) n'est jamais renvoyé — seulement s'il est défini. */ /** `sumup_api_key` (secret) n'est jamais renvoyé — seulement s'il est défini. */
sumup_api_key_set: boolean; sumup_api_key_set: boolean;
/** Jeton requis pour atteindre `/inscription/:token` — voir
* CLAUDE.md § 2, `FormTemplate`. Visible ici seulement (COORDINATION). */
access_token: string;
} }
/** `undefined`/absent = inchangé pour chaque champ. Chaîne vide sur /** `undefined`/absent = inchangé pour chaque champ. Chaîne vide sur
+16 -5
View File
@@ -93,6 +93,9 @@ export interface PlanningApi {
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). */ /** Export Excel de la liste des bénévoles (COORDINATION). */
exportXlsx(editionId: string): Promise<Blob>; exportXlsx(editionId: string): Promise<Blob>;
/** Reçus fiscaux (cerfa) des bénévoles ayant payé en mécénat, un PDF
* regroupant tous les reçus (COORDINATION). */
exportMecenatReceipts(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. */
@@ -175,11 +178,19 @@ export interface PlanningApi {
setFields(editionId: string, body: FormFieldsSet): Promise<FormTemplateRead>; setFields(editionId: string, body: FormFieldsSet): Promise<FormTemplateRead>;
setOpen(editionId: string, body: FormTemplateUpdate): Promise<FormTemplateRead>; setOpen(editionId: string, body: FormTemplateUpdate): Promise<FormTemplateRead>;
uploadLogo(editionId: string, file: File): Promise<FormTemplateRead>; uploadLogo(editionId: string, file: File): Promise<FormTemplateRead>;
/** Publics — aucune authentification, utilisables sans compte connecté /** Révoque le lien d'inscription partagé jusqu'ici (nouveau
* (y compris `logoBlob`, affiché sur `/inscription`). */ * `access_token`) — l'ancien lien cesse aussitôt de fonctionner. */
getPublicConfig(editionId: string): Promise<PublicFormConfig>; regenerateToken(editionId: string): Promise<FormTemplateRead>;
submitPublic(editionId: string, body: PublicFormSubmission): Promise<FormSubmissionResult>; /** Publics — aucune authentification, mais protégés par `token`
logoBlob(editionId: string): Promise<Blob | null>; * (`FormTemplateRead.access_token`, voir `/inscription/:token`) en plus
* de l'édition : sans lui, aucun de ces appels n'aboutit. */
getPublicConfig(editionId: string, token: string): Promise<PublicFormConfig>;
submitPublic(
editionId: string,
token: string,
body: PublicFormSubmission,
): Promise<FormSubmissionResult>;
logoBlob(editionId: string, token: string): Promise<Blob | null>;
/** Types de paiement de la cotisation (COORDINATION uniquement). */ /** Types de paiement de la cotisation (COORDINATION uniquement). */
paymentOptions: { paymentOptions: {
list(editionId: string): Promise<FormPaymentOptionRead[]>; list(editionId: string): Promise<FormPaymentOptionRead[]>;
+1 -1
View File
@@ -19,7 +19,7 @@ export const routes: RouteObject[] = [
// Formulaire public d'inscription : aucun compte requis, donc hors de // Formulaire public d'inscription : aucun compte requis, donc hors de
// <RequireAuth/> — voir app/api/forms.py (les seuls endpoints publics de // <RequireAuth/> — voir app/api/forms.py (les seuls endpoints publics de
// toute l'API). // toute l'API).
{ path: '/inscription', element: <PublicSignupPage /> }, { path: '/inscription/:token', element: <PublicSignupPage /> },
{ {
element: <RequireAuth />, element: <RequireAuth />,
children: [ children: [
+12 -4
View File
@@ -1,6 +1,7 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'; import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cx } from '@/lib/cx'; import { cx } from '@/lib/cx';
import { useTooltipPosition } from '@/lib/useTooltipPosition';
// Primitives visuelles du design « Bénévoles Festival » : bordures franches, // Primitives visuelles du design « Bénévoles Festival » : bordures franches,
// ombres portées dures, libellés capitales. Aucun texte en dur ici — les // ombres portées dures, libellés capitales. Aucun texte en dur ici — les
@@ -196,20 +197,27 @@ export function HelpTip({
side?: 'top' | 'bottom'; side?: 'top' | 'bottom';
className?: string; className?: string;
}) { }) {
const { anchorRef, tooltipRef, pos, reposition } = useTooltipPosition<
HTMLButtonElement,
HTMLSpanElement
>(side);
return ( return (
<span className={cx('group relative inline-flex align-middle', className)}> <span className={cx('group relative inline-flex align-middle', className)}>
<button <button
ref={anchorRef}
type="button" type="button"
onMouseEnter={reposition}
onFocus={reposition}
className="flex h-[15px] w-[15px] flex-none cursor-help items-center justify-center border-[1.5px] border-ink bg-cream font-mono text-[9px] font-bold leading-none text-ink hover:bg-yellow focus-visible:bg-yellow focus-visible:outline-none" className="flex h-[15px] w-[15px] flex-none cursor-help items-center justify-center border-[1.5px] border-ink bg-cream font-mono text-[9px] font-bold leading-none text-ink hover:bg-yellow focus-visible:bg-yellow focus-visible:outline-none"
aria-label={text} aria-label={text}
> >
? ?
</button> </button>
<span <span
className={cx( ref={tooltipRef}
'pointer-events-none absolute left-1/2 z-20 w-max max-w-[260px] -translate-x-1/2 border-2 border-ink bg-ink px-[9px] py-[7px] font-mono text-[10px] font-normal normal-case leading-relaxed tracking-normal text-cream opacity-0 shadow-brutal-md transition-opacity duration-100 group-hover:opacity-100 group-focus-within:opacity-100', style={{ left: pos.left, top: pos.top }}
side === 'top' ? 'bottom-full mb-[6px]' : 'top-full mt-[6px]', className="pointer-events-none fixed z-30 w-max max-w-[260px] border-2 border-ink bg-ink px-[9px] py-[7px] font-mono text-[10px] font-normal normal-case leading-relaxed tracking-normal text-cream opacity-0 shadow-brutal-md transition-opacity duration-100 group-hover:opacity-100 group-focus-within:opacity-100"
)}
> >
{text} {text}
</span> </span>
+7 -2
View File
@@ -223,6 +223,8 @@
"config.vol.importButton2": "IMPORTER UN FICHIER", "config.vol.importButton2": "IMPORTER UN FICHIER",
"config.vol.exportButton": "EXPORTER EN EXCEL", "config.vol.exportButton": "EXPORTER EN EXCEL",
"config.vol.exporting": "EXPORT…", "config.vol.exporting": "EXPORT…",
"config.vol.mecenatReceiptsButton": "REÇUS MÉCÉNAT (PDF)",
"config.vol.mecenatReceiptsHint": "Génère un PDF regroupant le reçu fiscal (cerfa) de chaque bénévole ayant payé sa cotisation en mécénat, avec ses coordonnées et le montant déjà remplis. Les informations de l'association (encadré du haut) restent à compléter à la main avant remise.",
"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.",
@@ -391,9 +393,11 @@
"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",
"form.link.hint": "À partager aux bénévoles potentiels — n'importe qui muni du lien peut s'inscrire.", "form.link.hint": "À partager aux bénévoles potentiels — n'importe qui muni du lien peut s'inscrire, mais lui seul : sans ce lien précis, impossible de tomber sur le formulaire.",
"form.link.copy": "Copier", "form.link.copy": "Copier",
"form.link.copied": "Copié ✓", "form.link.copied": "Copié ✓",
"form.link.regenerate": "Régénérer le lien",
"form.link.regenerate.help": "Remplace le lien par un nouveau — utile si l'ancien a fuité. L'ancien lien cesse aussitôt de fonctionner ; il faudra repartager le nouveau.",
"form.status.title": "Inscriptions", "form.status.title": "Inscriptions",
"form.status.open": "OUVERTES", "form.status.open": "OUVERTES",
"form.status.closed": "FERMÉES", "form.status.closed": "FERMÉES",
@@ -472,5 +476,6 @@
"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.",
"position_sheets.no_shifts": "Aucun créneau à imprimer pour ces postes." "position_sheets.no_shifts": "Aucun créneau à imprimer pour ces postes.",
"mecenat.no_receipts": "Aucun bénévole n'a payé la cotisation en mécénat pour cette édition."
} }
+51
View File
@@ -0,0 +1,51 @@
import { useRef, useState } from 'react';
interface Position {
left: number;
top: number;
}
/**
* Position (repère viewport, à utiliser avec `position: fixed`) d'une bulle
* ancrée sur un élément déclencheur, recalculée à chaque ouverture et bornée
* à l'intérieur de l'écran (marge `margin`) — un centrage purement CSS
* (`left-1/2 -translate-x-1/2` en position `absolute`) laisse la bulle
* déborder de l'écran quand le déclencheur est près d'un bord (colonne de
* droite, grille défilante…), et la fait passer derrière tout élément
* `position`/`z-index` placé entre elle et la racine (en-têtes figés d'une
* grille défilante, par exemple) : `fixed` s'affranchit des deux à la fois.
*
* `side` n'est qu'une préférence verticale : si la place manque de ce
* côté-là, la bulle bascule automatiquement de l'autre.
*/
export function useTooltipPosition<Anchor extends HTMLElement, Tooltip extends HTMLElement>(
side: 'top' | 'bottom' = 'top',
margin = 8,
) {
const anchorRef = useRef<Anchor>(null);
const tooltipRef = useRef<Tooltip>(null);
const [pos, setPos] = useState<Position>({ left: -9999, top: -9999 });
const reposition = () => {
const anchor = anchorRef.current;
const tooltip = tooltipRef.current;
if (!anchor || !tooltip) return;
const anchorRect = anchor.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const left = Math.min(
Math.max(anchorRect.left + anchorRect.width / 2 - tooltipRect.width / 2, margin),
window.innerWidth - tooltipRect.width - margin,
);
const above = anchorRect.top - tooltipRect.height - 6;
const below = anchorRect.bottom + 6;
let top = side === 'top' ? above : below;
if (side === 'top' && above < margin) top = below;
if (side === 'bottom' && below + tooltipRect.height > window.innerHeight - margin) top = above;
setPos({ left, top });
};
return { anchorRef, tooltipRef, pos, reposition };
}
+24 -2
View File
@@ -7,6 +7,7 @@ import {
useFormLogoUrl, useFormLogoUrl,
useFormPaymentOptions, useFormPaymentOptions,
useFormTemplate, useFormTemplate,
useRegenerateFormToken,
useSetFormFields, useSetFormFields,
useUpdateFormPaymentOption, useUpdateFormPaymentOption,
useUpdateFormTemplate, useUpdateFormTemplate,
@@ -70,7 +71,12 @@ export function FormBuilderPage() {
const saveDescription = useUpdateFormTemplate(); const saveDescription = useUpdateFormTemplate();
const saveSumUp = useUpdateFormTemplate(); const saveSumUp = useUpdateFormTemplate();
const uploadLogo = useUploadFormLogo(); const uploadLogo = useUploadFormLogo();
const logoUrl = useFormLogoUrl(template.data?.has_logo ?? false, template.dataUpdatedAt); const regenerateToken = useRegenerateFormToken();
const logoUrl = useFormLogoUrl(
template.data?.has_logo ?? false,
template.dataUpdatedAt,
template.data?.access_token ?? null,
);
const logoInputRef = useRef<HTMLInputElement>(null); const logoInputRef = useRef<HTMLInputElement>(null);
const [enabled, setEnabled] = useState<Set<FormFieldType> | null>(null); const [enabled, setEnabled] = useState<Set<FormFieldType> | null>(null);
@@ -102,7 +108,7 @@ export function FormBuilderPage() {
}); });
}; };
const link = `${window.location.origin}/inscription`; const link = `${window.location.origin}/inscription/${template.data?.access_token ?? ''}`;
const copyLink = () => { const copyLink = () => {
navigator.clipboard.writeText(link).then(() => { navigator.clipboard.writeText(link).then(() => {
setCopied(true); setCopied(true);
@@ -131,6 +137,22 @@ export function FormBuilderPage() {
</Btn> </Btn>
</div> </div>
<div className="flex items-center gap-[6px]">
<Btn
variant="ghost"
onClick={() => regenerateToken.mutate()}
disabled={regenerateToken.isPending}
>
{regenerateToken.isPending ? t('form.saving') : t('form.link.regenerate')}
</Btn>
<HelpTip text={t('form.link.regenerate.help')} />
{regenerateToken.isError ? (
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
{errorMessage(regenerateToken.error)}
</span>
) : null}
</div>
<div className="mt-2 flex items-center gap-3 border-t-2 border-ink pt-3"> <div className="mt-2 flex items-center gap-3 border-t-2 border-ink pt-3">
<span className="font-display text-[11px] font-extrabold uppercase tracking-wide"> <span className="font-display text-[11px] font-extrabold uppercase tracking-wide">
{t('form.status.title')} {t('form.status.title')}
+17 -11
View File
@@ -24,6 +24,7 @@ import {
import { hourRangeLabel, shortDayLabel } from '@/lib/time'; import { hourRangeLabel, shortDayLabel } from '@/lib/time';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { cx } from '@/lib/cx'; import { cx } from '@/lib/cx';
import { useTooltipPosition } from '@/lib/useTooltipPosition';
export function GrillePage() { export function GrillePage() {
const matrix = useMatrix(); const matrix = useMatrix();
@@ -437,22 +438,19 @@ function ShiftCell({
const isInc = !undefinedNeed && !isCrit && !isOver && assigned < shift.ideal_staff; const isInc = !undefinedNeed && !isCrit && !isOver && assigned < shift.ideal_staff;
const isOk = !undefinedNeed && !isCrit && !isOver && !isInc; const isOk = !undefinedNeed && !isCrit && !isOver && !isInc;
const glyph = undefinedNeed ? '·' : isCrit ? '▲' : isOver ? '▼' : isInc ? '◧' : '✓'; const glyph = undefinedNeed ? '·' : isCrit ? '▲' : isOver ? '▼' : isInc ? '◧' : '✓';
const { anchorRef, tooltipRef, pos, reposition } = useTooltipPosition<
HTMLDivElement,
HTMLDivElement
>('bottom');
return ( return (
<div <div
ref={anchorRef}
onClick={() => onSelect(shift.shift_id)} onClick={() => onSelect(shift.shift_id)}
onMouseEnter={reposition}
style={{ gridColumn, gridRow }} style={{ gridColumn, gridRow }}
className={cx( className={cx(
// `relative z-0` (plutôt que `relative` seul) crée un contexte 'group relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink',
// d'empilement local : l'infobulle interne (`z-20`, voir plus bas)
// ne rivalise qu'avec les autres enfants de cette cellule, jamais
// avec les en-têtes figés de la grille (z-10 à 21) — sans ça, un
// survol déclenché par la molette pendant un défilement (le
// curseur reste immobile à l'écran, ce sont les cellules qui
// défilent dessous) pouvait faire passer l'infobulle au-dessus de
// la ligne d'en-tête « Affectés / idéal » pendant sa transition de
// disparition.
'group relative z-0 flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink',
undefinedNeed && 'bg-sand-dark hover:bg-sand', undefinedNeed && 'bg-sand-dark hover:bg-sand',
isCrit && 'bg-hatch-orange', isCrit && 'bg-hatch-orange',
isOver && 'bg-hatch-mauve hover:bg-mauve-hover', isOver && 'bg-hatch-mauve hover:bg-mauve-hover',
@@ -463,7 +461,15 @@ function ShiftCell({
<span className="font-mono text-[13px] font-bold">{assigned}</span> <span className="font-mono text-[13px] font-bold">{assigned}</span>
<span className="font-mono text-[9px] opacity-50">/{shift.ideal_staff}</span> <span className="font-mono text-[9px] opacity-50">/{shift.ideal_staff}</span>
<span className={cx('font-mono text-[10px] font-bold', isOk && 'text-green')}>{glyph}</span> <span className={cx('font-mono text-[10px] font-bold', isOk && 'text-green')}>{glyph}</span>
<div className="pointer-events-none absolute left-1/2 top-full z-[100] mt-[4px] w-max max-w-[220px] -translate-x-1/2 border-2 border-ink bg-ink px-[9px] py-[7px] text-left font-mono text-[10px] font-normal normal-case leading-relaxed tracking-normal text-cream opacity-0 shadow-brutal-md transition-opacity duration-100 group-hover:opacity-100"> <div
ref={tooltipRef}
style={{ left: pos.left, top: pos.top }}
// `fixed` (plutôt qu'`absolute` ancré sur la cellule) : la bulle
// s'affranchit à la fois du cadrage de la zone de défilement de la
// grille (`overflow-auto`, qui la découpait) et de la pile locale
// des en-têtes figés (qui la faisait passer dessous, invisible) —
// voir `useTooltipPosition`. Recalculée à chaque survol.
className="pointer-events-none fixed z-30 w-max max-w-[220px] border-2 border-ink bg-ink px-[9px] py-[7px] text-left font-mono text-[10px] font-normal normal-case leading-relaxed tracking-normal text-cream opacity-0 shadow-brutal-md transition-opacity duration-100 group-hover:opacity-100">
<div className="mb-[1px] font-mono text-[9px] font-bold text-cream opacity-80"> <div className="mb-[1px] font-mono text-[9px] font-bold text-cream opacity-80">
{hourRangeLabel(shift.starts_at, shift.ends_at)} {hourRangeLabel(shift.starts_at, shift.ends_at)}
</div> </div>
+13 -7
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ApiError } from '@/api'; import { ApiError } from '@/api';
import { import {
@@ -140,15 +141,20 @@ export function PublicSignupPage() {
// `typeof window` : rendu aussi en SSR (voir scripts/smoke.tsx), où // `typeof window` : rendu aussi en SSR (voir scripts/smoke.tsx), où
// `window` n'existe pas — contrairement aux autres pages, rien ici ne // `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. // dépend d'abord du chargement d'une requête pour en différer l'accès.
const token = useMemo( const pendingToken = useMemo(
() => () =>
typeof window === 'undefined' typeof window === 'undefined'
? null ? null
: new URLSearchParams(window.location.search).get('paiement'), : new URLSearchParams(window.location.search).get('paiement'),
[], [],
); );
if (token) return <PaymentConfirmationView token={token} />; // Jeton d'accès au formulaire (`/inscription/:token`, voir CLAUDE.md § 2,
return <PublicSignupForm />; // FormTemplate.access_token) — distinct du jeton de paiement en attente
// ci-dessus, qui n'en a pas besoin (déjà sécurisé par son propre UUID).
const { token: accessToken } = useParams<{ token: string }>();
if (pendingToken) return <PaymentConfirmationView token={pendingToken} />;
if (!accessToken) return null;
return <PublicSignupForm accessToken={accessToken} />;
} }
/** Interrogée en direct (voir CLAUDE.md § 2, Paiement de la cotisation) /** Interrogée en direct (voir CLAUDE.md § 2, Paiement de la cotisation)
@@ -334,10 +340,10 @@ function CheckoutRetryPanel({ token, logoUrl }: { token: string; logoUrl: string
); );
} }
function PublicSignupForm() { function PublicSignupForm({ accessToken }: { accessToken: string }) {
const config = usePublicFormConfig(); const config = usePublicFormConfig(accessToken);
const submit = useSubmitPublicForm(); const submit = useSubmitPublicForm(accessToken);
const logoUrl = useFormLogoUrl(config.data?.has_logo ?? false, config.dataUpdatedAt); const logoUrl = useFormLogoUrl(config.data?.has_logo ?? false, config.dataUpdatedAt, accessToken);
const [firstName, setFirstName] = useState(''); const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState(''); const [lastName, setLastName] = useState('');
@@ -12,6 +12,7 @@ import {
useDeleteVolunteer, useDeleteVolunteer,
useEdition, useEdition,
useExecuteImport, useExecuteImport,
useExportMecenatReceipts,
useExportVolunteers, useExportVolunteers,
usePositions, usePositions,
useReplaceAvailability, useReplaceAvailability,
@@ -489,6 +490,7 @@ export function ConfigVolunteers() {
const updateVolunteer = useUpdateVolunteer(); const updateVolunteer = useUpdateVolunteer();
const deleteVolunteer = useDeleteVolunteer(); const deleteVolunteer = useDeleteVolunteer();
const exportVolunteers = useExportVolunteers(); const exportVolunteers = useExportVolunteers();
const exportMecenatReceipts = useExportMecenatReceipts();
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);
@@ -535,6 +537,19 @@ export function ConfigVolunteers() {
// Erreur exposée via exportVolunteers.isError / .error, affichée plus bas. // Erreur exposée via exportVolunteers.isError / .error, affichée plus bas.
} }
}; };
const handleExportMecenatReceipts = async () => {
try {
const blob = await exportMecenatReceipts.mutateAsync();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'recus-mecenat.pdf';
a.click();
URL.revokeObjectURL(url);
} catch {
// Erreur exposée via exportMecenatReceipts.isError / .error, affichée plus bas.
}
};
if (mode === 'add') { if (mode === 'add') {
return <AddVolunteerForm onClose={() => setMode('list')} />; return <AddVolunteerForm onClose={() => setMode('list')} />;
@@ -577,10 +592,25 @@ export function ConfigVolunteers() {
> >
↑ {exportVolunteers.isPending ? t('config.vol.exporting') : t('config.vol.exportButton')} ↑ {exportVolunteers.isPending ? t('config.vol.exporting') : t('config.vol.exportButton')}
</button> </button>
<button
type="button"
onClick={handleExportMecenatReceipts}
disabled={exportMecenatReceipts.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"
>
↑{' '}
{exportMecenatReceipts.isPending
? t('config.vol.exporting')
: t('config.vol.mecenatReceiptsButton')}
</button>
<HelpTip text={t('config.vol.mecenatReceiptsHint')} />
</div> </div>
{exportVolunteers.isError ? ( {exportVolunteers.isError ? (
<ErrorNote error={exportVolunteers.error} /> <ErrorNote error={exportVolunteers.error} />
) : null} ) : null}
{exportMecenatReceipts.isError ? (
<ErrorNote error={exportMecenatReceipts.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>