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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-02 07:47:17 +02:00
co-authored by Claude Sonnet 5
parent a2b748566b
commit 096b7cd26a
44 changed files with 1228 additions and 156 deletions
+2 -2
View File
@@ -29,7 +29,7 @@ from app.auth.rate_limit import login_limiter, magic_link_limiter
from app.auth.tokens import create_access_token, hash_token
from app.config import get_settings
from app.db import get_session
from app.email import edition_logo_url, render_magic_link, send
from app.email import edition_logo, render_magic_link, send
from app.models import Account, MagicLinkToken, local_now
router = APIRouter(tags=["auth"])
@@ -144,7 +144,7 @@ def request_magic_link(
minutes=settings.magic_link_ttl_minutes,
)
background.add_task(
send, account.email, subject, body, logo_url=edition_logo_url(session, edition_id)
send, account.email, subject, body, logo=edition_logo(session, edition_id)
)
if not settings.email_enabled:
# Aide au dev sans SMTP : le jeton (non nominatif) est journalisé.
+73 -13
View File
@@ -3,11 +3,15 @@
Deux familles d'endpoints sous le même routeur :
- configuration (COORDINATION) : quels champs sont activés, formulaire
ouvert ou fermé, identifiants SumUp de paiement de la cotisation ;
- publics (**aucune authentification**) : consultation de la configuration
et soumission (protégées par hCaptcha, `app/forms/captcha.py`, et un filet
de sécurité global, `app/auth/rate_limit.py:form_submission_limiter`), et
le webhook SumUp (`/public/sumup-webhook`, appelé par SumUp lui-même — pas
de hCaptcha possible serveur à serveur, voir `sumup_webhook` plus bas).
- publics (**aucune authentification**, mais — sauf le webhook — protégés
par un `token` requis en plus de `edition_id`, voir `_require_access_token`
et `FormTemplate.access_token` : `/inscription` seul, sans le jeton donné
par la coordination, ne mène nulle part) : consultation de la
configuration et soumission (protégées aussi par hCaptcha,
`app/forms/captcha.py`, et un filet de sécurité global,
`app/auth/rate_limit.py:form_submission_limiter`), et le webhook SumUp
(`/public/sumup-webhook`, appelé par SumUp lui-même — pas de hCaptcha ni
de jeton possible serveur à serveur, voir `sumup_webhook` plus bas).
Nom et prénom sont toujours collectés (indispensables à la création du
bénévole) : ils ne font pas partie des champs activables, contrairement aux
@@ -21,8 +25,10 @@ coordination, faute de savoir si la personne visée est déjà inscrite (voir
CLAUDE.md § 2, `Volunteer`)."""
import datetime as dt
import io
import json
import logging
import secrets
import uuid
from fastapi import (
@@ -36,6 +42,7 @@ from fastapi import (
UploadFile,
status,
)
from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
@@ -64,11 +71,11 @@ from app.api.schemas import (
SumUpWebhookPayload,
)
from app.auth.deps import require_coordinator
from app.auth.rate_limit import form_submission_limiter
from app.auth.rate_limit import form_submission_limiter, pending_status_limiter
from app.config import get_settings
from app.db import get_session
from app.email import (
edition_logo_url,
edition_logo,
render_registration_confirmation,
render_registration_paid_confirmation,
send,
@@ -154,9 +161,20 @@ def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormT
description=template.description,
sumup_merchant_code=template.sumup_merchant_code,
sumup_api_key_set=bool(template.sumup_api_key),
access_token=template.access_token,
)
def _require_access_token(template: FormTemplate, token: str) -> None:
"""`/inscription` seul, sans le bon jeton, ne doit rien révéler —
404 générique (`error.not_found`), jamais un message distinguant
« mauvais jeton » de « aucun formulaire ». Comparaison à temps constant
(`secrets.compare_digest`) : un jeton de longueur correcte mais faux ne
doit pas se deviner caractère par caractère via le temps de réponse."""
if not secrets.compare_digest(token, template.access_token):
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
@router.get("", response_model=FormTemplateRead)
def get_form(
edition: Edition = Depends(get_edition),
@@ -200,6 +218,23 @@ def update_form(
return _template_read(template, _enabled_fields(session, edition.id))
@router.post("/regenerate-token", response_model=FormTemplateRead)
def regenerate_access_token(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> FormTemplateRead:
"""Révoque le lien d'inscription partagé jusqu'ici : un nouveau jeton
remplace l'ancien, qui cesse aussitôt de fonctionner (utile si le lien a
fuité). Rien d'autre n'est affecté — les inscriptions déjà reçues, en
attente de paiement ou en cours de paiement restent valables."""
template = _get_or_create_template(session, edition.id)
template.access_token = secrets.token_urlsafe(16)
session.add(template)
session.commit()
return _template_read(template, _enabled_fields(session, edition.id))
# --------------------------------------------------------------------------- #
# Types de paiement (COORDINATION) — voir `FormPaymentOption` #
# --------------------------------------------------------------------------- #
@@ -323,12 +358,15 @@ async def set_logo(
@router.get("/logo")
def get_logo(
token: str,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> Response:
"""Public (aucune authentification) : affiché sur `/inscription`, comme
"""Public (aucune authentification, mais protégé par `token` — voir
`_require_access_token`) : affiché sur `/inscription/<token>`, comme
`GET .../form/public` — voir le commentaire du module."""
template = _get_or_create_template(session, edition.id)
_require_access_token(template, token)
if template.logo is None:
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
return Response(
@@ -342,10 +380,12 @@ def get_logo(
# --------------------------------------------------------------------------- #
@router.get("/public", response_model=PublicFormConfig)
def get_public_form(
token: str,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> PublicFormConfig:
template = _get_or_create_template(session, edition.id)
_require_access_token(template, token)
fields = _enabled_fields(session, edition.id)
positions: list[PublicPositionOption] = []
@@ -411,7 +451,15 @@ def _prune_stale_pending(session: Session, edition_id: uuid.UUID) -> None:
async def _read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None:
"""Validation partagée par l'inscription immédiate et l'inscription en
attente de paiement — `None` si aucun fichier fourni."""
attente de paiement — `None` si aucun fichier fourni. `Content-Type` est
un en-tête fourni par l'appelant, jamais vérifié par lui-même (ce
endpoint est public, sans authentification) : un fichier qui ne se
décode pas comme une vraie image malgré un `Content-Type` correct
(`image/png`/`image/jpeg` revendiqué) est rejeté ici plutôt que de
planter plus tard la génération des badges/feuilles de poste
(`app/badges/pdf.py`/`app/sheets/pdf.py`, qui lisent ces octets sans
filet) — même validation que l'import d'image des badges
(`app/api/badges.py:_pixel_size`, réservé à la coordination)."""
if photo is None or not photo.filename:
return None
content_type = photo.content_type or ""
@@ -422,7 +470,16 @@ async def _read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None:
content = await photo.read()
if len(content) > _MAX_PHOTO_BYTES:
raise HTTPException(status_code=413, detail={"code": "form.photo_too_large"})
return (content, content_type) if content else None
if not content:
return None
try:
with Image.open(io.BytesIO(content)) as img:
img.verify()
except (UnidentifiedImageError, OSError) as exc:
raise HTTPException(
status_code=422, detail={"code": "form.photo_type_not_supported"}
) from exc
return content, content_type
def _filter_owned_ids(
@@ -498,6 +555,7 @@ async def submit_public_form(
background: BackgroundTasks,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
access_token: str = Form(...),
first_name: str = Form(...),
last_name: str = Form(...),
hcaptcha_token: str = Form(...),
@@ -517,6 +575,7 @@ async def submit_public_form(
_prune_stale_pending(session, edition.id)
template = _get_or_create_template(session, edition.id)
_require_access_token(template, access_token)
if not template.is_open:
raise HTTPException(status_code=422, detail={"code": "form.closed"})
@@ -622,7 +681,7 @@ async def submit_public_form(
volunteer.email,
subject,
body,
logo_url=edition_logo_url(session, edition.id),
logo=edition_logo(session, edition.id),
)
return FormSubmissionResult(volunteer_id=volunteer.id)
@@ -682,7 +741,7 @@ def _create_pending_checkout(
checkout_reference=f"{pending.id}-{uuid.uuid4().hex[:8]}",
description=f"{pending.payment_label} — édition {edition.year}",
return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook",
redirect_url=f"{base}/inscription?paiement={pending.id}",
redirect_url=f"{base}/inscription/{template.access_token}?paiement={pending.id}",
)
if checkout is None:
return None
@@ -773,7 +832,7 @@ def _promote_pending(
volunteer.email,
subject,
body,
logo_url=edition_logo_url(session, edition.id),
logo=edition_logo(session, edition.id),
)
return volunteer
@@ -861,6 +920,7 @@ def get_pending_status(
`PublicSignupPage.tsx`) jusqu'à ce que le statut ne soit plus `PENDING` —
relit toujours l'état réel auprès de SumUp à chaque appel, jamais
seulement la dernière valeur connue en base."""
pending_status_limiter.hit(str(token))
pending = _get_pending_or_404(session, edition.id, token)
if pending.promoted_volunteer_id is None and pending.checkout_id:
template = _get_or_create_template(session, edition.id)
+2 -2
View File
@@ -14,7 +14,7 @@ from app.api.schemas import (
from app.auth.deps import require_coordinator
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
from app.db import get_session
from app.email import edition_logo_url, render_planning, send_many
from app.email import edition_logo, render_planning, send_many
from app.i18n import t
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
@@ -112,7 +112,7 @@ def send_planning_notification(
recipients.append(volunteer.id)
session.commit() # persiste les éventuels MagicLinkToken émis
background.add_task(send_many, items, logo_url=edition_logo_url(session, edition.id))
background.add_task(send_many, items, logo=edition_logo(session, edition.id))
return PlanningNotificationResult(
queued=len(items),
skipped_no_email=skipped_no_email,
+4
View File
@@ -513,6 +513,10 @@ class FormTemplateRead(ApiModel):
# `sumup_api_key` (secret) n'est jamais renvoyé — seulement s'il est
# défini, comme `has_logo` pour le logo.
sumup_api_key_set: bool
# Secret nécessaire pour atteindre le formulaire public
# (`/inscription/<access_token>`, voir CLAUDE.md § 2, `FormTemplate`) —
# exposé seulement ici (COORDINATION), jamais par `PublicFormConfig`.
access_token: str
class FormTemplateUpdate(ApiModel):
+50
View File
@@ -30,11 +30,13 @@ from app.auth.deps import (
)
from app.db import get_session
from app.exporting import generate_volunteers_xlsx
from app.mecenat import MecenatReceipt, generate_mecenat_receipts_pdf
from app.models import (
Account,
Artist,
Assignment,
Edition,
FeePaymentStatus,
Position,
Role,
Shift,
@@ -220,6 +222,54 @@ def export_volunteers(
)
@router.get("/mecenat-receipts")
def export_mecenat_receipts(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
"""Reçus fiscaux (cerfa 11580*05, `app/mecenat/pdf.py`) des bénévoles
ayant payé un type de paiement « mécénat » — repérés par
`mecenat_address` renseigné (voir CLAUDE.md § 2, Paiement de la
cotisation : ce champ n'est rempli que pour ce chemin-là). Déclaré avant
`/{volunteer_id}`, comme `/export` juste au-dessus."""
volunteers = list(
session.exec(
select(Volunteer)
.where(Volunteer.edition_id == edition.id)
.where(Volunteer.fee_payment_status == FeePaymentStatus.PAID)
.where(Volunteer.mecenat_address.is_not(None))
.order_by(Volunteer.last_name, Volunteer.first_name)
)
)
if not volunteers:
raise HTTPException(
status_code=422, detail={"code": "mecenat.no_receipts"}
)
receipts = [
MecenatReceipt(
last_name=v.last_name,
first_name=v.first_name,
address=v.mecenat_address or "",
postal_code=v.mecenat_postal_code or "",
city=v.mecenat_city or "",
country=v.mecenat_country or "",
amount=v.fee_amount or 0.0,
payment_date=(v.fee_paid_at or v.created_at).date(),
)
for v in volunteers
]
content = generate_mecenat_receipts_pdf(receipts)
return Response(
content=content,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="recus-mecenat-{edition.year}.pdf"'
},
)
@router.get("/{volunteer_id}", response_model=VolunteerRead)
def get_volunteer(
volunteer_id: uuid.UUID,
+11
View File
@@ -67,3 +67,14 @@ magic_link_limiter = RateLimiter(max_attempts=3, window_seconds=300)
# n'est qu'un filet de sécurité global côté édition contre un emballement
# malgré tout (30 inscriptions/5 min est très large pour un usage réel).
form_submission_limiter = RateLimiter(max_attempts=30, window_seconds=300)
# Interrogation du statut d'une inscription en attente de paiement
# (`GET .../form/public/pending/{token}`) : relit toujours l'état réel
# auprès de SumUp (voir `app/api/forms.py:get_pending_status`), donc chaque
# appel coûte une requête à l'API SumUp de l'édition. Clé par jeton (pas par
# édition, contrairement à `form_submission_limiter`) : plusieurs
# inscriptions en cours en même temps ne doivent pas s'épuiser mutuellement
# leur quota de sondage. 60/5 min couvre largement le sondage légitime (la
# page de confirmation interroge toutes les 3 s, jusqu'à 40 fois — voir
# `PublicSignupPage.tsx`), tout en bornant un sondage en boucle serrée qui
# n'aurait d'autre effet que de solliciter inutilement l'API SumUp.
pending_status_limiter = RateLimiter(max_attempts=60, window_seconds=300)
+2 -2
View File
@@ -4,7 +4,7 @@
(sans destinataire ni corps — RGPD). Voir CLAUDE.md.
"""
from app.email.branding import edition_logo_url
from app.email.branding import edition_logo
from app.email.sender import send, send_many
from app.email.templates import (
render_magic_link,
@@ -14,7 +14,7 @@ from app.email.templates import (
)
__all__ = [
"edition_logo_url",
"edition_logo",
"render_magic_link",
"render_planning",
"render_registration_confirmation",
+12 -7
View File
@@ -1,20 +1,25 @@
"""URL publique du logo d'une édition, pour l'en-tête des emails (voir
`app/email/html.py`) — `None` tant qu'aucun logo n'a été importé sur le
formulaire public (`FormTemplate.logo`, `app/api/forms.py:set_logo`)."""
"""Logo d'une édition pour l'en-tête des emails (voir `app/email/html.py`) —
`None` tant qu'aucun logo n'a été importé sur le formulaire public
(`FormTemplate.logo`, `app/api/forms.py:set_logo`).
Renvoie les octets (intégrés en pièce jointe « inline », voir
`app/email/sender.py:build_message`), jamais une URL : `GET .../form/logo`
exige désormais le `access_token` du formulaire public
(`app/api/forms.py:_require_access_token`) — une URL externe dans un email
aurait exposé ce jeton à quiconque lit la source du message, pas seulement
la coordination."""
import uuid
from sqlmodel import Session, select
from app.config import get_settings
from app.models import FormTemplate
def edition_logo_url(session: Session, edition_id: uuid.UUID) -> str | None:
def edition_logo(session: Session, edition_id: uuid.UUID) -> tuple[bytes, str] | None:
template = session.exec(
select(FormTemplate).where(FormTemplate.edition_id == edition_id)
).first()
if template is None or template.logo is None:
return None
base = get_settings().public_base_url.rstrip("/")
return f"{base}/api/editions/{edition_id}/form/logo"
return template.logo, template.logo_content_type or "image/png"
+13 -6
View File
@@ -18,6 +18,13 @@ _YELLOW = "#FFD23F"
_FONT_DISPLAY = "Arial, Helvetica, sans-serif"
_FONT_MONO = "'Courier New', Courier, monospace"
# Identifiant de la pièce jointe « inline » du logo (voir
# `app/email/sender.py:build_message`) — jamais une URL externe avec le
# `access_token` du formulaire public en requête : n'importe quel
# destinataire d'un email (lien magique, confirmation d'inscription…) aurait
# pu lire ce jeton depuis la source du message, pas seulement la coordination.
LOGO_CID = "edition-logo"
def _paragraphs(body: str) -> str:
"""`\n\n` sépare les paragraphes, un simple `\n` reste un retour à la
@@ -30,13 +37,13 @@ def _paragraphs(body: str) -> str:
)
def render_html(*, subject: str, body: str, logo_url: str | None) -> str:
"""`logo_url` : voir `app/email/branding.py:edition_logo_url` — `None`
tant que l'édition n'a pas importé de logo, l'en-tête retombe alors sur
un simple bandeau texte (le sujet, en capitales)."""
if logo_url:
def render_html(*, subject: str, body: str, has_logo: bool) -> str:
"""`has_logo` : voir `app/email/branding.py:edition_logo` — `False` tant
que l'édition n'a pas importé de logo, l'en-tête retombe alors sur un
simple bandeau texte (le sujet, en capitales)."""
if has_logo:
header = (
f'<img src="{_html.escape(logo_url)}" alt="" width="160" '
f'<img src="cid:{LOGO_CID}" alt="" width="160" '
'style="max-height:64px;width:auto;display:block;margin:0 auto;'
'border:0;">'
)
+28 -18
View File
@@ -12,7 +12,7 @@ from collections.abc import Iterable
from email.message import EmailMessage
from app.config import Settings, get_settings
from app.email.html import render_html
from app.email.html import LOGO_CID, render_html
logger = logging.getLogger("app.email")
@@ -34,20 +34,32 @@ def _smtp_error_summary(exc: smtplib.SMTPException) -> str:
def build_message(
to: str, subject: str, body: str, *, logo_url: str | None = None
to: str, subject: str, body: str, *, logo: tuple[bytes, str] | None = None
) -> EmailMessage:
"""`logo_url` (voir `app/email/branding.py:edition_logo_url`) : ajoute une
version HTML au thème de l'appli (`app/email/html.py`) en alternative au
texte brut — le texte reste la version de référence, envoyée telle
quelle si aucun client ne préfère le HTML."""
"""`logo` = (octets, content-type) (voir `app/email/branding.py:
edition_logo`) : ajoute une version HTML au thème de l'appli
(`app/email/html.py`) en alternative au texte brut — le texte reste la
version de référence, envoyée telle quelle si aucun client ne préfère le
HTML. Le logo, s'il y en a un, est intégré en pièce jointe « inline »
(Content-ID) référencée par la partie HTML — jamais une URL externe, qui
aurait exposé le jeton d'accès du formulaire public à quiconque lit la
source du message (voir `app/email/branding.py`)."""
message = EmailMessage()
message["From"] = get_settings().smtp_from
message["To"] = to
message["Subject"] = subject
message.set_content(body)
message.add_alternative(
render_html(subject=subject, body=body, logo_url=logo_url), subtype="html"
render_html(subject=subject, body=body, has_logo=logo is not None),
subtype="html",
)
if logo is not None:
content, content_type = logo
maintype, _, subtype = content_type.partition("/")
html_part = message.get_payload()[-1]
html_part.add_related(
content, maintype or "image", subtype or "png", cid=f"<{LOGO_CID}>"
)
return message
@@ -112,22 +124,20 @@ def deliver_many(messages: list[EmailMessage]) -> None:
message["From"],
_smtp_error_summary(exc),
)
# RGPD : ni destinataire ni corps, seulement le compte et le sujet.
logger.info(
"Emails remis : %d/%d — sujet=%r",
sent,
len(messages),
messages[0]["Subject"],
)
# RGPD : ni destinataire, ni corps, ni sujet — un sujet de récapitulatif
# de planning (`app/api/notifications.py`) peut être personnalisé par la
# coordination avec le nom du bénévole (`{prenom}`/`{nom}`), ce ne serait
# alors plus un simple texte d'interface.
logger.info("Emails remis : %d/%d", sent, len(messages))
def send(to: str, subject: str, body: str, *, logo_url: str | None = None) -> None:
deliver_many([build_message(to, subject, body, logo_url=logo_url)])
def send(to: str, subject: str, body: str, *, logo: tuple[bytes, str] | None = None) -> None:
deliver_many([build_message(to, subject, body, logo=logo)])
def send_many(
items: Iterable[tuple[str, str, str]], *, logo_url: str | None = None
items: Iterable[tuple[str, str, str]], *, logo: tuple[bytes, str] | None = None
) -> None:
"""`items` = itérable de `(destinataire, sujet, corps)` — même logo pour
tous (un seul appel par édition, voir `app/api/notifications.py`)."""
deliver_many([build_message(*item, logo_url=logo_url) for item in items])
deliver_many([build_message(*item, logo=logo) for item in items])
+24 -12
View File
@@ -43,13 +43,25 @@ _FEE_STATUS_LABELS = {
"EXPIRED": "Expirée",
}
# Caractères qui font d'une cellule Excel/LibreOffice une formule plutôt
# qu'un texte littéral — injection de formule (CWE-1236) : la plupart de ces
# champs viennent du formulaire public, sans authentification (nom, email,
# téléphone, commentaire, note de compagnon, adresse de mécénat…). Un
# préfixe `'` force l'interprétation en texte, sans changer la valeur
# affichée.
_FORMULA_PREFIXES = ("=", "+", "-", "@")
def _safe_text(value: str) -> str:
return f"'{value}" if value.startswith(_FORMULA_PREFIXES) else value
def _yes_no(value: bool) -> str:
return "Oui" if value else "Non"
def _join_names(ids: list[uuid.UUID], names: dict[uuid.UUID, str]) -> str:
return ", ".join(names[i] for i in ids if i in names)
return _safe_text(", ".join(names[i] for i in ids if i in names))
def _fmt_dt(value) -> str:
@@ -76,24 +88,24 @@ def generate_volunteers_xlsx(
for v in volunteers:
sheet.append(
[
v.first_name,
v.last_name,
v.email or "",
v.phone or "",
_safe_text(v.first_name),
_safe_text(v.last_name),
_safe_text(v.email or ""),
_safe_text(v.phone or ""),
_yes_no(v.self_declared_available),
_join_names(v.wished_position_ids, position_names),
_join_names(v.artist_ids, artist_names),
_join_names(v.wished_companion_ids, volunteer_names),
v.companion_wish_note or "",
v.fee_payment_label or "",
_safe_text(v.companion_wish_note or ""),
_safe_text(v.fee_payment_label or ""),
v.fee_amount if v.fee_amount is not None else "",
_FEE_STATUS_LABELS.get(v.fee_payment_status, ""),
_fmt_dt(v.fee_paid_at),
v.mecenat_address or "",
v.mecenat_city or "",
v.mecenat_postal_code or "",
v.mecenat_country or "",
v.comment or "",
_safe_text(v.mecenat_address or ""),
_safe_text(v.mecenat_city or ""),
_safe_text(v.mecenat_postal_code or ""),
_safe_text(v.mecenat_country or ""),
_safe_text(v.comment or ""),
_fmt_dt(v.created_at),
]
)
+2 -1
View File
@@ -94,5 +94,6 @@
"form.too_many_position_wishes": "Deux vœux de poste au maximum.",
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.",
"position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.",
"position_sheets.no_shifts": "Aucun créneau à imprimer pour ces postes."
"position_sheets.no_shifts": "Aucun créneau à imprimer pour ces postes.",
"mecenat.no_receipts": "Aucun bénévole n'a payé la cotisation en mécénat pour cette édition."
}
+3
View File
@@ -0,0 +1,3 @@
from app.mecenat.pdf import MecenatReceipt, generate_mecenat_receipts_pdf
__all__ = ["MecenatReceipt", "generate_mecenat_receipts_pdf"]
Binary file not shown.
+84
View File
@@ -0,0 +1,84 @@
"""Montant en toutes lettres, en français — remplit l'encadré « Somme en
toutes lettres » du cerfa (voir `pdf.py`). Fonction pure, testée séparément
de la génération PDF : l'orthographe des nombres en français a suffisamment
d'exceptions (accord de "quatre-vingts"/"cents", "et un" irrégulier à 71)
pour mériter ses propres cas de test.
"""
_ONES = [
"zéro", "un", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf",
"dix", "onze", "douze", "treize", "quatorze", "quinze", "seize",
"dix-sept", "dix-huit", "dix-neuf",
]
_TENS_PREFIX = {2: "vingt", 3: "trente", 4: "quarante", 5: "cinquante", 6: "soixante"}
def _under_hundred(n: int) -> str:
"""0 <= n <= 99."""
if n < 20:
return _ONES[n]
if n < 70:
tens, unit = divmod(n, 10)
prefix = _TENS_PREFIX[tens]
if unit == 0:
return prefix
if unit == 1:
return f"{prefix} et un"
return f"{prefix}-{_ONES[unit]}"
if n < 80:
# 70-79 : soixante + dix..dix-neuf, « et onze » irrégulier à 71 (le
# seul cas des années 70 avec « et » — 72..79 n'en prennent pas).
rem = n - 60
if rem == 11:
return "soixante et onze"
return f"soixante-{_ONES[rem]}"
# 80-99 : quatre-vingt(s) + ... — jamais de « et », y compris à 91.
rem = n - 80
if rem == 0:
return "quatre-vingts"
return f"quatre-vingt-{_ONES[rem]}"
def _under_thousand(n: int) -> str:
"""0 <= n <= 999."""
if n < 100:
return _under_hundred(n)
hundreds, rem = divmod(n, 100)
prefix = "cent" if hundreds == 1 else f"{_ONES[hundreds]} cent"
if rem == 0:
# « cents » ne prend un accord au pluriel que sur un multiple exact
# (deux cents, mais deux cent un).
return f"{prefix}s" if hundreds > 1 else prefix
return f"{prefix} {_under_hundred(rem)}"
def _number_to_words(n: int) -> str:
"""0 <= n <= 999 999 999 — largement suffisant pour une cotisation ou un
don, jamais invariable au-delà (« mille » ne prend jamais de « s »)."""
if n == 0:
return "zéro"
parts = []
millions, n = divmod(n, 1_000_000)
if millions:
parts.append("un million" if millions == 1 else f"{_under_thousand(millions)} millions")
thousands, n = divmod(n, 1000)
if thousands:
parts.append("mille" if thousands == 1 else f"{_under_thousand(thousands)} mille")
if n:
parts.append(_under_thousand(n))
return " ".join(parts)
def amount_in_words(amount: float) -> str:
"""« Cent cinquante euros », « cinquante centimes », « un euro et dix
centimes »… — texte de l'encadré « Somme en toutes lettres » du cerfa, à
partir d'un montant en euros (`Volunteer.fee_amount`)."""
cents_total = round(amount * 100)
euros, cents = divmod(cents_total, 100)
parts = []
if euros or not cents:
parts.append(f"{_number_to_words(euros)} {'euro' if euros <= 1 else 'euros'}")
if cents:
parts.append(f"{_number_to_words(cents)} {'centime' if cents <= 1 else 'centimes'}")
text = " et ".join(parts)
return text[:1].upper() + text[1:]
+132
View File
@@ -0,0 +1,132 @@
"""Génération des reçus fiscaux (cerfa 11580*05) pour les dons « mécénat »
de la cotisation — voir `app/api/volunteers.py:generate_mecenat_receipts`.
Module isolé, fonction pure (`generate_mecenat_receipts_pdf`) : aucun accès
base ici. Le cerfa officiel (`cerfa-mecenat-particuliers.pdf`, à côté de ce
fichier) n'est pas un formulaire PDF interactif (aucun champ AcroForm) — les
informations du donateur, du montant et de la date sont donc dessinées
par-dessus la page 2 du modèle, aux coordonnées mesurées sur ce document
précis (mise en page fixe d'un cerfa officiel, jamais destinée à changer).
Les informations de l'organisme bénéficiaire (page 1, encadré « Organisme
bénéficiaire ») ne sont volontairement pas renseignées : à compléter à la
main par la coordination avant remise au bénévole.
"""
import datetime as dt
import io
from dataclasses import dataclass
from pathlib import Path
from pypdf import PageObject, PdfReader, PdfWriter
from reportlab.lib.colors import black
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen import canvas
from app.mecenat.number_words import amount_in_words
_TEMPLATE_PATH = Path(__file__).parent / "cerfa-mecenat-particuliers.pdf"
_PAGE_W = 595.0
_PAGE_H = 842.0
# `bottom` : ligne de base du champ imprimé (pointillés) sur lequel écrire,
# en points depuis le HAUT de la page (repère pdfplumber, utilisé pour
# mesurer ces coordonnées) ; `_NUDGE` replace le texte juste au-dessus de
# cette ligne plutôt que dessus, une fois converti en repère PDF (origine en
# bas — voir `_draw`).
_NUDGE = 1.5
@dataclass(frozen=True)
class _Field:
x: float
bottom: float
max_width: float
font_size: float
bold: bool = False
_LAST_NAME = _Field(x=66, bottom=250.5, max_width=230, font_size=9)
_FIRST_NAME = _Field(x=352, bottom=250.5, max_width=190, font_size=9)
_ADDRESS = _Field(x=136, bottom=281.2, max_width=405, font_size=9)
_POSTAL_CODE = _Field(x=99, bottom=295.5, max_width=33, font_size=8)
_CITY = _Field(x=232, bottom=295.5, max_width=310, font_size=9)
_COUNTRY = _Field(x=67, bottom=310.5, max_width=125, font_size=9)
_AMOUNT = _Field(x=44, bottom=365.2, max_width=110, font_size=9)
_AMOUNT_IN_WORDS = _Field(x=356, bottom=363.1, max_width=190, font_size=8)
_PAYMENT_DATE = _Field(x=184, bottom=380.9, max_width=76, font_size=9)
@dataclass(frozen=True)
class MecenatReceipt:
"""Un reçu = un bénévole ayant payé un type de paiement « mécénat » —
voir `Volunteer.mecenat_*`/`fee_*` (CLAUDE.md § 2, Paiement de la
cotisation)."""
last_name: str
first_name: str
address: str
postal_code: str
city: str
country: str
amount: float
payment_date: dt.date
def _fitted_font_size(text: str, max_width: float, font_size: float, bold: bool) -> float:
"""Réduit la taille de police jusqu'à ce que `text` tienne dans
`max_width` (plancher 6 pt, en dessous le texte reste simplement
tronqué par le tracé plutôt que rendu illisible)."""
font = "Helvetica-Bold" if bold else "Helvetica"
size = font_size
while size > 6 and stringWidth(text, font, size) > max_width:
size -= 0.5
return size
def _draw(c: canvas.Canvas, text: str, field: _Field) -> None:
if not text:
return
font_size = _fitted_font_size(text, field.max_width, field.font_size, field.bold)
c.setFont("Helvetica-Bold" if field.bold else "Helvetica", font_size)
c.setFillColor(black)
c.drawString(field.x, _PAGE_H - field.bottom + _NUDGE, text)
def _overlay_page(receipt: MecenatReceipt) -> PageObject:
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=(_PAGE_W, _PAGE_H))
_draw(c, receipt.last_name, _LAST_NAME)
_draw(c, receipt.first_name, _FIRST_NAME)
_draw(c, receipt.address, _ADDRESS)
_draw(c, receipt.postal_code, _POSTAL_CODE)
_draw(c, receipt.city, _CITY)
_draw(c, receipt.country, _COUNTRY)
_draw(c, f"{receipt.amount:.2f}".replace(".", ","), _AMOUNT)
_draw(c, amount_in_words(receipt.amount), _AMOUNT_IN_WORDS)
_draw(c, receipt.payment_date.strftime("%d/%m/%Y"), _PAYMENT_DATE)
c.showPage()
c.save()
buffer.seek(0)
return PdfReader(buffer).pages[0]
def generate_mecenat_receipts_pdf(receipts: list[MecenatReceipt]) -> bytes:
"""Un reçu = les 2 pages du cerfa officiel, page 2 complétée avec les
informations du donateur, du montant et de la date — voir le docstring
du module. Retourne les octets du PDF concaténant tous les reçus, un
bénévole après l'autre. Le modèle est relu à chaque itération : `merge_page`
mutant la page en place, réutiliser le même objet d'un tour à l'autre
accumulerait les tracés d'un bénévole sur la page du suivant."""
writer = PdfWriter()
for receipt in receipts:
base = PdfReader(_TEMPLATE_PATH)
writer.add_page(base.pages[0])
# `add_page` renvoie la page telle qu'attachée au writer — fusionner
# l'overlay sur ce retour plutôt que sur `base.pages[1]` (page encore
# détachée d'un writer, `merge_page` dépréciée dans ce cas).
donor_page = writer.add_page(base.pages[1])
donor_page.merge_page(_overlay_page(receipt))
buffer = io.BytesIO()
writer.write(buffer)
return buffer.getvalue()
@@ -0,0 +1,44 @@
"""Jeton d'accès au formulaire public (lien non devinable)
Revision ID: 0019_form_access_token
Revises: 0018_pending_submissions_mecenat
Create Date: 2026-09-30
`form_template.access_token` : secret partagé désormais nécessaire pour
atteindre le formulaire public (`/inscription/<jeton>`) — voir CLAUDE.md § 2,
`FormTemplate`, et `app/api/forms.py:_require_access_token`. `/inscription`
seul, sans jeton dans l'URL, ne mène plus nulle part.
Rétrocompatibilité : chaque `form_template` déjà en base reçoit un jeton
généré aléatoirement — les liens déjà partagés cessent de fonctionner, la
coordination doit en repartager un nouveau (« Copier le lien » sur l'écran
Inscriptions) — c'est précisément le but du changement.
"""
import secrets
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0019_form_access_token"
down_revision: str | None = "0018_pending_submissions_mecenat"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"form_template", sa.Column("access_token", sa.String(), nullable=True)
)
conn = op.get_bind()
for (row_id,) in conn.execute(sa.text("SELECT id FROM form_template")):
conn.execute(
sa.text("UPDATE form_template SET access_token = :token WHERE id = :id"),
{"token": secrets.token_urlsafe(16), "id": row_id},
)
op.alter_column("form_template", "access_token", nullable=False)
def downgrade() -> None:
op.drop_column("form_template", "access_token")
+9
View File
@@ -7,6 +7,7 @@ au niveau base (`ondelete="CASCADE"`) ; les relations ORM utilisent
"""
import datetime as dt
import secrets
import uuid
from sqlalchemy import Boolean, CheckConstraint, Column, Integer, LargeBinary, Time
@@ -524,6 +525,14 @@ class FormTemplate(TimestampMixin, table=True):
# un secret, au même titre qu'un mot de passe SMTP.
sumup_merchant_code: str | None = None
sumup_api_key: str | None = None
# Secret partagé nécessaire pour atteindre le formulaire public
# (`GET .../form/public`, `GET .../form/logo`, `POST .../form/public/submit`
# — voir `app/api/forms.py:_require_access_token`) : `/inscription` seul,
# sans ce jeton dans l'URL, ne mène nulle part. Généré aléatoirement à la
# création de la ligne (`secrets.token_urlsafe`) ; régénérable par la
# coordination (`POST .../form/regenerate-token`) pour révoquer un lien
# qui aurait fuité — l'ancien cesse alors immédiatement de fonctionner.
access_token: str = Field(default_factory=lambda: secrets.token_urlsafe(16))
class FormPaymentOption(TimestampMixin, table=True):
+54
View File
@@ -25,6 +25,7 @@ from app.models import (
Availability,
DayPart,
Edition,
FeePaymentStatus,
Position,
PositionCategory,
Role,
@@ -203,6 +204,14 @@ ARTIST_SLOTS: list[tuple[dt.time, dt.time]] = [
(dt.time(21, 30), dt.time(23, 0)),
]
# Adresses postales des bénévoles « mécénat » — voir `_add_demo_payments`.
MECENAT_ADDRESSES = [
("12 rue des Lilas", "75020", "Paris"),
("3 impasse du Petit Bois", "44000", "Nantes"),
("8 avenue de la République", "69003", "Lyon"),
("21 chemin des Vignes", "33000", "Bordeaux"),
]
@dataclass
class Summary:
@@ -216,6 +225,8 @@ class Summary:
position_wishes: int = 0
artist_wishes: int = 0
companion_wishes: int = 0
fee_paid_classique: int = 0
fee_paid_mecenat: int = 0
warnings: Counter = field(default_factory=Counter)
blocking: int = 0
@@ -232,6 +243,8 @@ class Summary:
f" Vœux de poste déclarés : {self.position_wishes}",
f" Vœux d'artiste déclarés : {self.artist_wishes}",
f" Vœux de compagnon déclarés : {self.companion_wishes}",
f" Cotisation payée (classique) : {self.fee_paid_classique}",
f" Cotisation payée (mécénat) : {self.fee_paid_mecenat}",
f" Avertissements actifs : {sum(self.warnings.values())}",
]
for code, count in sorted(self.warnings.items()):
@@ -512,6 +525,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
n_assignments += _add_demo_warnings(
session, edition, shifts, reserved, assigned, availabilities
)
n_fee_classique, n_fee_mecenat = _add_demo_payments(session, volunteers, rng)
session.flush()
fill: Counter = Counter()
@@ -537,6 +551,8 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
position_wishes=n_position_wishes,
artist_wishes=n_artist_wishes,
companion_wishes=n_companion_wishes,
fee_paid_classique=n_fee_classique,
fee_paid_mecenat=n_fee_mecenat,
)
_verify(session, edition, summary)
return summary
@@ -610,6 +626,44 @@ def _add_demo_warnings(
return added
def _add_demo_payments(
session: Session, volunteers: list[Volunteer], rng: random.Random
) -> tuple[int, int]:
"""Quelques bénévoles ayant payé leur cotisation via SumUp — voir
CLAUDE.md § 2, Paiement de la cotisation. `fee_payment_label`/
`fee_amount` sont une copie figée (comme en production) : aucun
`FormPaymentOption` n'a besoin d'exister pour autant. Pour que l'export
Excel et les reçus fiscaux mécénat (`/editions/{id}/volunteers/
mecenat-receipts`) aient de quoi montrer quelque chose en démonstration."""
pool = rng.sample(volunteers, k=12)
classique, mecenat = pool[:8], pool[8:]
for volunteer in classique:
volunteer.fee_payment_label = "Cotisation classique"
volunteer.fee_amount = 15.0
volunteer.fee_payment_status = FeePaymentStatus.PAID
volunteer.fee_paid_at = dt.datetime.combine(
START - dt.timedelta(days=rng.randint(5, 45)), dt.time(rng.randint(9, 21))
)
session.add(volunteer)
for i, volunteer in enumerate(mecenat):
address, postal_code, city = MECENAT_ADDRESSES[i % len(MECENAT_ADDRESSES)]
volunteer.fee_payment_label = "Mécénat"
volunteer.fee_amount = rng.choice([30.0, 50.0, 80.0, 120.0])
volunteer.fee_payment_status = FeePaymentStatus.PAID
volunteer.fee_paid_at = dt.datetime.combine(
START - dt.timedelta(days=rng.randint(5, 45)), dt.time(rng.randint(9, 21))
)
volunteer.mecenat_address = address
volunteer.mecenat_postal_code = postal_code
volunteer.mecenat_city = city
volunteer.mecenat_country = "France"
session.add(volunteer)
return len(classique), len(mecenat)
def _find_rest_pair(shifts: list[Shift]) -> tuple[Shift, Shift] | None:
plain = sorted(shifts, key=lambda s: s.starts_at)
for i, first in enumerate(plain):