Files
benevoles/backend/app/api/forms.py
T
AdamThomasandClaude Opus 5.5 c49ce520e3
ci-cd / build-backend (push) Successful in 33s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s
Type de paiement à montant libre (pensé pour le mécénat)
FormPaymentOption.free_amount (migration 0021) : case « Montant libre »
sur l'écran Inscriptions ; le montant configuré devient le minimum et le
bénévole saisit le sien sur le formulaire public (payment_amount, virgule
ou point, 2 décimales max, plafond 10 000 € contre une faute de frappe —
422 form.invalid_free_amount sinon). Validé côté client avant l'envoi,
le serveur restant seul juge. Le montant choisi est celui du checkout
SumUp, recopié sur la fiche, l'email de confirmation et le cerfa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:49:27 +02:00

1100 lines
42 KiB
Python

"""Formulaire public d'inscription des bénévoles.
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**, 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
neuf autres (`FormFieldType`). Les vœux de postes/artistes réutilisent la
validation de `app/api/_common.py` (partagée avec `volunteers.py`), avec
deux règles propres au formulaire : au plus `_MAX_POSITION_WISHES` postes,
et au plus un artiste souhaité par jour de festival (`performance_date`,
voir `_check_one_artist_wish_per_day`). Le vœu de compagnon est un texte
libre (`companion_wish_note`) résolu ensuite manuellement par la
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 decimal import Decimal, InvalidOperation
from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
File,
Form,
HTTPException,
Response,
UploadFile,
status,
)
from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
from app.api._common import (
owned_or_404,
resolve_owned_ids,
set_artists,
set_wished_positions,
)
from app.api.deps import get_edition
from app.api.schemas import (
FormFieldsSet,
FormPaymentOptionCreate,
FormPaymentOptionRead,
FormPaymentOptionUpdate,
FormSubmissionResult,
FormTemplateRead,
FormTemplateUpdate,
MecenatInfoSubmit,
PendingSubmissionStatus,
PublicArtistOption,
PublicFormConfig,
PublicPaymentOption,
PublicPositionOption,
SumUpWebhookPayload,
)
from app.auth.deps import require_coordinator
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,
render_registration_confirmation,
render_registration_paid_confirmation,
send,
)
from app.forms import verify_hcaptcha
from app.mecenat import (
RECEIPT_FILENAME,
MecenatReceipt,
assign_receipt_number,
generate_mecenat_receipts_pdf,
receipt_for,
)
from app.models import (
Account,
Artist,
Edition,
FeePaymentStatus,
FormField,
FormFieldType,
FormPaymentOption,
FormTemplate,
PendingFormSubmission,
Position,
Volunteer,
local_now,
)
from app.sumup import SumUpCheckout, create_checkout, get_checkout
logger = logging.getLogger("app.forms")
router = APIRouter(prefix="/editions/{edition_id}/form", tags=["form"])
_MAX_PHOTO_BYTES = 8 * 1024 * 1024
_ALLOWED_PHOTO_TYPES = {"image/png", "image/jpeg"}
# Vœux de poste : au plus deux, pour éviter que tout le monde coche tous les
# postes « au cas où » — force une vraie priorisation dès le formulaire.
_MAX_POSITION_WISHES = 2
# Plafond d'un montant libre (`FormPaymentOption.free_amount`) : garde-fou
# contre une faute de frappe (un zéro de trop) avant d'ouvrir le paiement.
_MAX_FREE_AMOUNT = Decimal("10000")
# Durée de vie d'une inscription en attente de paiement jamais menée à terme
# (ni payée, ni relancée) — purgée au-delà (voir `_prune_stale_pending`),
# pour ne pas garder indéfiniment des coordonnées de bénévoles qui n'ont
# jamais payé.
_PENDING_STALE_HOURS = 48
# --------------------------------------------------------------------------- #
# Configuration (COORDINATION) #
# --------------------------------------------------------------------------- #
def _get_or_create_template(session: Session, edition_id: uuid.UUID) -> FormTemplate:
template = session.exec(
select(FormTemplate).where(FormTemplate.edition_id == edition_id)
).first()
if template is None:
template = FormTemplate(edition_id=edition_id)
session.add(template)
session.commit()
session.refresh(template)
return template
def _enabled_fields(session: Session, edition_id: uuid.UUID) -> list[FormFieldType]:
return list(
session.exec(
select(FormField.field_type)
.where(FormField.edition_id == edition_id)
.order_by(FormField.field_type)
)
)
def _payment_options(session: Session, edition_id: uuid.UUID) -> list[FormPaymentOption]:
return list(
session.exec(
select(FormPaymentOption)
.where(FormPaymentOption.edition_id == edition_id)
.order_by(FormPaymentOption.order, FormPaymentOption.label)
)
)
def _sumup_enabled(template: FormTemplate, options: list[FormPaymentOption]) -> bool:
return bool(template.sumup_merchant_code and template.sumup_api_key and options)
def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormTemplateRead:
return FormTemplateRead(
edition_id=template.edition_id,
is_open=template.is_open,
fields=fields,
has_logo=template.logo is not None,
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),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> FormTemplateRead:
template = _get_or_create_template(session, edition.id)
return _template_read(template, _enabled_fields(session, edition.id))
@router.put("/fields", response_model=FormTemplateRead)
def set_fields(
payload: FormFieldsSet,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> FormTemplateRead:
template = _get_or_create_template(session, edition.id)
session.execute(delete(FormField).where(FormField.edition_id == edition.id))
for field_type in dict.fromkeys(payload.fields): # dé-doublonne, garde l'ordre
session.add(FormField(edition_id=edition.id, field_type=field_type))
session.commit()
return _template_read(template, _enabled_fields(session, edition.id))
@router.patch("", response_model=FormTemplateRead)
def update_form(
payload: FormTemplateUpdate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> FormTemplateRead:
template = _get_or_create_template(session, edition.id)
changes = payload.model_dump(exclude_unset=True)
if changes.get("sumup_api_key") == "":
changes["sumup_api_key"] = None
for field, value in changes.items():
setattr(template, field, value)
session.add(template)
session.commit()
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` #
# --------------------------------------------------------------------------- #
@router.get("/payment-options", response_model=list[FormPaymentOptionRead])
def list_payment_options(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> list[FormPaymentOption]:
return _payment_options(session, edition.id)
@router.post(
"/payment-options",
response_model=FormPaymentOptionRead,
status_code=status.HTTP_201_CREATED,
)
def create_payment_option(
payload: FormPaymentOptionCreate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> FormPaymentOption:
if payload.amount <= 0:
raise HTTPException(
status_code=422, detail={"code": "form.invalid_sumup_amount"}
)
option = FormPaymentOption(
edition_id=edition.id,
label=payload.label.strip(),
amount=payload.amount,
order=payload.order,
requires_mecenat_info=payload.requires_mecenat_info,
free_amount=payload.free_amount,
)
session.add(option)
try:
session.commit()
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=409, detail={"code": "form.duplicate_payment_option_label"}
) from exc
session.refresh(option)
return option
@router.patch("/payment-options/{option_id}", response_model=FormPaymentOptionRead)
def update_payment_option(
option_id: uuid.UUID,
payload: FormPaymentOptionUpdate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> FormPaymentOption:
option = owned_or_404(session.get(FormPaymentOption, option_id), edition.id)
changes = payload.model_dump(exclude_unset=True)
if changes.get("amount") is not None and changes["amount"] <= 0:
raise HTTPException(
status_code=422, detail={"code": "form.invalid_sumup_amount"}
)
if changes.get("label") is not None:
changes["label"] = changes["label"].strip()
for field, value in changes.items():
setattr(option, field, value)
session.add(option)
try:
session.commit()
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=409, detail={"code": "form.duplicate_payment_option_label"}
) from exc
session.refresh(option)
return option
@router.delete("/payment-options/{option_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_payment_option(
option_id: uuid.UUID,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
option = owned_or_404(session.get(FormPaymentOption, option_id), edition.id)
# Les bénévoles ayant déjà choisi cette option gardent leur copie figée
# (`Volunteer.fee_payment_label`/`fee_amount`) — rien à faire côté eux.
session.delete(option)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
async def _read_logo(file: UploadFile) -> tuple[bytes, str]:
content_type = file.content_type or ""
if content_type not in _ALLOWED_PHOTO_TYPES:
raise HTTPException(
status_code=422, detail={"code": "form.logo_type_not_supported"}
)
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail={"code": "form.logo_empty"})
if len(content) > _MAX_PHOTO_BYTES:
raise HTTPException(status_code=413, detail={"code": "form.logo_too_large"})
return content, content_type
@router.put("/logo", response_model=FormTemplateRead)
async def set_logo(
file: UploadFile = File(...),
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> FormTemplateRead:
template = _get_or_create_template(session, edition.id)
content, content_type = await _read_logo(file)
template.logo = content
template.logo_content_type = content_type
session.add(template)
session.commit()
return _template_read(template, _enabled_fields(session, edition.id))
@router.get("/logo")
def get_logo(
token: str,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> Response:
"""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(
content=template.logo,
media_type=template.logo_content_type or "application/octet-stream",
)
# --------------------------------------------------------------------------- #
# Formulaire public (aucune authentification) #
# --------------------------------------------------------------------------- #
@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] = []
if FormFieldType.POSITION_WISHES in fields:
positions = [
PublicPositionOption(id=p.id, name=p.name)
for p in session.exec(
select(Position)
.where(Position.edition_id == edition.id)
.order_by(Position.name)
)
]
artists: list[PublicArtistOption] = []
if FormFieldType.ARTIST_WISHES in fields:
artists = [
PublicArtistOption(
id=a.id, name=a.name, performance_date=a.performance_date
)
for a in session.exec(
select(Artist)
.where(Artist.edition_id == edition.id)
.order_by(Artist.performance_date, Artist.name)
)
]
options = _payment_options(session, edition.id)
return PublicFormConfig(
is_open=template.is_open,
fields=fields,
positions=positions,
artists=artists,
has_logo=template.logo is not None,
description=template.description,
sumup_enabled=_sumup_enabled(template, options),
payment_options=[
PublicPaymentOption(
id=o.id,
label=o.label,
amount=o.amount,
requires_mecenat_info=o.requires_mecenat_info,
free_amount=o.free_amount,
)
for o in options
],
)
def _prune_stale_pending(session: Session, edition_id: uuid.UUID) -> None:
"""Nettoyage paresseux (même principe que `app/auth/rate_limit.py`) :
supprime les inscriptions en attente jamais payées et jamais relancées
depuis `_PENDING_STALE_HOURS` — appelé à chaque soumission/relance, pas
de tâche planifiée dans cette appli."""
cutoff = local_now() - dt.timedelta(hours=_PENDING_STALE_HOURS)
session.execute(
delete(PendingFormSubmission).where(
PendingFormSubmission.edition_id == edition_id,
PendingFormSubmission.promoted_volunteer_id.is_(None),
PendingFormSubmission.created_at < cutoff,
)
)
session.commit()
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. `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 ""
if content_type not in _ALLOWED_PHOTO_TYPES:
raise HTTPException(
status_code=422, detail={"code": "form.photo_type_not_supported"}
)
content = await photo.read()
if len(content) > _MAX_PHOTO_BYTES:
raise HTTPException(status_code=413, detail={"code": "form.photo_too_large"})
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(
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID]
) -> list[uuid.UUID]:
"""Comme `resolve_owned_ids` (`app/api/_common.py`) mais silencieux : ne
garde que les ids toujours valides pour cette édition, sans jamais lever.
Utilisé à la promotion d'une inscription en attente — le paiement a déjà
eu lieu, un poste/artiste supprimé entretemps ne doit jamais faire
échouer la création du bénévole (voir `_promote_pending`)."""
wanted = list(dict.fromkeys(ids))
if not wanted:
return []
found = set(
session.exec(
select(model.id).where(
model.edition_id == edition_id, model.id.in_(wanted)
)
)
)
return [i for i in wanted if i in found]
def _get_pending_or_404(
session: Session, edition_id: uuid.UUID, token: uuid.UUID
) -> PendingFormSubmission:
pending = session.get(PendingFormSubmission, token)
if pending is None or pending.edition_id != edition_id:
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
return pending
def _parse_id_list(raw: str | None) -> list[uuid.UUID]:
"""`position_wish_ids`/`artist_wish_ids` arrivent en JSON dans un champ
`multipart/form-data` (impossible d'y envoyer un tableau natif)."""
if not raw:
return []
try:
values = json.loads(raw)
if not isinstance(values, list):
raise ValueError
return [uuid.UUID(str(v)) for v in values]
except (ValueError, TypeError) as exc:
raise HTTPException(
status_code=422, detail={"code": "form.invalid_payload"}
) from exc
def _free_amount(raw: str | None, *, minimum: float) -> float:
"""Montant saisi par le bénévole pour un type de paiement à montant
libre : virgule ou point décimal, au plus 2 décimales, entre `minimum`
(le montant configuré sur l'option) et `_MAX_FREE_AMOUNT` inclus."""
error = HTTPException(
status_code=422,
detail={
"code": "form.invalid_free_amount",
"params": {"min": float(minimum), "max": float(_MAX_FREE_AMOUNT)},
},
)
try:
value = Decimal((raw or "").strip().replace(",", "."))
except InvalidOperation as exc:
raise error from exc
if (
not value.is_finite()
or value.as_tuple().exponent < -2
or value < Decimal(str(minimum))
or value > _MAX_FREE_AMOUNT
):
raise error
return float(value)
def _check_one_artist_wish_per_day(
session: Session, edition_id: uuid.UUID, artist_ids: list[uuid.UUID]
) -> None:
"""Un seul vœu d'artiste par soir de festival — `set_artists` (validation
d'appartenance à l'édition) s'occupe du reste."""
dates = list(
session.exec(
select(Artist.performance_date).where(
Artist.edition_id == edition_id, Artist.id.in_(artist_ids)
)
)
)
if len(dates) != len(set(dates)):
raise HTTPException(
status_code=422, detail={"code": "form.too_many_artists_same_day"}
)
@router.post(
"/public/submit",
response_model=FormSubmissionResult,
status_code=status.HTTP_201_CREATED,
)
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(...),
email: str | None = Form(None),
phone: str | None = Form(None),
comment: str | None = Form(None),
companion_wish_note: str | None = Form(None),
available: bool = Form(False),
payment_option_id: str | None = Form(None),
payment_amount: str | None = Form(None),
position_wish_ids: str | None = Form(None),
artist_wish_ids: str | None = Form(None),
photo: UploadFile | None = File(None),
) -> FormSubmissionResult:
# Filet de sécurité global (par édition) : hCaptcha est la défense
# principale, voir `app/auth/rate_limit.py`.
form_submission_limiter.hit(str(edition.id))
_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"})
if not verify_hcaptcha(hcaptcha_token):
raise HTTPException(status_code=422, detail={"code": "form.captcha_failed"})
if not first_name.strip() or not last_name.strip():
raise HTTPException(
status_code=422,
detail={
"code": "import.row.missing_required_field",
"params": {"field": "first_name"},
},
)
fields = set(_enabled_fields(session, edition.id))
# Tout champ activé par la coordination est obligatoire — le formulaire
# public n'est plus une simple collecte d'infos facultatives.
email = (email or "").strip() or None
phone = (phone or "").strip() or None
comment = (comment or "").strip() or None
companion_wish_note = (companion_wish_note or "").strip() or None
for active, field_name, ok in (
(FormFieldType.EMAIL in fields, "email", bool(email)),
(FormFieldType.PHONE in fields, "phone", bool(phone)),
(FormFieldType.AVAILABLE in fields, "available", available),
):
if active and not ok:
raise HTTPException(
status_code=422,
detail={
"code": "import.row.missing_required_field",
"params": {"field": field_name},
},
)
position_ids = (
_parse_id_list(position_wish_ids) if FormFieldType.POSITION_WISHES in fields else []
)
if len(position_ids) > _MAX_POSITION_WISHES:
raise HTTPException(
status_code=422, detail={"code": "form.too_many_position_wishes"}
)
if position_ids:
resolve_owned_ids(session, Position, edition.id, position_ids, "position.unknown")
artist_ids = (
_parse_id_list(artist_wish_ids) if FormFieldType.ARTIST_WISHES in fields else []
)
if artist_ids:
_check_one_artist_wish_per_day(session, edition.id, artist_ids)
resolve_owned_ids(session, Artist, edition.id, artist_ids, "artist.unknown")
payment_option: FormPaymentOption | None = None
if FormFieldType.WILLING_TO_PAY_FEE in fields and payment_option_id:
try:
option_uuid = uuid.UUID(payment_option_id)
except ValueError:
option_uuid = None
payment_option = (
session.get(FormPaymentOption, option_uuid) if option_uuid else None
)
if payment_option is None or payment_option.edition_id != edition.id:
raise HTTPException(
status_code=422, detail={"code": "form.unknown_payment_option"}
)
amount_to_pay = (
_free_amount(payment_amount, minimum=payment_option.amount)
if payment_option is not None and payment_option.free_amount
else payment_option.amount if payment_option is not None else None
)
photo_data = await _read_photo(photo) if FormFieldType.PHOTO in fields else None
if payment_option is None:
# Pas de paiement en ligne (case désactivée, ou « je paierai sur
# place ») : rien à attendre, le bénévole est créé tout de suite.
volunteer = Volunteer(
edition_id=edition.id,
first_name=first_name.strip().title(),
last_name=last_name.strip().title(),
email=email if FormFieldType.EMAIL in fields else None,
phone=phone if FormFieldType.PHONE in fields else None,
comment=comment if FormFieldType.COMMENT in fields else None,
companion_wish_note=(
companion_wish_note if FormFieldType.COMPANION_WISH in fields else None
),
self_declared_available=(
available if FormFieldType.AVAILABLE in fields else False
),
willing_to_pay_fee=FormFieldType.WILLING_TO_PAY_FEE in fields,
)
if photo_data:
volunteer.photo, volunteer.photo_content_type = photo_data
session.add(volunteer)
session.flush()
if FormFieldType.POSITION_WISHES in fields:
set_wished_positions(session, volunteer, position_ids)
if FormFieldType.ARTIST_WISHES in fields:
set_artists(session, volunteer, artist_ids)
session.commit()
session.refresh(volunteer)
if volunteer.email:
subject, body = render_registration_confirmation(first_name=volunteer.first_name)
background.add_task(
send,
volunteer.email,
subject,
body,
logo=edition_logo(session, edition.id),
)
return FormSubmissionResult(volunteer_id=volunteer.id)
# Paiement en ligne choisi : rien n'est compté en base tant que le
# paiement n'est pas confirmé — voir `PendingFormSubmission` et
# `_promote_pending`.
pending = PendingFormSubmission(
edition_id=edition.id,
first_name=first_name.strip().title(),
last_name=last_name.strip().title(),
email=email if FormFieldType.EMAIL in fields else None,
phone=phone if FormFieldType.PHONE in fields else None,
comment=comment if FormFieldType.COMMENT in fields else None,
companion_wish_note=(
companion_wish_note if FormFieldType.COMPANION_WISH in fields else None
),
available=available if FormFieldType.AVAILABLE in fields else False,
position_wish_ids_json=(
json.dumps([str(i) for i in position_ids]) if position_ids else None
),
artist_wish_ids_json=(
json.dumps([str(i) for i in artist_ids]) if artist_ids else None
),
payment_label=payment_option.label,
payment_amount=amount_to_pay,
requires_mecenat_info=payment_option.requires_mecenat_info,
)
if photo_data:
pending.photo, pending.photo_content_type = photo_data
session.add(pending)
session.commit()
session.refresh(pending)
if pending.requires_mecenat_info:
return FormSubmissionResult(pending_token=pending.id, requires_mecenat_info=True)
checkout_url = _create_pending_checkout(session, edition, template, pending)
return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url)
def _create_pending_checkout(
session: Session, edition: Edition, template: FormTemplate, pending: PendingFormSubmission
) -> str | None:
"""Crée (ou recrée, voir `retry_pending_payment`) le checkout SumUp d'une
inscription en attente — best effort (comme l'envoi d'emails) : un échec
de l'appel API laisse l'inscription en attente, jamais perdue (la page de
confirmation propose alors de réessayer). `checkout_reference` porte un
suffixe aléatoire à chaque appel : une relance après échec/expiration doit
ouvrir un nouveau checkout SumUp, jamais réutiliser une référence déjà
consommée (409 côté SumUp sinon)."""
base = get_settings().public_base_url.rstrip("/")
checkout = create_checkout(
api_key=template.sumup_api_key,
merchant_code=template.sumup_merchant_code,
amount=pending.payment_amount,
currency="EUR",
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/{template.access_token}?paiement={pending.id}",
)
if checkout is None:
return None
pending.checkout_id = checkout.id
pending.checkout_status = FeePaymentStatus.PENDING
session.add(pending)
session.commit()
return checkout.hosted_checkout_url
def _promote_pending(
session: Session,
edition: Edition,
pending: PendingFormSubmission,
checkout: SumUpCheckout,
background: BackgroundTasks,
) -> Volunteer:
"""Paiement confirmé : crée enfin le `Volunteer` (voir le docstring de
`PendingFormSubmission`), résout les vœux en filtrant silencieusement les
ids devenus invalides (`_filter_owned_ids` — le paiement a déjà eu lieu,
rien ne doit plus faire échouer la création), puis vide les champs
nominatifs de la ligne en attente (RGPD : cette information ne doit plus
vivre qu'à un seul endroit)."""
volunteer = Volunteer(
edition_id=edition.id,
first_name=pending.first_name or "",
last_name=pending.last_name or "",
email=pending.email,
phone=pending.phone,
comment=pending.comment,
companion_wish_note=pending.companion_wish_note,
self_declared_available=pending.available,
willing_to_pay_fee=True,
fee_payment_label=pending.payment_label,
fee_amount=pending.payment_amount,
fee_checkout_id=checkout.id,
fee_payment_status=FeePaymentStatus.PAID,
fee_paid_at=local_now(),
mecenat_address=pending.mecenat_address,
mecenat_city=pending.mecenat_city,
mecenat_postal_code=pending.mecenat_postal_code,
mecenat_country=pending.mecenat_country,
photo=pending.photo,
photo_content_type=pending.photo_content_type,
)
session.add(volunteer)
session.flush()
if volunteer.mecenat_address:
assign_receipt_number(session, volunteer)
position_ids = _filter_owned_ids(
session, Position, edition.id, _parse_id_list(pending.position_wish_ids_json)
)
if position_ids:
set_wished_positions(session, volunteer, position_ids)
artist_ids = _filter_owned_ids(
session, Artist, edition.id, _parse_id_list(pending.artist_wish_ids_json)
)
if artist_ids:
set_artists(session, volunteer, artist_ids)
session.commit()
session.refresh(volunteer)
pending.promoted_volunteer_id = volunteer.id
pending.checkout_status = FeePaymentStatus.PAID
pending.first_name = None
pending.last_name = None
pending.email = None
pending.phone = None
pending.comment = None
pending.companion_wish_note = None
pending.photo = None
pending.photo_content_type = None
pending.mecenat_address = None
pending.mecenat_city = None
pending.mecenat_postal_code = None
pending.mecenat_country = None
session.add(pending)
session.commit()
if volunteer.email:
# Paiement « mécénat » (adresse postale saisie) : le cerfa rempli part
# en pièce jointe du même email — voir `_send_paid_confirmation`.
receipt = receipt_for(volunteer) if volunteer.mecenat_address else None
subject, body = render_registration_paid_confirmation(
first_name=volunteer.first_name,
label=volunteer.fee_payment_label or "",
amount=volunteer.fee_amount or 0,
with_mecenat_receipt=receipt is not None,
)
background.add_task(
_send_paid_confirmation,
volunteer.email,
subject,
body,
logo=edition_logo(session, edition.id),
receipt=receipt,
)
return volunteer
def _send_paid_confirmation(
to: str,
subject: str,
body: str,
*,
logo: tuple[bytes, str] | None,
receipt: MecenatReceipt | None,
) -> None:
"""Tâche de fond : génère le cerfa (si mécénat) puis envoie. Best effort
comme le reste des emails — un échec de génération du PDF ne doit pas
priver le bénévole de sa confirmation, qui part alors sans pièce jointe
(la coordination garde l'export des reçus pour le lui renvoyer)."""
attachments = []
if receipt is not None:
try:
pdf = generate_mecenat_receipts_pdf([receipt])
attachments.append((pdf, "application/pdf", RECEIPT_FILENAME))
except Exception:
logger.exception("Reçu fiscal mécénat non généré — email envoyé sans pièce jointe")
send(to, subject, body, logo=logo, attachments=attachments)
def _apply_pending_checkout_result(
session: Session,
edition: Edition,
pending: PendingFormSubmission,
checkout: SumUpCheckout,
background: BackgroundTasks,
) -> None:
"""Met à jour une inscription en attente à partir d'un état de checkout
fraîchement relu chez SumUp — partagé par le webhook et la vérification
en direct (`get_pending_status`)."""
if pending.promoted_volunteer_id is not None:
return
try:
new_status = FeePaymentStatus(checkout.status)
except ValueError:
return
if new_status == FeePaymentStatus.PAID:
_promote_pending(session, edition, pending, checkout, background)
else:
pending.checkout_status = new_status
session.add(pending)
session.commit()
@router.post(
"/public/pending/{token}/mecenat-info",
response_model=FormSubmissionResult,
)
def submit_mecenat_info(
token: uuid.UUID,
payload: MecenatInfoSubmit,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> FormSubmissionResult:
"""Étape d'adresse postale d'un type de paiement « mécénat », entre la
soumission du formulaire principal et le paiement lui-même — crée le
checkout SumUp une fois ces coordonnées enregistrées."""
form_submission_limiter.hit(str(edition.id))
pending = _get_pending_or_404(session, edition.id, token)
if pending.promoted_volunteer_id is not None:
raise HTTPException(
status_code=422, detail={"code": "form.pending_already_paid"}
)
if not pending.requires_mecenat_info:
raise HTTPException(
status_code=422, detail={"code": "form.pending_not_awaiting_info"}
)
address = payload.address.strip()
city = payload.city.strip()
postal_code = payload.postal_code.strip()
country = payload.country.strip()
if not (address and city and postal_code and country):
raise HTTPException(
status_code=422,
detail={
"code": "import.row.missing_required_field",
"params": {"field": "address"},
},
)
pending.mecenat_address = address
pending.mecenat_city = city
pending.mecenat_postal_code = postal_code
pending.mecenat_country = country
session.add(pending)
session.commit()
template = _get_or_create_template(session, edition.id)
checkout_url = _create_pending_checkout(session, edition, template, pending)
return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url)
@router.get("/public/pending/{token}", response_model=PendingSubmissionStatus)
def get_pending_status(
token: uuid.UUID,
background: BackgroundTasks,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> PendingSubmissionStatus:
"""Interrogé en direct par la page de confirmation (voir
`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)
if template.sumup_api_key:
checkout = get_checkout(
api_key=template.sumup_api_key, checkout_id=pending.checkout_id
)
if checkout is not None:
_apply_pending_checkout_result(session, edition, pending, checkout, background)
session.refresh(pending)
if pending.promoted_volunteer_id is not None:
return PendingSubmissionStatus(
status=FeePaymentStatus.PAID,
label=pending.payment_label,
amount=pending.payment_amount,
volunteer_id=pending.promoted_volunteer_id,
)
return PendingSubmissionStatus(
status=pending.checkout_status or FeePaymentStatus.PENDING,
label=pending.payment_label,
amount=pending.payment_amount,
)
@router.post(
"/public/pending/{token}/retry-payment",
response_model=FormSubmissionResult,
)
def retry_pending_payment(
token: uuid.UUID,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> FormSubmissionResult:
"""Relance un paiement après échec/expiration, sans faire ressaisir le
formulaire — un nouveau checkout SumUp est créé pour la même inscription
en attente (voir `_create_pending_checkout`)."""
form_submission_limiter.hit(str(edition.id))
pending = _get_pending_or_404(session, edition.id, token)
if pending.promoted_volunteer_id is not None:
raise HTTPException(
status_code=422, detail={"code": "form.pending_already_paid"}
)
if pending.requires_mecenat_info and pending.mecenat_address is None:
raise HTTPException(
status_code=422, detail={"code": "form.pending_not_awaiting_info"}
)
template = _get_or_create_template(session, edition.id)
checkout_url = _create_pending_checkout(session, edition, template, pending)
return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url)
@router.post("/public/sumup-webhook", status_code=status.HTTP_200_OK)
def sumup_webhook(
payload: SumUpWebhookPayload,
background: BackgroundTasks,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> Response:
"""Appelé par SumUp (aucune authentification possible côté SumUp) à
chaque changement de statut d'un checkout. Le payload ne donne qu'un
identifiant : jamais fait confiance sans relire l'état réel via l'API
(voir `app/sumup/client.py:get_checkout`). Répond toujours `200`, y
compris sur un événement inconnu — SumUp réessaie sinon inutilement."""
template = _get_or_create_template(session, edition.id)
if not template.sumup_api_key:
return Response(status_code=status.HTTP_200_OK)
checkout = get_checkout(api_key=template.sumup_api_key, checkout_id=payload.id)
if checkout is None:
logger.warning("Webhook SumUp : relecture du checkout impossible.")
return Response(status_code=status.HTTP_200_OK)
pending = session.exec(
select(PendingFormSubmission).where(
PendingFormSubmission.edition_id == edition.id,
PendingFormSubmission.checkout_id == payload.id,
)
).first()
if pending is not None:
_apply_pending_checkout_result(session, edition, pending, checkout, background)
return Response(status_code=status.HTTP_200_OK)
# Filet de sécurité pour d'anciennes inscriptions créées avant le passage
# à l'inscription en deux temps (voir CLAUDE.md § 2, Paiement de la
# cotisation) : un `Volunteer` directement lié à un checkout.
volunteer = session.exec(
select(Volunteer).where(
Volunteer.edition_id == edition.id, Volunteer.fee_checkout_id == payload.id
)
).first()
if volunteer is not None:
try:
new_status = FeePaymentStatus(checkout.status)
except ValueError:
return Response(status_code=status.HTTP_200_OK)
volunteer.fee_payment_status = new_status
if new_status == FeePaymentStatus.PAID and volunteer.fee_paid_at is None:
volunteer.fee_paid_at = local_now()
session.add(volunteer)
session.commit()
return Response(status_code=status.HTTP_200_OK)