Section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer),
PUT/DELETE /editions/{id}/volunteers/{vid}/photo (COORDINATION),
validation de l'image partagée avec le formulaire public (read_photo).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1176 lines
44 KiB
Python
1176 lines
44 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 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 sqlalchemy import delete
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlmodel import Session, select
|
|
|
|
from app.api._common import (
|
|
ALLOWED_PHOTO_TYPES,
|
|
MAX_PHOTO_BYTES,
|
|
owned_or_404,
|
|
read_photo,
|
|
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"])
|
|
|
|
# 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()
|
|
|
|
|
|
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)
|
|
logger.info(
|
|
"Formulaire public : bénévole %s créé (édition %s, sans paiement en ligne)",
|
|
volunteer.id,
|
|
edition.id,
|
|
)
|
|
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)
|
|
logger.info(
|
|
"Formulaire public : inscription en attente de paiement %s (édition %s, "
|
|
"montant %.2f, mécénat=%s)",
|
|
pending.id,
|
|
edition.id,
|
|
pending.payment_amount,
|
|
pending.requires_mecenat_info,
|
|
)
|
|
|
|
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=_checkout_description(edition, pending),
|
|
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:
|
|
logger.warning(
|
|
"Checkout SumUp non créé pour l'inscription en attente %s (édition %s) — "
|
|
"le bénévole devra réessayer",
|
|
pending.id,
|
|
edition.id,
|
|
)
|
|
return None
|
|
pending.checkout_id = checkout.id
|
|
pending.checkout_status = FeePaymentStatus.PENDING
|
|
session.add(pending)
|
|
session.commit()
|
|
logger.info(
|
|
"Checkout SumUp %s créé pour l'inscription en attente %s (édition %s)",
|
|
checkout.id,
|
|
pending.id,
|
|
edition.id,
|
|
)
|
|
if not checkout.hosted_checkout_url:
|
|
logger.warning(
|
|
"Checkout SumUp %s sans hosted_checkout_url — le paiement hébergé "
|
|
"est-il activé sur le compte marchand ?",
|
|
checkout.id,
|
|
)
|
|
return checkout.hosted_checkout_url
|
|
|
|
|
|
# Longueur prudente : la doc SumUp ne fixe pas de maximum pour `description`.
|
|
_CHECKOUT_DESCRIPTION_MAX = 255
|
|
|
|
|
|
def _checkout_description(edition: Edition, pending: PendingFormSubmission) -> str:
|
|
"""Libellé de la transaction côté SumUp (tableau de bord, exports) : le
|
|
nom du bénévole en tête, pour qu'un paiement s'y rapproche d'un coup
|
|
d'œil de la bonne personne — l'API de création de checkout n'a pas de
|
|
champ de métadonnées libre (voir `app/sumup/client.py:create_checkout`).
|
|
Donnée transmise au prestataire de paiement, pas un log : la règle RGPD
|
|
de `app/logging_config.py` ne s'applique pas ici."""
|
|
name = " ".join(p for p in (pending.first_name, pending.last_name) if p)
|
|
parts = [name, pending.payment_label, f"édition {edition.year}"]
|
|
return " — ".join(p for p in parts if p)[:_CHECKOUT_DESCRIPTION_MAX]
|
|
|
|
|
|
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(),
|
|
fee_transaction_code=checkout.transaction_code,
|
|
fee_transaction_id=checkout.transaction_id,
|
|
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()
|
|
logger.info(
|
|
"Paiement confirmé : inscription en attente %s → bénévole %s (édition %s, "
|
|
"checkout %s, transaction %s, reçu mécénat %s)",
|
|
pending.id,
|
|
volunteer.id,
|
|
edition.id,
|
|
checkout.id,
|
|
checkout.transaction_code or "inconnue",
|
|
volunteer.mecenat_receipt_number or "-",
|
|
)
|
|
if not checkout.transaction_code:
|
|
logger.warning(
|
|
"Checkout SumUp %s payé sans code de transaction renvoyé — rapprochement "
|
|
"à faire depuis le tableau de bord SumUp",
|
|
checkout.id,
|
|
)
|
|
|
|
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 n° %s non généré — email envoyé sans pièce jointe",
|
|
receipt.number or "?",
|
|
)
|
|
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:
|
|
logger.warning(
|
|
"Checkout SumUp %s : statut inconnu %r ignoré", checkout.id, checkout.status
|
|
)
|
|
return
|
|
if new_status == FeePaymentStatus.PAID:
|
|
_promote_pending(session, edition, pending, checkout, background)
|
|
else:
|
|
if new_status != pending.checkout_status:
|
|
logger.info(
|
|
"Checkout SumUp %s (inscription en attente %s) : %s → %s",
|
|
checkout.id,
|
|
pending.id,
|
|
pending.checkout_status,
|
|
new_status,
|
|
)
|
|
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)
|
|
logger.info(
|
|
"Relance du paiement de l'inscription en attente %s (édition %s, statut %s)",
|
|
pending.id,
|
|
edition.id,
|
|
pending.checkout_status,
|
|
)
|
|
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."""
|
|
logger.info(
|
|
"Webhook SumUp reçu (édition %s, événement %s, checkout %s)",
|
|
edition.id,
|
|
payload.event_type,
|
|
payload.id,
|
|
)
|
|
template = _get_or_create_template(session, edition.id)
|
|
if not template.sumup_api_key:
|
|
logger.warning(
|
|
"Webhook SumUp ignoré : aucune clé API SumUp configurée (édition %s)",
|
|
edition.id,
|
|
)
|
|
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 %s impossible.", payload.id)
|
|
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()
|
|
if checkout.transaction_code and not volunteer.fee_transaction_code:
|
|
volunteer.fee_transaction_code = checkout.transaction_code
|
|
volunteer.fee_transaction_id = checkout.transaction_id
|
|
session.add(volunteer)
|
|
session.commit()
|
|
logger.info(
|
|
"Webhook SumUp : bénévole %s (checkout %s) → %s",
|
|
volunteer.id,
|
|
payload.id,
|
|
new_status,
|
|
)
|
|
return Response(status_code=status.HTTP_200_OK)
|
|
logger.warning(
|
|
"Webhook SumUp : checkout %s inconnu dans l'édition %s (inscription purgée ?)",
|
|
payload.id,
|
|
edition.id,
|
|
)
|
|
return Response(status_code=status.HTTP_200_OK)
|