Files
benevoles/backend/app/api/forms.py
T
AdamThomasandClaude Sonnet 5 0bd38a6465
ci-cd / build-backend (push) Failing after 4m12s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped
Formulaire public : paiement de la cotisation via l'API SumUp (remplace Yapla)
Remplace le simple lien Yapla statique par une vraie intégration API SumUp :
checkout hébergé créé à la soumission du formulaire, statut de paiement
(PENDING/PAID/FAILED/EXPIRED) tenu à jour par un webhook SumUp et visible sur
la fiche du bénévole. La cotisation n'est plus un montant unique : la
coordination configure autant de types de paiement qu'elle veut (libellé +
montant, ex. « Cotisation classique », « Mécénat »), et le bénévole en
choisit un à l'inscription — le libellé et le montant choisis sont figés sur
le bénévole, indépendamment d'une modification ultérieure de la config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 19:56:04 +02:00

612 lines
22 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 json
import logging
import uuid
from fastapi import (
APIRouter,
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, set_artists, set_wished_positions
from app.api.deps import get_edition
from app.api.schemas import (
FormFieldsSet,
FormPaymentOptionCreate,
FormPaymentOptionRead,
FormPaymentOptionUpdate,
FormSubmissionResult,
FormTemplateRead,
FormTemplateUpdate,
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.forms import verify_hcaptcha
from app.models import (
Account,
Artist,
Edition,
FeePaymentStatus,
FormField,
FormFieldType,
FormPaymentOption,
FormTemplate,
Position,
Volunteer,
local_now,
)
from app.sumup import 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
# --------------------------------------------------------------------------- #
# 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,
)
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) for o in options
],
)
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(
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),
willing_to_pay_fee: 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))
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))
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"}
)
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)
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"}
)
volunteer = Volunteer(
edition_id=edition.id,
first_name=first_name.strip().title(),
last_name=last_name.strip().title(),
email=((email or "").strip() or None) if FormFieldType.EMAIL in fields else None,
phone=((phone or "").strip() or None) if FormFieldType.PHONE in fields else None,
comment=(
(comment or "").strip() or None if FormFieldType.COMMENT in fields else None
),
companion_wish_note=(
(companion_wish_note or "").strip() or None
if FormFieldType.COMPANION_WISH in fields
else None
),
self_declared_available=(
available if FormFieldType.AVAILABLE in fields else False
),
# Choisir un type de paiement vaut « je participe » même sans cocher
# la case (le formulaire public n'affiche d'ailleurs plus qu'un choix
# unique dès qu'au moins un type est configuré, voir PublicSignupPage).
willing_to_pay_fee=(
(willing_to_pay_fee or payment_option is not None)
if FormFieldType.WILLING_TO_PAY_FEE in fields
else False
),
)
if FormFieldType.PHOTO in fields and photo is not None and photo.filename:
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 content:
volunteer.photo = content
volunteer.photo_content_type = content_type
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)
checkout_url = None
if (
payment_option is not None
and template.sumup_merchant_code
and template.sumup_api_key
):
checkout_url = _create_fee_checkout(
session, edition, template, volunteer, payment_option
)
return FormSubmissionResult(volunteer_id=volunteer.id, sumup_checkout_url=checkout_url)
def _create_fee_checkout(
session: Session,
edition: Edition,
template: FormTemplate,
volunteer: Volunteer,
option: FormPaymentOption,
) -> str | None:
"""Crée le checkout SumUp du type de paiement choisi — best effort (comme
l'envoi d'emails) : un échec de l'appel API ne remet jamais en cause
l'inscription déjà enregistrée. `option.label`/`option.amount` sont
recopiés sur le bénévole (`fee_payment_label`/`fee_amount`) avant même
l'appel, pour garder une trace du choix même si l'appel échoue."""
volunteer.fee_payment_label = option.label
volunteer.fee_amount = option.amount
base = get_settings().public_base_url.rstrip("/")
checkout = create_checkout(
api_key=template.sumup_api_key,
merchant_code=template.sumup_merchant_code,
amount=option.amount,
currency="EUR",
checkout_reference=str(volunteer.id),
description=f"{option.label} — édition {edition.year}",
return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook",
redirect_url=f"{base}/inscription?paiement=ok",
)
if checkout is not None:
volunteer.fee_checkout_id = checkout.id
volunteer.fee_payment_status = FeePaymentStatus.PENDING
session.add(volunteer)
session.commit()
return checkout.hosted_checkout_url if checkout is not None else None
@router.post("/public/sumup-webhook", status_code=status.HTTP_200_OK)
def sumup_webhook(
payload: SumUpWebhookPayload,
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 template.sumup_api_key:
checkout = get_checkout(api_key=template.sumup_api_key, checkout_id=payload.id)
volunteer = (
session.exec(
select(Volunteer).where(
Volunteer.edition_id == edition.id,
Volunteer.fee_checkout_id == payload.id,
)
).first()
if checkout is not None
else None
)
if checkout is not None and volunteer is not None:
try:
new_status = FeePaymentStatus(checkout.status)
except ValueError:
new_status = None
if new_status is not None:
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()
elif checkout is None:
logger.warning("Webhook SumUp : relecture du checkout impossible.")
return Response(status_code=status.HTTP_200_OK)