Lien d'inscription protégé par jeton, reçus fiscaux mécénat, infobulles et revue de sécurité
- 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:
co-authored by
Claude Sonnet 5
parent
a2b748566b
commit
096b7cd26a
@@ -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 +
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,19 +34,31 @@ 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])
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.mecenat.pdf import MecenatReceipt, generate_mecenat_receipts_pdf
|
||||
|
||||
__all__ = ["MecenatReceipt", "generate_mecenat_receipts_pdf"]
|
||||
Binary file not shown.
@@ -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:]
|
||||
@@ -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")
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
Generated
+15
-4
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user