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>
1100 lines
42 KiB
Python
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)
|