Files
benevoles/backend/app/api/forms.py
T
AdamThomasandClaude Sonnet 5 a2b748566b
ci-cd / build-backend (push) Successful in 30s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s
Inscription en deux temps, emails de confirmation et export Excel des bénévoles
Formulaire public — paiement en ligne :
- Le bénévole n'est plus compté en base tant qu'un paiement en ligne choisi
  n'est pas confirmé (PendingFormSubmission, promu en Volunteer à la
  confirmation, webhook ou vérification en direct).
- Type de paiement « mécénat » : étape d'adresse postale avant le paiement,
  conservée sur le bénévole pour un reçu fiscal (cerfa) ultérieur.
- Page de confirmation au retour de SumUp, avec relance possible après
  échec/expiration sans ressaisir le formulaire.
- « Je paierai sur place » remplace « je ne participe pas » ; tous les
  champs activés par la coordination sont désormais obligatoires.
- La fiche bénévole n'affiche plus l'état des cases à cocher, seulement le
  résultat réel du paiement (libellé, montant, statut).

Emails :
- Email de confirmation d'inscription à la création du bénévole (tient lieu
  de reçu de paiement quand une cotisation est payée en ligne).
- Mise en forme HTML au thème de l'application (logo de l'édition en
  en-tête) pour tous les emails envoyés, texte brut conservé en repli.

Export Excel des bénévoles (COORDINATION) : coordonnées, vœux résolus en
noms lisibles, cotisation et mécénat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 07:41:58 +02:00

967 lines
36 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**) : 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).
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 json
import logging
import uuid
from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
File,
Form,
HTTPException,
Response,
UploadFile,
status,
)
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
from app.config import get_settings
from app.db import get_session
from app.email import (
edition_logo_url,
render_registration_confirmation,
render_registration_paid_confirmation,
send,
)
from app.forms import verify_hcaptcha
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
# 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),
)
@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))
# --------------------------------------------------------------------------- #
# 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,
)
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(
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> Response:
"""Public (aucune authentification) : affiché sur `/inscription`, comme
`GET .../form/public` — voir le commentaire du module."""
template = _get_or_create_template(session, edition.id)
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(
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> PublicFormConfig:
template = _get_or_create_template(session, edition.id)
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,
)
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."""
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"})
return (content, content_type) if content else None
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 _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),
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),
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)
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"}
)
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_url=edition_logo_url(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=payment_option.amount,
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?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()
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:
subject, body = render_registration_paid_confirmation(
first_name=volunteer.first_name,
label=volunteer.fee_payment_label or "",
amount=volunteer.fee_amount or 0,
)
background.add_task(
send,
volunteer.email,
subject,
body,
logo_url=edition_logo_url(session, edition.id),
)
return volunteer
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 = _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)