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
+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()