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
mécanique de téléchargement que les badges/feuilles de poste (blob →
`<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
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
paiement de la cotisation (`app/sumup/`) — `sumup_api_key` est un secret,
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`
(`app/email/sender.py`), pour **tous** les emails envoyés, sans exception.
Logo de l'édition en en-tête si elle en a importé un pour son formulaire
public (`app/email/branding.py:edition_logo_url`, `FormTemplate.logo` —
lien vers `GET .../form/logo`, public) ; à défaut, un bandeau texte avec
le sujet.
public (`app/email/branding.py:edition_logo`, `FormTemplate.logo`) ; à
défaut, un bandeau texte avec le sujet. Intégré en pièce jointe « inline »
(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
`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`
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
sans égalité (coins figés 20/21 > en-têtes jour/heure 12/11 > libellés de
poste 10 > cellules de créneau, chacune isolée dans son propre contexte
d'empilement — `relative z-0` — pour que son infobulle au survol, `z-20`,
ne rivalise jamais avec les en-têtes figés) : deux éléments sticky à égalité
de `z-index` peuvent se chevaucher de façon imprévisible pendant un
défilement selon le navigateur. Largeur adaptative par jour (`columnWidthPx`,
sans égalité entre les éléments sticky de la grille (coins figés 20/21 >
en-têtes jour/heure 12/11 > libellés de poste 10) : deux éléments sticky à
égalité de `z-index` peuvent se chevaucher de façon imprévisible pendant un
défilement selon le navigateur. L'infobulle au survol d'une cellule (détail
du créneau) est en `position: fixed`, positionnée par JS
(`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,
beaucoup ⇒ largeur compacte avec défilement horizontal — jamais de colonnes
étroites par défaut.
@@ -657,6 +697,7 @@ l'étape 21.)*
│ │ ├── solver/ # suggestions OR-Tools (models, engine, loader)
│ │ ├── badges/ # génération PDF des planches (pdf.py, ReportLab)
│ │ ├── 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
│ │ ├── sumup/ # client API SumUp (client.py) — paiement de la cotisation
│ │ ├── 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 |
| 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend, limiteur de tentatives sur `/auth/login` et `/auth/magic-link` | à valider |
| 20 | Planches de badges bénévoles (PDF) : recto personnalisable (fond + champs nom/poste(s)/catégorie/rôle, éditeur glisser-déposer), verso fixe (même image pour tous), écran « Badges » dédié (`/badges`, COORDINATION) | à valider |
| 21 | Formulaire public d'inscription (`/inscription`, sans authentification) : champs activables par la coordination (email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir, vœu de compagnon en texte libre, disponibilité/cotisation déclaratives, photo, commentaire), captcha hCaptcha, ouverture/fermeture, lien à partager, écran de configuration dédié (`/inscriptions`, COORDINATION) — les bénévoles créés rejoignent directement la liste | à valider |
| 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 |
| 23 | Créneaux : plus de génération automatique à la création d'un poste — création manuelle à horaire libre (`ConfigGrid.tsx`), génération depuis les créneaux types conservée en option (« Générer les créneaux ») ; grille des besoins redessinée en vrai axe des temps (une colonne = 30 min, bornée à la plage utilisée par jour, cases fusionnées pour chaque créneau — `buildTimeGrid`/`placeInTimeGrid`) | à valider |
| 24 | Corrections de stabilité de la grille des besoins (suite à l'étape 23) : positionnement explicite (`gridColumn`/`gridRow`) de chaque cellule (un poste aux créneaux non alignés sur 30 min ne décale plus les postes suivants) ; hiérarchie de `z-index` sans égalité entre les en-têtes figés et isolation de l'infobulle de survol dans son propre contexte d'empilement (ne recouvrait plus la ligne « Affectés / idéal » pendant un défilement) | à valider |
| 25 | Paiement de la cotisation via l'API SumUp (remplace le lien statique Yapla) : plusieurs types de paiement configurables par édition (`FormPaymentOption` — libellé + montant, ex. « Cotisation classique », « Mécénat »), 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 |
| 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
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.config import get_settings
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
router = APIRouter(tags=["auth"])
@@ -144,7 +144,7 @@ def request_magic_link(
minutes=settings.magic_link_ttl_minutes,
)
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:
# 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 :
- configuration (COORDINATION) : quels champs sont activés, formulaire
ouvert ou fermé, identifiants SumUp de paiement de la cotisation ;
- publics (**aucune authentification**) : consultation de la configuration
et soumission (protégées 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 possible serveur à serveur, voir `sumup_webhook` plus bas).
- publics (**aucune authentification**, mais — sauf le webhook — protégés
par un `token` requis en plus de `edition_id`, voir `_require_access_token`
et `FormTemplate.access_token` : `/inscription` seul, sans le jeton donné
par la coordination, ne mène nulle part) : consultation de la
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
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`)."""
import datetime as dt
import io
import json
import logging
import secrets
import uuid
from fastapi import (
@@ -36,6 +42,7 @@ from fastapi import (
UploadFile,
status,
)
from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
@@ -64,11 +71,11 @@ from app.api.schemas import (
SumUpWebhookPayload,
)
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.db import get_session
from app.email import (
edition_logo_url,
edition_logo,
render_registration_confirmation,
render_registration_paid_confirmation,
send,
@@ -154,9 +161,20 @@ def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormT
description=template.description,
sumup_merchant_code=template.sumup_merchant_code,
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)
def get_form(
edition: Edition = Depends(get_edition),
@@ -200,6 +218,23 @@ def update_form(
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` #
# --------------------------------------------------------------------------- #
@@ -323,12 +358,15 @@ async def set_logo(
@router.get("/logo")
def get_logo(
token: str,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> 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."""
template = _get_or_create_template(session, edition.id)
_require_access_token(template, token)
if template.logo is None:
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
return Response(
@@ -342,10 +380,12 @@ def get_logo(
# --------------------------------------------------------------------------- #
@router.get("/public", response_model=PublicFormConfig)
def get_public_form(
token: str,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> PublicFormConfig:
template = _get_or_create_template(session, edition.id)
_require_access_token(template, token)
fields = _enabled_fields(session, edition.id)
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:
"""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:
return None
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()
if len(content) > _MAX_PHOTO_BYTES:
raise HTTPException(status_code=413, detail={"code": "form.photo_too_large"})
return (content, content_type) if content else None
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(
@@ -498,6 +555,7 @@ async def submit_public_form(
background: BackgroundTasks,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
access_token: str = Form(...),
first_name: str = Form(...),
last_name: str = Form(...),
hcaptcha_token: str = Form(...),
@@ -517,6 +575,7 @@ async def submit_public_form(
_prune_stale_pending(session, edition.id)
template = _get_or_create_template(session, edition.id)
_require_access_token(template, access_token)
if not template.is_open:
raise HTTPException(status_code=422, detail={"code": "form.closed"})
@@ -622,7 +681,7 @@ async def submit_public_form(
volunteer.email,
subject,
body,
logo_url=edition_logo_url(session, edition.id),
logo=edition_logo(session, edition.id),
)
return FormSubmissionResult(volunteer_id=volunteer.id)
@@ -682,7 +741,7 @@ def _create_pending_checkout(
checkout_reference=f"{pending.id}-{uuid.uuid4().hex[:8]}",
description=f"{pending.payment_label} — édition {edition.year}",
return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook",
redirect_url=f"{base}/inscription?paiement={pending.id}",
redirect_url=f"{base}/inscription/{template.access_token}?paiement={pending.id}",
)
if checkout is None:
return None
@@ -773,7 +832,7 @@ def _promote_pending(
volunteer.email,
subject,
body,
logo_url=edition_logo_url(session, edition.id),
logo=edition_logo(session, edition.id),
)
return volunteer
@@ -861,6 +920,7 @@ def get_pending_status(
`PublicSignupPage.tsx`) jusqu'à ce que le statut ne soit plus `PENDING` —
relit toujours l'état réel auprès de SumUp à chaque appel, jamais
seulement la dernière valeur connue en base."""
pending_status_limiter.hit(str(token))
pending = _get_pending_or_404(session, edition.id, token)
if pending.promoted_volunteer_id is None and pending.checkout_id:
template = _get_or_create_template(session, edition.id)
+2 -2
View File
@@ -14,7 +14,7 @@ from app.api.schemas import (
from app.auth.deps import require_coordinator
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
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.models import Account, Assignment, Edition, Position, Shift, Volunteer
@@ -112,7 +112,7 @@ def send_planning_notification(
recipients.append(volunteer.id)
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(
queued=len(items),
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
# défini, comme `has_logo` pour le logo.
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):
+50
View File
@@ -30,11 +30,13 @@ from app.auth.deps import (
)
from app.db import get_session
from app.exporting import generate_volunteers_xlsx
from app.mecenat import MecenatReceipt, generate_mecenat_receipts_pdf
from app.models import (
Account,
Artist,
Assignment,
Edition,
FeePaymentStatus,
Position,
Role,
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)
def get_volunteer(
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
# 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)
# 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.
"""
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.templates import (
render_magic_link,
@@ -14,7 +14,7 @@ from app.email.templates import (
)
__all__ = [
"edition_logo_url",
"edition_logo",
"render_magic_link",
"render_planning",
"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
`app/email/html.py`) — `None` tant qu'aucun logo n'a été importé sur le
formulaire public (`FormTemplate.logo`, `app/api/forms.py:set_logo`)."""
"""Logo d'une édition pour l'en-tête des emails (voir `app/email/html.py`) —
`None` tant qu'aucun logo n'a été importé sur le formulaire public
(`FormTemplate.logo`, `app/api/forms.py:set_logo`).
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
from sqlmodel import Session, select
from app.config import get_settings
from app.models import FormTemplate
def edition_logo_url(session: Session, edition_id: uuid.UUID) -> str | None:
def edition_logo(session: Session, edition_id: uuid.UUID) -> tuple[bytes, str] | None:
template = session.exec(
select(FormTemplate).where(FormTemplate.edition_id == edition_id)
).first()
if template is None or template.logo is None:
return None
base = get_settings().public_base_url.rstrip("/")
return f"{base}/api/editions/{edition_id}/form/logo"
return template.logo, template.logo_content_type or "image/png"
+13 -6
View File
@@ -18,6 +18,13 @@ _YELLOW = "#FFD23F"
_FONT_DISPLAY = "Arial, Helvetica, sans-serif"
_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:
"""`\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:
"""`logo_url` : voir `app/email/branding.py:edition_logo_url` — `None`
tant que l'édition n'a pas importé de logo, l'en-tête retombe alors sur
un simple bandeau texte (le sujet, en capitales)."""
if logo_url:
def render_html(*, subject: str, body: str, has_logo: bool) -> str:
"""`has_logo` : voir `app/email/branding.py:edition_logo` — `False` tant
que l'édition n'a pas importé de logo, l'en-tête retombe alors sur un
simple bandeau texte (le sujet, en capitales)."""
if has_logo:
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;'
'border:0;">'
)
+28 -18
View File
@@ -12,7 +12,7 @@ from collections.abc import Iterable
from email.message import EmailMessage
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")
@@ -34,20 +34,32 @@ def _smtp_error_summary(exc: smtplib.SMTPException) -> str:
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:
"""`logo_url` (voir `app/email/branding.py:edition_logo_url`) : ajoute une
version HTML au thème de l'appli (`app/email/html.py`) en alternative au
texte brut — le texte reste la version de référence, envoyée telle
quelle si aucun client ne préfère le HTML."""
"""`logo` = (octets, content-type) (voir `app/email/branding.py:
edition_logo`) : ajoute une version HTML au thème de l'appli
(`app/email/html.py`) en alternative au texte brut — le texte reste la
version de référence, envoyée telle quelle si aucun client ne préfère le
HTML. 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["From"] = get_settings().smtp_from
message["To"] = to
message["Subject"] = subject
message.set_content(body)
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
@@ -112,22 +124,20 @@ def deliver_many(messages: list[EmailMessage]) -> None:
message["From"],
_smtp_error_summary(exc),
)
# RGPD : ni destinataire ni corps, seulement le compte et le sujet.
logger.info(
"Emails remis : %d/%d — sujet=%r",
sent,
len(messages),
messages[0]["Subject"],
)
# RGPD : ni destinataire, ni corps, ni sujet — un sujet de récapitulatif
# de planning (`app/api/notifications.py`) peut être personnalisé par la
# coordination avec le nom du bénévole (`{prenom}`/`{nom}`), ce ne serait
# alors plus un simple texte d'interface.
logger.info("Emails remis : %d/%d", sent, len(messages))
def send(to: str, subject: str, body: str, *, logo_url: str | None = None) -> None:
deliver_many([build_message(to, subject, body, logo_url=logo_url)])
def send(to: str, subject: str, body: str, *, logo: tuple[bytes, str] | None = None) -> None:
deliver_many([build_message(to, subject, body, logo=logo)])
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:
"""`items` = itérable de `(destinataire, sujet, corps)` — même logo pour
tous (un seul appel par édition, voir `app/api/notifications.py`)."""
deliver_many([build_message(*item, logo_url=logo_url) for item in items])
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",
}
# 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:
return "Oui" if value else "Non"
def _join_names(ids: list[uuid.UUID], names: dict[uuid.UUID, str]) -> str:
return ", ".join(names[i] for i in ids if i in names)
return _safe_text(", ".join(names[i] for i in ids if i in names))
def _fmt_dt(value) -> str:
@@ -76,24 +88,24 @@ def generate_volunteers_xlsx(
for v in volunteers:
sheet.append(
[
v.first_name,
v.last_name,
v.email or "",
v.phone or "",
_safe_text(v.first_name),
_safe_text(v.last_name),
_safe_text(v.email or ""),
_safe_text(v.phone or ""),
_yes_no(v.self_declared_available),
_join_names(v.wished_position_ids, position_names),
_join_names(v.artist_ids, artist_names),
_join_names(v.wished_companion_ids, volunteer_names),
v.companion_wish_note or "",
v.fee_payment_label or "",
_safe_text(v.companion_wish_note or ""),
_safe_text(v.fee_payment_label or ""),
v.fee_amount if v.fee_amount is not None else "",
_FEE_STATUS_LABELS.get(v.fee_payment_status, ""),
_fmt_dt(v.fee_paid_at),
v.mecenat_address or "",
v.mecenat_city or "",
v.mecenat_postal_code or "",
v.mecenat_country or "",
v.comment or "",
_safe_text(v.mecenat_address or ""),
_safe_text(v.mecenat_city or ""),
_safe_text(v.mecenat_postal_code or ""),
_safe_text(v.mecenat_country or ""),
_safe_text(v.comment or ""),
_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_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_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 secrets
import uuid
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.
sumup_merchant_code: 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):
+54
View File
@@ -25,6 +25,7 @@ from app.models import (
Availability,
DayPart,
Edition,
FeePaymentStatus,
Position,
PositionCategory,
Role,
@@ -203,6 +204,14 @@ ARTIST_SLOTS: list[tuple[dt.time, dt.time]] = [
(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
class Summary:
@@ -216,6 +225,8 @@ class Summary:
position_wishes: int = 0
artist_wishes: int = 0
companion_wishes: int = 0
fee_paid_classique: int = 0
fee_paid_mecenat: int = 0
warnings: Counter = field(default_factory=Counter)
blocking: int = 0
@@ -232,6 +243,8 @@ class Summary:
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 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())}",
]
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(
session, edition, shifts, reserved, assigned, availabilities
)
n_fee_classique, n_fee_mecenat = _add_demo_payments(session, volunteers, rng)
session.flush()
fill: Counter = Counter()
@@ -537,6 +551,8 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
position_wishes=n_position_wishes,
artist_wishes=n_artist_wishes,
companion_wishes=n_companion_wishes,
fee_paid_classique=n_fee_classique,
fee_paid_mecenat=n_fee_mecenat,
)
_verify(session, edition, summary)
return summary
@@ -610,6 +626,44 @@ def _add_demo_warnings(
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:
plain = sorted(shifts, key=lambda s: s.starts_at)
for i, first in enumerate(plain):
+6 -1
View File
@@ -12,10 +12,15 @@ dependencies = [
"pydantic-settings>=2.6",
"python-multipart>=0.0.12",
"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",
"ortools>=9.11",
"reportlab>=4.2",
"pypdf>=5.1",
"pillow>=11.0",
"httpx>=0.27",
]
+2
View File
@@ -11,6 +11,7 @@ from app.auth.rate_limit import (
form_submission_limiter,
login_limiter,
magic_link_limiter,
pending_status_limiter,
)
from app.config import get_settings
from app.db import get_session
@@ -36,6 +37,7 @@ def _reset_rate_limiters():
login_limiter.reset()
magic_link_limiter.reset()
form_submission_limiter.reset()
pending_status_limiter.reset()
yield
+97 -1
View File
@@ -3,9 +3,10 @@ import io
import uuid
from openpyxl import load_workbook
from pypdf import PdfReader
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 (
dt_at,
make_artist,
@@ -426,7 +427,102 @@ def test_export_volunteers_as_excel(client, edition, session):
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):
lead = make_account(Role.LEAD, email="lead@e.fr")
resp = as_account(lead).get(_url(edition, "/export"))
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 "https://x/connexion?token=abc" 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 uuid
from app.auth.rate_limit import form_submission_limiter
from app.models import PendingFormSubmission, Role, local_now
import pytest
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 tests.factories import make_artist, make_position
from tests.mail import plain_text
_MERCHANT = "MXXXXX"
_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"):
@@ -58,7 +71,14 @@ def _form_url(edition, 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):
fields.setdefault("access_token", _ACCESS_TOKEN)
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
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):
body = client.get(_form_url(edition)).json()
assert body["sumup_merchant_code"] is None
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["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.
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})
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):
@@ -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"]
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["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):
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
@@ -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
logo = raw_client.get(_form_url(edition, "/logo"))
logo = raw_client.get(_public(edition, "/logo"))
assert logo.status_code == 200
assert logo.headers["content-type"] == "image/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")}
)
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["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_artist(session, edition, name="Chilla")
resp = raw_client.get(_form_url(edition, "/public"))
resp = raw_client.get(_public(edition, "/public"))
body = resp.json()
assert body["positions"] == []
assert body["artists"] == []
@@ -305,7 +369,7 @@ def test_public_form_hides_positions_and_artists_unless_enabled(
_form_url(edition, "/fields"),
json={"fields": ["POSITION_WISHES", "ARTIST_WISHES"]},
)
resp = raw_client.get(_form_url(edition, "/public"))
resp = raw_client.get(_public(edition, "/public"))
body = resp.json()
assert [p["name"] for p in body["positions"]] == ["Bar central"]
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"]})
resp = raw_client.post(
_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")},
)
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"]})
resp = raw_client.post(
_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")},
)
assert resp.status_code == 422
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(
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"
)
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 app.models import Edition, Volunteer
from app.models import Edition, FeePaymentStatus, Volunteer
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.artist_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(
select(Edition).where(Edition.name == DEMO_EDITION_NAME)
@@ -30,6 +33,19 @@ def test_build_creates_a_realistic_dataset(session):
).all()
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):
first = build(session)
@@ -47,6 +63,8 @@ def test_build_is_deterministic_and_idempotent(session):
assert second.position_wishes == first.position_wishes
assert second.artist_wishes == first.artist_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(
select(Edition).where(Edition.name == DEMO_EDITION_NAME)
+15 -4
View File
@@ -113,6 +113,7 @@ dependencies = [
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "pypdf" },
{ name = "python-multipart" },
{ name = "reportlab" },
{ name = "sqlmodel" },
@@ -137,7 +138,8 @@ requires-dist = [
{ name = "pillow", specifier = ">=11.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
{ 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 = "reportlab", specifier = ">=4.2" },
{ name = "sqlmodel", specifier = ">=0.0.22" },
@@ -629,11 +631,20 @@ wheels = [
[[package]]
name = "pyjwt"
version = "2.13.0"
version = "2.15.1"
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 = [
{ 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]]
+6 -3
View File
@@ -9,9 +9,12 @@ server {
server_tokens off;
# RGPD / sécurité : les liens magiques (`/connexion?token=…`, un jeton de
# connexion à usage unique) transitent 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 pendant sa durée de vie.
# connexion à usage unique) et le lien d'inscription partagé par la
# coordination (`/inscription/<access_token>`, voir CLAUDE.md § 2,
# `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;
# 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, {
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.positions.length !== positions.length) {
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
// vérifiables ici.
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 !') {
throw new Error("formulaire public : le texte d'introduction ne revient pas correctement");
}
@@ -127,7 +129,7 @@ async function checkPublicForm() {
amount: 15,
});
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) {
throw new Error('formulaire public : la configuration SumUp ne revient pas correctement');
}
@@ -136,7 +138,7 @@ async function checkPublicForm() {
let tooManyArtistsRejectedWith = 'non';
if (sameDay.length >= 2) {
try {
await api.forms.submitPublic(e, {
await api.forms.submitPublic(e, formToken, {
first_name: 'Test',
last_name: 'Smoke',
hcaptcha_token: 'dev',
@@ -151,7 +153,7 @@ async function checkPublicForm() {
let tooManyPositionsRejectedWith = 'non';
if (positions.length >= 3) {
try {
await api.forms.submitPublic(e, {
await api.forms.submitPublic(e, formToken, {
first_name: 'Test',
last_name: 'Smoke',
hcaptcha_token: 'dev',
@@ -165,7 +167,7 @@ async function checkPublicForm() {
// Paiement en ligne choisi : rien en base tant que la « banque » (mock) ne
// confirme pas — voir CLAUDE.md § 2, Paiement de la cotisation.
const result = await api.forms.submitPublic(e, {
const result = await api.forms.submitPublic(e, formToken, {
first_name: 'test',
last_name: 'smoke',
hcaptcha_token: 'dev',
@@ -430,7 +432,9 @@ await checkGridLayout().catch((err) => fail('checkGridLayout', err));
renderPublic('/connexion');
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) {
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. */
export function useReplaceAvailability() {
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`. */
export function useFormPaymentOptions() {
return useQuery({
@@ -684,19 +702,26 @@ export function useDeleteFormPaymentOption() {
}
/** 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é —
* affiché sur `/inscription`). `refreshKey` (ex. `dataUpdatedAt` de
* `useFormTemplate`) force un nouveau fetch après un changement de logo. */
export function useFormLogoUrl(hasLogo: boolean, refreshKey: number | string) {
* `GET .../form/logo` exige `token` (voir `_require_access_token` côté
* back-end) — celui de l'édition pour l'écran de configuration
* (COORDINATION, `template.access_token`), celui de l'URL pour la page
* 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);
useEffect(() => {
let objectUrl: string | null = null;
let cancelled = false;
if (!hasLogo) {
if (!hasLogo || !token) {
setUrl(null);
return;
}
api.forms.logoBlob(edition).then((blob) => {
api.forms.logoBlob(edition, token).then((blob) => {
if (cancelled || !blob) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
@@ -705,22 +730,24 @@ export function useFormLogoUrl(hasLogo: boolean, refreshKey: number | string) {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [hasLogo, refreshKey]);
}, [hasLogo, refreshKey, token]);
return url;
}
/** Configuration publique du formulaire — utilisable sans compte connecté
* (écran `/inscription`, hors de `RequireAuth`). */
export function usePublicFormConfig() {
* (écran `/inscription/:token`, hors de `RequireAuth`). `token` vient de
* l'URL (voir `PublicSignupPage`) ; requis par le back-end en plus de
* l'édition (`_require_access_token`). */
export function usePublicFormConfig(token: string) {
return useQuery({
queryKey: ['publicFormConfig', edition],
queryFn: () => api.forms.getPublicConfig(edition),
queryKey: ['publicFormConfig', edition, token],
queryFn: () => api.forms.getPublicConfig(edition, token),
});
}
export function useSubmitPublicForm() {
export function useSubmitPublicForm(token: string) {
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', []);
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: {
list: (editionId) => request(`${base(editionId)}/accounts`),
@@ -289,10 +294,15 @@ export function createHttpApi(baseUrl: string): PlanningApi {
form.append('file', file);
return request(`${base(editionId)}/form/logo`, { method: 'PUT', body: form });
},
getPublicConfig: (editionId) => request(`${base(editionId)}/form/public`),
logoBlob: (editionId) => requestBlob(`${base(editionId)}/form/logo`),
submitPublic: (editionId, body: PublicFormSubmission) => {
regenerateToken: (editionId) =>
request(`${base(editionId)}/form/regenerate-token`, { method: 'POST' }),
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();
form.append('access_token', token);
form.append('first_name', body.first_name);
form.append('last_name', body.last_name);
form.append('hcaptcha_token', body.hcaptcha_token);
+2 -1
View File
@@ -91,6 +91,7 @@ export interface MockState {
description: string | null;
sumupMerchantCode: string | null;
sumupApiKey: string | null;
accessToken: string;
};
formPaymentOptions: FormPaymentOptionRead[];
/** Inscriptions en attente de paiement en ligne (voir CLAUDE.md § 2,
@@ -494,7 +495,7 @@ export function buildMockState(): MockState {
},
formTemplate: {
isOpen: true, fields: [], logo: null, logoType: null, description: null,
sumupMerchantCode: null, sumupApiKey: null,
sumupMerchantCode: null, sumupApiKey: null, accessToken: 'demo-access-token',
},
formPaymentOptions: [],
pendingSubmissions: new Map(),
+18 -3
View File
@@ -142,6 +142,7 @@ const formTemplateRead = (): FormTemplateRead => ({
description: state.formTemplate.description,
sumup_merchant_code: state.formTemplate.sumupMerchantCode,
sumup_api_key_set: state.formTemplate.sumupApiKey !== null,
access_token: state.formTemplate.accessToken,
});
const sortedPaymentOptions = (): FormPaymentOptionRead[] =>
@@ -1018,6 +1019,13 @@ export function createMockApi(): PlanningApi {
return (await fetch(dataUrl)).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) => {
if (!state.volunteers.some((v) => v.id === id)) return notFound();
const entries = state.assignments
@@ -1606,11 +1614,17 @@ export function createMockApi(): PlanningApi {
state.formTemplate.logoType = file.type;
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;
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;
return delay({
is_open: state.formTemplate.isOpen,
@@ -1688,7 +1702,8 @@ export function createMockApi(): PlanningApi {
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 (!body.hcaptcha_token) throw new ApiError(422, 'form.captcha_failed');
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_api_key` (secret) n'est jamais renvoyé — seulement s'il est défini. */
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
+16 -5
View File
@@ -93,6 +93,9 @@ export interface PlanningApi {
photoBlob(editionId: string, id: string): Promise<Blob | null>;
/** Export Excel de la liste des bénévoles (COORDINATION). */
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: {
/** 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>;
setOpen(editionId: string, body: FormTemplateUpdate): Promise<FormTemplateRead>;
uploadLogo(editionId: string, file: File): Promise<FormTemplateRead>;
/** Publics — aucune authentification, utilisables sans compte connecté
* (y compris `logoBlob`, affiché sur `/inscription`). */
getPublicConfig(editionId: string): Promise<PublicFormConfig>;
submitPublic(editionId: string, body: PublicFormSubmission): Promise<FormSubmissionResult>;
logoBlob(editionId: string): Promise<Blob | null>;
/** Révoque le lien d'inscription partagé jusqu'ici (nouveau
* `access_token`) — l'ancien lien cesse aussitôt de fonctionner. */
regenerateToken(editionId: string): Promise<FormTemplateRead>;
/** Publics — aucune authentification, mais protégés par `token`
* (`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). */
paymentOptions: {
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
// <RequireAuth/> — voir app/api/forms.py (les seuls endpoints publics de
// toute l'API).
{ path: '/inscription', element: <PublicSignupPage /> },
{ path: '/inscription/:token', element: <PublicSignupPage /> },
{
element: <RequireAuth />,
children: [
+12 -4
View File
@@ -1,6 +1,7 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cx } from '@/lib/cx';
import { useTooltipPosition } from '@/lib/useTooltipPosition';
// Primitives visuelles du design « Bénévoles Festival » : bordures franches,
// ombres portées dures, libellés capitales. Aucun texte en dur ici — les
@@ -196,20 +197,27 @@ export function HelpTip({
side?: 'top' | 'bottom';
className?: string;
}) {
const { anchorRef, tooltipRef, pos, reposition } = useTooltipPosition<
HTMLButtonElement,
HTMLSpanElement
>(side);
return (
<span className={cx('group relative inline-flex align-middle', className)}>
<button
ref={anchorRef}
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"
aria-label={text}
>
?
</button>
<span
className={cx(
'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',
side === 'top' ? 'bottom-full mb-[6px]' : 'top-full mt-[6px]',
)}
ref={tooltipRef}
style={{ left: pos.left, top: pos.top }}
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}
</span>
+7 -2
View File
@@ -223,6 +223,8 @@
"config.vol.importButton2": "IMPORTER UN FICHIER",
"config.vol.exportButton": "EXPORTER EN EXCEL",
"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.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.",
@@ -391,9 +393,11 @@
"form.field.photo": "Une magnifique photo de toi :",
"form.field.comment": "Commentaire",
"form.link.title": "Lien du formulaire",
"form.link.hint": "À partager aux bénévoles potentiels — n'importe qui muni du lien peut s'inscrire.",
"form.link.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.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.open": "OUVERTES",
"form.status.closed": "FERMÉES",
@@ -472,5 +476,6 @@
"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.",
"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,
useFormPaymentOptions,
useFormTemplate,
useRegenerateFormToken,
useSetFormFields,
useUpdateFormPaymentOption,
useUpdateFormTemplate,
@@ -70,7 +71,12 @@ export function FormBuilderPage() {
const saveDescription = useUpdateFormTemplate();
const saveSumUp = useUpdateFormTemplate();
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 [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 = () => {
navigator.clipboard.writeText(link).then(() => {
setCopied(true);
@@ -131,6 +137,22 @@ export function FormBuilderPage() {
</Btn>
</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">
<span className="font-display text-[11px] font-extrabold uppercase tracking-wide">
{t('form.status.title')}
+17 -11
View File
@@ -24,6 +24,7 @@ import {
import { hourRangeLabel, shortDayLabel } from '@/lib/time';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
import { useTooltipPosition } from '@/lib/useTooltipPosition';
export function GrillePage() {
const matrix = useMatrix();
@@ -437,22 +438,19 @@ function ShiftCell({
const isInc = !undefinedNeed && !isCrit && !isOver && assigned < shift.ideal_staff;
const isOk = !undefinedNeed && !isCrit && !isOver && !isInc;
const glyph = undefinedNeed ? '·' : isCrit ? '▲' : isOver ? '▼' : isInc ? '◧' : '✓';
const { anchorRef, tooltipRef, pos, reposition } = useTooltipPosition<
HTMLDivElement,
HTMLDivElement
>('bottom');
return (
<div
ref={anchorRef}
onClick={() => onSelect(shift.shift_id)}
onMouseEnter={reposition}
style={{ gridColumn, gridRow }}
className={cx(
// `relative z-0` (plutôt que `relative` seul) crée un contexte
// 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',
'group relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink',
undefinedNeed && 'bg-sand-dark hover:bg-sand',
isCrit && 'bg-hatch-orange',
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-[9px] opacity-50">/{shift.ideal_staff}</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">
{hourRangeLabel(shift.starts_at, shift.ends_at)}
</div>
+13 -7
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ApiError } from '@/api';
import {
@@ -140,15 +141,20 @@ export function PublicSignupPage() {
// `typeof window` : rendu aussi en SSR (voir scripts/smoke.tsx), où
// `window` n'existe pas — contrairement aux autres pages, rien ici ne
// dépend d'abord du chargement d'une requête pour en différer l'accès.
const token = useMemo(
const pendingToken = useMemo(
() =>
typeof window === 'undefined'
? null
: new URLSearchParams(window.location.search).get('paiement'),
[],
);
if (token) return <PaymentConfirmationView token={token} />;
return <PublicSignupForm />;
// Jeton d'accès au formulaire (`/inscription/:token`, voir CLAUDE.md § 2,
// 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)
@@ -334,10 +340,10 @@ function CheckoutRetryPanel({ token, logoUrl }: { token: string; logoUrl: string
);
}
function PublicSignupForm() {
const config = usePublicFormConfig();
const submit = useSubmitPublicForm();
const logoUrl = useFormLogoUrl(config.data?.has_logo ?? false, config.dataUpdatedAt);
function PublicSignupForm({ accessToken }: { accessToken: string }) {
const config = usePublicFormConfig(accessToken);
const submit = useSubmitPublicForm(accessToken);
const logoUrl = useFormLogoUrl(config.data?.has_logo ?? false, config.dataUpdatedAt, accessToken);
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
@@ -12,6 +12,7 @@ import {
useDeleteVolunteer,
useEdition,
useExecuteImport,
useExportMecenatReceipts,
useExportVolunteers,
usePositions,
useReplaceAvailability,
@@ -489,6 +490,7 @@ export function ConfigVolunteers() {
const updateVolunteer = useUpdateVolunteer();
const deleteVolunteer = useDeleteVolunteer();
const exportVolunteers = useExportVolunteers();
const exportMecenatReceipts = useExportMecenatReceipts();
const [mode, setMode] = useState<Mode>('list');
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.
}
};
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') {
return <AddVolunteerForm onClose={() => setMode('list')} />;
@@ -577,10 +592,25 @@ export function ConfigVolunteers() {
>
↑ {exportVolunteers.isPending ? t('config.vol.exporting') : t('config.vol.exportButton')}
</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>
{exportVolunteers.isError ? (
<ErrorNote error={exportVolunteers.error} />
) : null}
{exportMecenatReceipts.isError ? (
<ErrorNote error={exportMecenatReceipts.error} />
) : null}
</div>
<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>