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
@@ -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()
|
||||
Reference in New Issue
Block a user