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