Formulaire public — paiement en ligne : - Le bénévole n'est plus compté en base tant qu'un paiement en ligne choisi n'est pas confirmé (PendingFormSubmission, promu en Volunteer à la confirmation, webhook ou vérification en direct). - Type de paiement « mécénat » : étape d'adresse postale avant le paiement, conservée sur le bénévole pour un reçu fiscal (cerfa) ultérieur. - Page de confirmation au retour de SumUp, avec relance possible après échec/expiration sans ressaisir le formulaire. - « Je paierai sur place » remplace « je ne participe pas » ; tous les champs activés par la coordination sont désormais obligatoires. - La fiche bénévole n'affiche plus l'état des cases à cocher, seulement le résultat réel du paiement (libellé, montant, statut). Emails : - Email de confirmation d'inscription à la création du bénévole (tient lieu de reçu de paiement quand une cotisation est payée en ligne). - Mise en forme HTML au thème de l'application (logo de l'édition en en-tête) pour tous les emails envoyés, texte brut conservé en repli. Export Excel des bénévoles (COORDINATION) : coordonnées, vœux résolus en noms lisibles, cotisation et mécénat. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
967 lines
36 KiB
Python
967 lines
36 KiB
Python
"""Formulaire public d'inscription des bénévoles.
|
|
|
|
Deux familles d'endpoints sous le même routeur :
|
|
- configuration (COORDINATION) : quels champs sont activés, formulaire
|
|
ouvert ou fermé, identifiants SumUp de paiement de la cotisation ;
|
|
- publics (**aucune authentification**) : consultation de la configuration
|
|
et soumission (protégées par hCaptcha, `app/forms/captcha.py`, et un filet
|
|
de sécurité global, `app/auth/rate_limit.py:form_submission_limiter`), et
|
|
le webhook SumUp (`/public/sumup-webhook`, appelé par SumUp lui-même — pas
|
|
de hCaptcha possible serveur à serveur, voir `sumup_webhook` plus bas).
|
|
|
|
Nom et prénom sont toujours collectés (indispensables à la création du
|
|
bénévole) : ils ne font pas partie des champs activables, contrairement aux
|
|
neuf autres (`FormFieldType`). Les vœux de postes/artistes réutilisent la
|
|
validation de `app/api/_common.py` (partagée avec `volunteers.py`), avec
|
|
deux règles propres au formulaire : au plus `_MAX_POSITION_WISHES` postes,
|
|
et au plus un artiste souhaité par jour de festival (`performance_date`,
|
|
voir `_check_one_artist_wish_per_day`). Le vœu de compagnon est un texte
|
|
libre (`companion_wish_note`) résolu ensuite manuellement par la
|
|
coordination, faute de savoir si la personne visée est déjà inscrite (voir
|
|
CLAUDE.md § 2, `Volunteer`)."""
|
|
|
|
import datetime as dt
|
|
import json
|
|
import logging
|
|
import uuid
|
|
|
|
from fastapi import (
|
|
APIRouter,
|
|
BackgroundTasks,
|
|
Depends,
|
|
File,
|
|
Form,
|
|
HTTPException,
|
|
Response,
|
|
UploadFile,
|
|
status,
|
|
)
|
|
from sqlalchemy import delete
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlmodel import Session, select
|
|
|
|
from app.api._common import (
|
|
owned_or_404,
|
|
resolve_owned_ids,
|
|
set_artists,
|
|
set_wished_positions,
|
|
)
|
|
from app.api.deps import get_edition
|
|
from app.api.schemas import (
|
|
FormFieldsSet,
|
|
FormPaymentOptionCreate,
|
|
FormPaymentOptionRead,
|
|
FormPaymentOptionUpdate,
|
|
FormSubmissionResult,
|
|
FormTemplateRead,
|
|
FormTemplateUpdate,
|
|
MecenatInfoSubmit,
|
|
PendingSubmissionStatus,
|
|
PublicArtistOption,
|
|
PublicFormConfig,
|
|
PublicPaymentOption,
|
|
PublicPositionOption,
|
|
SumUpWebhookPayload,
|
|
)
|
|
from app.auth.deps import require_coordinator
|
|
from app.auth.rate_limit import form_submission_limiter
|
|
from app.config import get_settings
|
|
from app.db import get_session
|
|
from app.email import (
|
|
edition_logo_url,
|
|
render_registration_confirmation,
|
|
render_registration_paid_confirmation,
|
|
send,
|
|
)
|
|
from app.forms import verify_hcaptcha
|
|
from app.models import (
|
|
Account,
|
|
Artist,
|
|
Edition,
|
|
FeePaymentStatus,
|
|
FormField,
|
|
FormFieldType,
|
|
FormPaymentOption,
|
|
FormTemplate,
|
|
PendingFormSubmission,
|
|
Position,
|
|
Volunteer,
|
|
local_now,
|
|
)
|
|
from app.sumup import SumUpCheckout, create_checkout, get_checkout
|
|
|
|
logger = logging.getLogger("app.forms")
|
|
|
|
router = APIRouter(prefix="/editions/{edition_id}/form", tags=["form"])
|
|
|
|
_MAX_PHOTO_BYTES = 8 * 1024 * 1024
|
|
_ALLOWED_PHOTO_TYPES = {"image/png", "image/jpeg"}
|
|
# Vœux de poste : au plus deux, pour éviter que tout le monde coche tous les
|
|
# postes « au cas où » — force une vraie priorisation dès le formulaire.
|
|
_MAX_POSITION_WISHES = 2
|
|
# Durée de vie d'une inscription en attente de paiement jamais menée à terme
|
|
# (ni payée, ni relancée) — purgée au-delà (voir `_prune_stale_pending`),
|
|
# pour ne pas garder indéfiniment des coordonnées de bénévoles qui n'ont
|
|
# jamais payé.
|
|
_PENDING_STALE_HOURS = 48
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Configuration (COORDINATION) #
|
|
# --------------------------------------------------------------------------- #
|
|
def _get_or_create_template(session: Session, edition_id: uuid.UUID) -> FormTemplate:
|
|
template = session.exec(
|
|
select(FormTemplate).where(FormTemplate.edition_id == edition_id)
|
|
).first()
|
|
if template is None:
|
|
template = FormTemplate(edition_id=edition_id)
|
|
session.add(template)
|
|
session.commit()
|
|
session.refresh(template)
|
|
return template
|
|
|
|
|
|
def _enabled_fields(session: Session, edition_id: uuid.UUID) -> list[FormFieldType]:
|
|
return list(
|
|
session.exec(
|
|
select(FormField.field_type)
|
|
.where(FormField.edition_id == edition_id)
|
|
.order_by(FormField.field_type)
|
|
)
|
|
)
|
|
|
|
|
|
def _payment_options(session: Session, edition_id: uuid.UUID) -> list[FormPaymentOption]:
|
|
return list(
|
|
session.exec(
|
|
select(FormPaymentOption)
|
|
.where(FormPaymentOption.edition_id == edition_id)
|
|
.order_by(FormPaymentOption.order, FormPaymentOption.label)
|
|
)
|
|
)
|
|
|
|
|
|
def _sumup_enabled(template: FormTemplate, options: list[FormPaymentOption]) -> bool:
|
|
return bool(template.sumup_merchant_code and template.sumup_api_key and options)
|
|
|
|
|
|
def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormTemplateRead:
|
|
return FormTemplateRead(
|
|
edition_id=template.edition_id,
|
|
is_open=template.is_open,
|
|
fields=fields,
|
|
has_logo=template.logo is not None,
|
|
description=template.description,
|
|
sumup_merchant_code=template.sumup_merchant_code,
|
|
sumup_api_key_set=bool(template.sumup_api_key),
|
|
)
|
|
|
|
|
|
@router.get("", response_model=FormTemplateRead)
|
|
def get_form(
|
|
edition: Edition = Depends(get_edition),
|
|
_guard: Account = Depends(require_coordinator),
|
|
session: Session = Depends(get_session),
|
|
) -> FormTemplateRead:
|
|
template = _get_or_create_template(session, edition.id)
|
|
return _template_read(template, _enabled_fields(session, edition.id))
|
|
|
|
|
|
@router.put("/fields", response_model=FormTemplateRead)
|
|
def set_fields(
|
|
payload: FormFieldsSet,
|
|
edition: Edition = Depends(get_edition),
|
|
_guard: Account = Depends(require_coordinator),
|
|
session: Session = Depends(get_session),
|
|
) -> FormTemplateRead:
|
|
template = _get_or_create_template(session, edition.id)
|
|
session.execute(delete(FormField).where(FormField.edition_id == edition.id))
|
|
for field_type in dict.fromkeys(payload.fields): # dé-doublonne, garde l'ordre
|
|
session.add(FormField(edition_id=edition.id, field_type=field_type))
|
|
session.commit()
|
|
return _template_read(template, _enabled_fields(session, edition.id))
|
|
|
|
|
|
@router.patch("", response_model=FormTemplateRead)
|
|
def update_form(
|
|
payload: FormTemplateUpdate,
|
|
edition: Edition = Depends(get_edition),
|
|
_guard: Account = Depends(require_coordinator),
|
|
session: Session = Depends(get_session),
|
|
) -> FormTemplateRead:
|
|
template = _get_or_create_template(session, edition.id)
|
|
changes = payload.model_dump(exclude_unset=True)
|
|
if changes.get("sumup_api_key") == "":
|
|
changes["sumup_api_key"] = None
|
|
for field, value in changes.items():
|
|
setattr(template, field, value)
|
|
session.add(template)
|
|
session.commit()
|
|
return _template_read(template, _enabled_fields(session, edition.id))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Types de paiement (COORDINATION) — voir `FormPaymentOption` #
|
|
# --------------------------------------------------------------------------- #
|
|
@router.get("/payment-options", response_model=list[FormPaymentOptionRead])
|
|
def list_payment_options(
|
|
edition: Edition = Depends(get_edition),
|
|
_guard: Account = Depends(require_coordinator),
|
|
session: Session = Depends(get_session),
|
|
) -> list[FormPaymentOption]:
|
|
return _payment_options(session, edition.id)
|
|
|
|
|
|
@router.post(
|
|
"/payment-options",
|
|
response_model=FormPaymentOptionRead,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def create_payment_option(
|
|
payload: FormPaymentOptionCreate,
|
|
edition: Edition = Depends(get_edition),
|
|
_guard: Account = Depends(require_coordinator),
|
|
session: Session = Depends(get_session),
|
|
) -> FormPaymentOption:
|
|
if payload.amount <= 0:
|
|
raise HTTPException(
|
|
status_code=422, detail={"code": "form.invalid_sumup_amount"}
|
|
)
|
|
option = FormPaymentOption(
|
|
edition_id=edition.id,
|
|
label=payload.label.strip(),
|
|
amount=payload.amount,
|
|
order=payload.order,
|
|
requires_mecenat_info=payload.requires_mecenat_info,
|
|
)
|
|
session.add(option)
|
|
try:
|
|
session.commit()
|
|
except IntegrityError as exc:
|
|
session.rollback()
|
|
raise HTTPException(
|
|
status_code=409, detail={"code": "form.duplicate_payment_option_label"}
|
|
) from exc
|
|
session.refresh(option)
|
|
return option
|
|
|
|
|
|
@router.patch("/payment-options/{option_id}", response_model=FormPaymentOptionRead)
|
|
def update_payment_option(
|
|
option_id: uuid.UUID,
|
|
payload: FormPaymentOptionUpdate,
|
|
edition: Edition = Depends(get_edition),
|
|
_guard: Account = Depends(require_coordinator),
|
|
session: Session = Depends(get_session),
|
|
) -> FormPaymentOption:
|
|
option = owned_or_404(session.get(FormPaymentOption, option_id), edition.id)
|
|
changes = payload.model_dump(exclude_unset=True)
|
|
if changes.get("amount") is not None and changes["amount"] <= 0:
|
|
raise HTTPException(
|
|
status_code=422, detail={"code": "form.invalid_sumup_amount"}
|
|
)
|
|
if changes.get("label") is not None:
|
|
changes["label"] = changes["label"].strip()
|
|
for field, value in changes.items():
|
|
setattr(option, field, value)
|
|
session.add(option)
|
|
try:
|
|
session.commit()
|
|
except IntegrityError as exc:
|
|
session.rollback()
|
|
raise HTTPException(
|
|
status_code=409, detail={"code": "form.duplicate_payment_option_label"}
|
|
) from exc
|
|
session.refresh(option)
|
|
return option
|
|
|
|
|
|
@router.delete("/payment-options/{option_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_payment_option(
|
|
option_id: uuid.UUID,
|
|
edition: Edition = Depends(get_edition),
|
|
_guard: Account = Depends(require_coordinator),
|
|
session: Session = Depends(get_session),
|
|
) -> Response:
|
|
option = owned_or_404(session.get(FormPaymentOption, option_id), edition.id)
|
|
# Les bénévoles ayant déjà choisi cette option gardent leur copie figée
|
|
# (`Volunteer.fee_payment_label`/`fee_amount`) — rien à faire côté eux.
|
|
session.delete(option)
|
|
session.commit()
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
async def _read_logo(file: UploadFile) -> tuple[bytes, str]:
|
|
content_type = file.content_type or ""
|
|
if content_type not in _ALLOWED_PHOTO_TYPES:
|
|
raise HTTPException(
|
|
status_code=422, detail={"code": "form.logo_type_not_supported"}
|
|
)
|
|
content = await file.read()
|
|
if not content:
|
|
raise HTTPException(status_code=400, detail={"code": "form.logo_empty"})
|
|
if len(content) > _MAX_PHOTO_BYTES:
|
|
raise HTTPException(status_code=413, detail={"code": "form.logo_too_large"})
|
|
return content, content_type
|
|
|
|
|
|
@router.put("/logo", response_model=FormTemplateRead)
|
|
async def set_logo(
|
|
file: UploadFile = File(...),
|
|
edition: Edition = Depends(get_edition),
|
|
_guard: Account = Depends(require_coordinator),
|
|
session: Session = Depends(get_session),
|
|
) -> FormTemplateRead:
|
|
template = _get_or_create_template(session, edition.id)
|
|
content, content_type = await _read_logo(file)
|
|
template.logo = content
|
|
template.logo_content_type = content_type
|
|
session.add(template)
|
|
session.commit()
|
|
return _template_read(template, _enabled_fields(session, edition.id))
|
|
|
|
|
|
@router.get("/logo")
|
|
def get_logo(
|
|
edition: Edition = Depends(get_edition),
|
|
session: Session = Depends(get_session),
|
|
) -> Response:
|
|
"""Public (aucune authentification) : affiché sur `/inscription`, comme
|
|
`GET .../form/public` — voir le commentaire du module."""
|
|
template = _get_or_create_template(session, edition.id)
|
|
if template.logo is None:
|
|
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
|
|
return Response(
|
|
content=template.logo,
|
|
media_type=template.logo_content_type or "application/octet-stream",
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Formulaire public (aucune authentification) #
|
|
# --------------------------------------------------------------------------- #
|
|
@router.get("/public", response_model=PublicFormConfig)
|
|
def get_public_form(
|
|
edition: Edition = Depends(get_edition),
|
|
session: Session = Depends(get_session),
|
|
) -> PublicFormConfig:
|
|
template = _get_or_create_template(session, edition.id)
|
|
fields = _enabled_fields(session, edition.id)
|
|
|
|
positions: list[PublicPositionOption] = []
|
|
if FormFieldType.POSITION_WISHES in fields:
|
|
positions = [
|
|
PublicPositionOption(id=p.id, name=p.name)
|
|
for p in session.exec(
|
|
select(Position)
|
|
.where(Position.edition_id == edition.id)
|
|
.order_by(Position.name)
|
|
)
|
|
]
|
|
|
|
artists: list[PublicArtistOption] = []
|
|
if FormFieldType.ARTIST_WISHES in fields:
|
|
artists = [
|
|
PublicArtistOption(
|
|
id=a.id, name=a.name, performance_date=a.performance_date
|
|
)
|
|
for a in session.exec(
|
|
select(Artist)
|
|
.where(Artist.edition_id == edition.id)
|
|
.order_by(Artist.performance_date, Artist.name)
|
|
)
|
|
]
|
|
|
|
options = _payment_options(session, edition.id)
|
|
return PublicFormConfig(
|
|
is_open=template.is_open,
|
|
fields=fields,
|
|
positions=positions,
|
|
artists=artists,
|
|
has_logo=template.logo is not None,
|
|
description=template.description,
|
|
sumup_enabled=_sumup_enabled(template, options),
|
|
payment_options=[
|
|
PublicPaymentOption(
|
|
id=o.id,
|
|
label=o.label,
|
|
amount=o.amount,
|
|
requires_mecenat_info=o.requires_mecenat_info,
|
|
)
|
|
for o in options
|
|
],
|
|
)
|
|
|
|
|
|
def _prune_stale_pending(session: Session, edition_id: uuid.UUID) -> None:
|
|
"""Nettoyage paresseux (même principe que `app/auth/rate_limit.py`) :
|
|
supprime les inscriptions en attente jamais payées et jamais relancées
|
|
depuis `_PENDING_STALE_HOURS` — appelé à chaque soumission/relance, pas
|
|
de tâche planifiée dans cette appli."""
|
|
cutoff = local_now() - dt.timedelta(hours=_PENDING_STALE_HOURS)
|
|
session.execute(
|
|
delete(PendingFormSubmission).where(
|
|
PendingFormSubmission.edition_id == edition_id,
|
|
PendingFormSubmission.promoted_volunteer_id.is_(None),
|
|
PendingFormSubmission.created_at < cutoff,
|
|
)
|
|
)
|
|
session.commit()
|
|
|
|
|
|
async def _read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None:
|
|
"""Validation partagée par l'inscription immédiate et l'inscription en
|
|
attente de paiement — `None` si aucun fichier fourni."""
|
|
if photo is None or not photo.filename:
|
|
return None
|
|
content_type = photo.content_type or ""
|
|
if content_type not in _ALLOWED_PHOTO_TYPES:
|
|
raise HTTPException(
|
|
status_code=422, detail={"code": "form.photo_type_not_supported"}
|
|
)
|
|
content = await photo.read()
|
|
if len(content) > _MAX_PHOTO_BYTES:
|
|
raise HTTPException(status_code=413, detail={"code": "form.photo_too_large"})
|
|
return (content, content_type) if content else None
|
|
|
|
|
|
def _filter_owned_ids(
|
|
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID]
|
|
) -> list[uuid.UUID]:
|
|
"""Comme `resolve_owned_ids` (`app/api/_common.py`) mais silencieux : ne
|
|
garde que les ids toujours valides pour cette édition, sans jamais lever.
|
|
Utilisé à la promotion d'une inscription en attente — le paiement a déjà
|
|
eu lieu, un poste/artiste supprimé entretemps ne doit jamais faire
|
|
échouer la création du bénévole (voir `_promote_pending`)."""
|
|
wanted = list(dict.fromkeys(ids))
|
|
if not wanted:
|
|
return []
|
|
found = set(
|
|
session.exec(
|
|
select(model.id).where(
|
|
model.edition_id == edition_id, model.id.in_(wanted)
|
|
)
|
|
)
|
|
)
|
|
return [i for i in wanted if i in found]
|
|
|
|
|
|
def _get_pending_or_404(
|
|
session: Session, edition_id: uuid.UUID, token: uuid.UUID
|
|
) -> PendingFormSubmission:
|
|
pending = session.get(PendingFormSubmission, token)
|
|
if pending is None or pending.edition_id != edition_id:
|
|
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
|
|
return pending
|
|
|
|
|
|
def _parse_id_list(raw: str | None) -> list[uuid.UUID]:
|
|
"""`position_wish_ids`/`artist_wish_ids` arrivent en JSON dans un champ
|
|
`multipart/form-data` (impossible d'y envoyer un tableau natif)."""
|
|
if not raw:
|
|
return []
|
|
try:
|
|
values = json.loads(raw)
|
|
if not isinstance(values, list):
|
|
raise ValueError
|
|
return [uuid.UUID(str(v)) for v in values]
|
|
except (ValueError, TypeError) as exc:
|
|
raise HTTPException(
|
|
status_code=422, detail={"code": "form.invalid_payload"}
|
|
) from exc
|
|
|
|
|
|
def _check_one_artist_wish_per_day(
|
|
session: Session, edition_id: uuid.UUID, artist_ids: list[uuid.UUID]
|
|
) -> None:
|
|
"""Un seul vœu d'artiste par soir de festival — `set_artists` (validation
|
|
d'appartenance à l'édition) s'occupe du reste."""
|
|
dates = list(
|
|
session.exec(
|
|
select(Artist.performance_date).where(
|
|
Artist.edition_id == edition_id, Artist.id.in_(artist_ids)
|
|
)
|
|
)
|
|
)
|
|
if len(dates) != len(set(dates)):
|
|
raise HTTPException(
|
|
status_code=422, detail={"code": "form.too_many_artists_same_day"}
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/public/submit",
|
|
response_model=FormSubmissionResult,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def submit_public_form(
|
|
background: BackgroundTasks,
|
|
edition: Edition = Depends(get_edition),
|
|
session: Session = Depends(get_session),
|
|
first_name: str = Form(...),
|
|
last_name: str = Form(...),
|
|
hcaptcha_token: str = Form(...),
|
|
email: str | None = Form(None),
|
|
phone: str | None = Form(None),
|
|
comment: str | None = Form(None),
|
|
companion_wish_note: str | None = Form(None),
|
|
available: bool = Form(False),
|
|
payment_option_id: str | None = Form(None),
|
|
position_wish_ids: str | None = Form(None),
|
|
artist_wish_ids: str | None = Form(None),
|
|
photo: UploadFile | None = File(None),
|
|
) -> FormSubmissionResult:
|
|
# Filet de sécurité global (par édition) : hCaptcha est la défense
|
|
# principale, voir `app/auth/rate_limit.py`.
|
|
form_submission_limiter.hit(str(edition.id))
|
|
_prune_stale_pending(session, edition.id)
|
|
|
|
template = _get_or_create_template(session, edition.id)
|
|
if not template.is_open:
|
|
raise HTTPException(status_code=422, detail={"code": "form.closed"})
|
|
|
|
if not verify_hcaptcha(hcaptcha_token):
|
|
raise HTTPException(status_code=422, detail={"code": "form.captcha_failed"})
|
|
|
|
if not first_name.strip() or not last_name.strip():
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail={
|
|
"code": "import.row.missing_required_field",
|
|
"params": {"field": "first_name"},
|
|
},
|
|
)
|
|
|
|
fields = set(_enabled_fields(session, edition.id))
|
|
|
|
# Tout champ activé par la coordination est obligatoire — le formulaire
|
|
# public n'est plus une simple collecte d'infos facultatives.
|
|
email = (email or "").strip() or None
|
|
phone = (phone or "").strip() or None
|
|
comment = (comment or "").strip() or None
|
|
companion_wish_note = (companion_wish_note or "").strip() or None
|
|
for active, field_name, ok in (
|
|
(FormFieldType.EMAIL in fields, "email", bool(email)),
|
|
(FormFieldType.PHONE in fields, "phone", bool(phone)),
|
|
(FormFieldType.AVAILABLE in fields, "available", available),
|
|
):
|
|
if active and not ok:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail={
|
|
"code": "import.row.missing_required_field",
|
|
"params": {"field": field_name},
|
|
},
|
|
)
|
|
|
|
position_ids = (
|
|
_parse_id_list(position_wish_ids) if FormFieldType.POSITION_WISHES in fields else []
|
|
)
|
|
if len(position_ids) > _MAX_POSITION_WISHES:
|
|
raise HTTPException(
|
|
status_code=422, detail={"code": "form.too_many_position_wishes"}
|
|
)
|
|
if position_ids:
|
|
resolve_owned_ids(session, Position, edition.id, position_ids, "position.unknown")
|
|
|
|
artist_ids = (
|
|
_parse_id_list(artist_wish_ids) if FormFieldType.ARTIST_WISHES in fields else []
|
|
)
|
|
if artist_ids:
|
|
_check_one_artist_wish_per_day(session, edition.id, artist_ids)
|
|
resolve_owned_ids(session, Artist, edition.id, artist_ids, "artist.unknown")
|
|
|
|
payment_option: FormPaymentOption | None = None
|
|
if FormFieldType.WILLING_TO_PAY_FEE in fields and payment_option_id:
|
|
try:
|
|
option_uuid = uuid.UUID(payment_option_id)
|
|
except ValueError:
|
|
option_uuid = None
|
|
payment_option = (
|
|
session.get(FormPaymentOption, option_uuid) if option_uuid else None
|
|
)
|
|
if payment_option is None or payment_option.edition_id != edition.id:
|
|
raise HTTPException(
|
|
status_code=422, detail={"code": "form.unknown_payment_option"}
|
|
)
|
|
|
|
photo_data = await _read_photo(photo) if FormFieldType.PHOTO in fields else None
|
|
|
|
if payment_option is None:
|
|
# Pas de paiement en ligne (case désactivée, ou « je paierai sur
|
|
# place ») : rien à attendre, le bénévole est créé tout de suite.
|
|
volunteer = Volunteer(
|
|
edition_id=edition.id,
|
|
first_name=first_name.strip().title(),
|
|
last_name=last_name.strip().title(),
|
|
email=email if FormFieldType.EMAIL in fields else None,
|
|
phone=phone if FormFieldType.PHONE in fields else None,
|
|
comment=comment if FormFieldType.COMMENT in fields else None,
|
|
companion_wish_note=(
|
|
companion_wish_note if FormFieldType.COMPANION_WISH in fields else None
|
|
),
|
|
self_declared_available=(
|
|
available if FormFieldType.AVAILABLE in fields else False
|
|
),
|
|
willing_to_pay_fee=FormFieldType.WILLING_TO_PAY_FEE in fields,
|
|
)
|
|
if photo_data:
|
|
volunteer.photo, volunteer.photo_content_type = photo_data
|
|
session.add(volunteer)
|
|
session.flush()
|
|
if FormFieldType.POSITION_WISHES in fields:
|
|
set_wished_positions(session, volunteer, position_ids)
|
|
if FormFieldType.ARTIST_WISHES in fields:
|
|
set_artists(session, volunteer, artist_ids)
|
|
session.commit()
|
|
session.refresh(volunteer)
|
|
if volunteer.email:
|
|
subject, body = render_registration_confirmation(first_name=volunteer.first_name)
|
|
background.add_task(
|
|
send,
|
|
volunteer.email,
|
|
subject,
|
|
body,
|
|
logo_url=edition_logo_url(session, edition.id),
|
|
)
|
|
return FormSubmissionResult(volunteer_id=volunteer.id)
|
|
|
|
# Paiement en ligne choisi : rien n'est compté en base tant que le
|
|
# paiement n'est pas confirmé — voir `PendingFormSubmission` et
|
|
# `_promote_pending`.
|
|
pending = PendingFormSubmission(
|
|
edition_id=edition.id,
|
|
first_name=first_name.strip().title(),
|
|
last_name=last_name.strip().title(),
|
|
email=email if FormFieldType.EMAIL in fields else None,
|
|
phone=phone if FormFieldType.PHONE in fields else None,
|
|
comment=comment if FormFieldType.COMMENT in fields else None,
|
|
companion_wish_note=(
|
|
companion_wish_note if FormFieldType.COMPANION_WISH in fields else None
|
|
),
|
|
available=available if FormFieldType.AVAILABLE in fields else False,
|
|
position_wish_ids_json=(
|
|
json.dumps([str(i) for i in position_ids]) if position_ids else None
|
|
),
|
|
artist_wish_ids_json=(
|
|
json.dumps([str(i) for i in artist_ids]) if artist_ids else None
|
|
),
|
|
payment_label=payment_option.label,
|
|
payment_amount=payment_option.amount,
|
|
requires_mecenat_info=payment_option.requires_mecenat_info,
|
|
)
|
|
if photo_data:
|
|
pending.photo, pending.photo_content_type = photo_data
|
|
session.add(pending)
|
|
session.commit()
|
|
session.refresh(pending)
|
|
|
|
if pending.requires_mecenat_info:
|
|
return FormSubmissionResult(pending_token=pending.id, requires_mecenat_info=True)
|
|
|
|
checkout_url = _create_pending_checkout(session, edition, template, pending)
|
|
return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url)
|
|
|
|
|
|
def _create_pending_checkout(
|
|
session: Session, edition: Edition, template: FormTemplate, pending: PendingFormSubmission
|
|
) -> str | None:
|
|
"""Crée (ou recrée, voir `retry_pending_payment`) le checkout SumUp d'une
|
|
inscription en attente — best effort (comme l'envoi d'emails) : un échec
|
|
de l'appel API laisse l'inscription en attente, jamais perdue (la page de
|
|
confirmation propose alors de réessayer). `checkout_reference` porte un
|
|
suffixe aléatoire à chaque appel : une relance après échec/expiration doit
|
|
ouvrir un nouveau checkout SumUp, jamais réutiliser une référence déjà
|
|
consommée (409 côté SumUp sinon)."""
|
|
base = get_settings().public_base_url.rstrip("/")
|
|
checkout = create_checkout(
|
|
api_key=template.sumup_api_key,
|
|
merchant_code=template.sumup_merchant_code,
|
|
amount=pending.payment_amount,
|
|
currency="EUR",
|
|
checkout_reference=f"{pending.id}-{uuid.uuid4().hex[:8]}",
|
|
description=f"{pending.payment_label} — édition {edition.year}",
|
|
return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook",
|
|
redirect_url=f"{base}/inscription?paiement={pending.id}",
|
|
)
|
|
if checkout is None:
|
|
return None
|
|
pending.checkout_id = checkout.id
|
|
pending.checkout_status = FeePaymentStatus.PENDING
|
|
session.add(pending)
|
|
session.commit()
|
|
return checkout.hosted_checkout_url
|
|
|
|
|
|
def _promote_pending(
|
|
session: Session,
|
|
edition: Edition,
|
|
pending: PendingFormSubmission,
|
|
checkout: SumUpCheckout,
|
|
background: BackgroundTasks,
|
|
) -> Volunteer:
|
|
"""Paiement confirmé : crée enfin le `Volunteer` (voir le docstring de
|
|
`PendingFormSubmission`), résout les vœux en filtrant silencieusement les
|
|
ids devenus invalides (`_filter_owned_ids` — le paiement a déjà eu lieu,
|
|
rien ne doit plus faire échouer la création), puis vide les champs
|
|
nominatifs de la ligne en attente (RGPD : cette information ne doit plus
|
|
vivre qu'à un seul endroit)."""
|
|
volunteer = Volunteer(
|
|
edition_id=edition.id,
|
|
first_name=pending.first_name or "",
|
|
last_name=pending.last_name or "",
|
|
email=pending.email,
|
|
phone=pending.phone,
|
|
comment=pending.comment,
|
|
companion_wish_note=pending.companion_wish_note,
|
|
self_declared_available=pending.available,
|
|
willing_to_pay_fee=True,
|
|
fee_payment_label=pending.payment_label,
|
|
fee_amount=pending.payment_amount,
|
|
fee_checkout_id=checkout.id,
|
|
fee_payment_status=FeePaymentStatus.PAID,
|
|
fee_paid_at=local_now(),
|
|
mecenat_address=pending.mecenat_address,
|
|
mecenat_city=pending.mecenat_city,
|
|
mecenat_postal_code=pending.mecenat_postal_code,
|
|
mecenat_country=pending.mecenat_country,
|
|
photo=pending.photo,
|
|
photo_content_type=pending.photo_content_type,
|
|
)
|
|
session.add(volunteer)
|
|
session.flush()
|
|
|
|
position_ids = _filter_owned_ids(
|
|
session, Position, edition.id, _parse_id_list(pending.position_wish_ids_json)
|
|
)
|
|
if position_ids:
|
|
set_wished_positions(session, volunteer, position_ids)
|
|
artist_ids = _filter_owned_ids(
|
|
session, Artist, edition.id, _parse_id_list(pending.artist_wish_ids_json)
|
|
)
|
|
if artist_ids:
|
|
set_artists(session, volunteer, artist_ids)
|
|
|
|
session.commit()
|
|
session.refresh(volunteer)
|
|
|
|
pending.promoted_volunteer_id = volunteer.id
|
|
pending.checkout_status = FeePaymentStatus.PAID
|
|
pending.first_name = None
|
|
pending.last_name = None
|
|
pending.email = None
|
|
pending.phone = None
|
|
pending.comment = None
|
|
pending.companion_wish_note = None
|
|
pending.photo = None
|
|
pending.photo_content_type = None
|
|
pending.mecenat_address = None
|
|
pending.mecenat_city = None
|
|
pending.mecenat_postal_code = None
|
|
pending.mecenat_country = None
|
|
session.add(pending)
|
|
session.commit()
|
|
|
|
if volunteer.email:
|
|
subject, body = render_registration_paid_confirmation(
|
|
first_name=volunteer.first_name,
|
|
label=volunteer.fee_payment_label or "",
|
|
amount=volunteer.fee_amount or 0,
|
|
)
|
|
background.add_task(
|
|
send,
|
|
volunteer.email,
|
|
subject,
|
|
body,
|
|
logo_url=edition_logo_url(session, edition.id),
|
|
)
|
|
return volunteer
|
|
|
|
|
|
def _apply_pending_checkout_result(
|
|
session: Session,
|
|
edition: Edition,
|
|
pending: PendingFormSubmission,
|
|
checkout: SumUpCheckout,
|
|
background: BackgroundTasks,
|
|
) -> None:
|
|
"""Met à jour une inscription en attente à partir d'un état de checkout
|
|
fraîchement relu chez SumUp — partagé par le webhook et la vérification
|
|
en direct (`get_pending_status`)."""
|
|
if pending.promoted_volunteer_id is not None:
|
|
return
|
|
try:
|
|
new_status = FeePaymentStatus(checkout.status)
|
|
except ValueError:
|
|
return
|
|
if new_status == FeePaymentStatus.PAID:
|
|
_promote_pending(session, edition, pending, checkout, background)
|
|
else:
|
|
pending.checkout_status = new_status
|
|
session.add(pending)
|
|
session.commit()
|
|
|
|
|
|
@router.post(
|
|
"/public/pending/{token}/mecenat-info",
|
|
response_model=FormSubmissionResult,
|
|
)
|
|
def submit_mecenat_info(
|
|
token: uuid.UUID,
|
|
payload: MecenatInfoSubmit,
|
|
edition: Edition = Depends(get_edition),
|
|
session: Session = Depends(get_session),
|
|
) -> FormSubmissionResult:
|
|
"""Étape d'adresse postale d'un type de paiement « mécénat », entre la
|
|
soumission du formulaire principal et le paiement lui-même — crée le
|
|
checkout SumUp une fois ces coordonnées enregistrées."""
|
|
form_submission_limiter.hit(str(edition.id))
|
|
pending = _get_pending_or_404(session, edition.id, token)
|
|
if pending.promoted_volunteer_id is not None:
|
|
raise HTTPException(
|
|
status_code=422, detail={"code": "form.pending_already_paid"}
|
|
)
|
|
if not pending.requires_mecenat_info:
|
|
raise HTTPException(
|
|
status_code=422, detail={"code": "form.pending_not_awaiting_info"}
|
|
)
|
|
|
|
address = payload.address.strip()
|
|
city = payload.city.strip()
|
|
postal_code = payload.postal_code.strip()
|
|
country = payload.country.strip()
|
|
if not (address and city and postal_code and country):
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail={
|
|
"code": "import.row.missing_required_field",
|
|
"params": {"field": "address"},
|
|
},
|
|
)
|
|
pending.mecenat_address = address
|
|
pending.mecenat_city = city
|
|
pending.mecenat_postal_code = postal_code
|
|
pending.mecenat_country = country
|
|
session.add(pending)
|
|
session.commit()
|
|
|
|
template = _get_or_create_template(session, edition.id)
|
|
checkout_url = _create_pending_checkout(session, edition, template, pending)
|
|
return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url)
|
|
|
|
|
|
@router.get("/public/pending/{token}", response_model=PendingSubmissionStatus)
|
|
def get_pending_status(
|
|
token: uuid.UUID,
|
|
background: BackgroundTasks,
|
|
edition: Edition = Depends(get_edition),
|
|
session: Session = Depends(get_session),
|
|
) -> PendingSubmissionStatus:
|
|
"""Interrogé en direct par la page de confirmation (voir
|
|
`PublicSignupPage.tsx`) jusqu'à ce que le statut ne soit plus `PENDING` —
|
|
relit toujours l'état réel auprès de SumUp à chaque appel, jamais
|
|
seulement la dernière valeur connue en base."""
|
|
pending = _get_pending_or_404(session, edition.id, token)
|
|
if pending.promoted_volunteer_id is None and pending.checkout_id:
|
|
template = _get_or_create_template(session, edition.id)
|
|
if template.sumup_api_key:
|
|
checkout = get_checkout(
|
|
api_key=template.sumup_api_key, checkout_id=pending.checkout_id
|
|
)
|
|
if checkout is not None:
|
|
_apply_pending_checkout_result(session, edition, pending, checkout, background)
|
|
session.refresh(pending)
|
|
|
|
if pending.promoted_volunteer_id is not None:
|
|
return PendingSubmissionStatus(
|
|
status=FeePaymentStatus.PAID,
|
|
label=pending.payment_label,
|
|
amount=pending.payment_amount,
|
|
volunteer_id=pending.promoted_volunteer_id,
|
|
)
|
|
return PendingSubmissionStatus(
|
|
status=pending.checkout_status or FeePaymentStatus.PENDING,
|
|
label=pending.payment_label,
|
|
amount=pending.payment_amount,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/public/pending/{token}/retry-payment",
|
|
response_model=FormSubmissionResult,
|
|
)
|
|
def retry_pending_payment(
|
|
token: uuid.UUID,
|
|
edition: Edition = Depends(get_edition),
|
|
session: Session = Depends(get_session),
|
|
) -> FormSubmissionResult:
|
|
"""Relance un paiement après échec/expiration, sans faire ressaisir le
|
|
formulaire — un nouveau checkout SumUp est créé pour la même inscription
|
|
en attente (voir `_create_pending_checkout`)."""
|
|
form_submission_limiter.hit(str(edition.id))
|
|
pending = _get_pending_or_404(session, edition.id, token)
|
|
if pending.promoted_volunteer_id is not None:
|
|
raise HTTPException(
|
|
status_code=422, detail={"code": "form.pending_already_paid"}
|
|
)
|
|
if pending.requires_mecenat_info and pending.mecenat_address is None:
|
|
raise HTTPException(
|
|
status_code=422, detail={"code": "form.pending_not_awaiting_info"}
|
|
)
|
|
|
|
template = _get_or_create_template(session, edition.id)
|
|
checkout_url = _create_pending_checkout(session, edition, template, pending)
|
|
return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url)
|
|
|
|
|
|
@router.post("/public/sumup-webhook", status_code=status.HTTP_200_OK)
|
|
def sumup_webhook(
|
|
payload: SumUpWebhookPayload,
|
|
background: BackgroundTasks,
|
|
edition: Edition = Depends(get_edition),
|
|
session: Session = Depends(get_session),
|
|
) -> Response:
|
|
"""Appelé par SumUp (aucune authentification possible côté SumUp) à
|
|
chaque changement de statut d'un checkout. Le payload ne donne qu'un
|
|
identifiant : jamais fait confiance sans relire l'état réel via l'API
|
|
(voir `app/sumup/client.py:get_checkout`). Répond toujours `200`, y
|
|
compris sur un événement inconnu — SumUp réessaie sinon inutilement."""
|
|
template = _get_or_create_template(session, edition.id)
|
|
if not template.sumup_api_key:
|
|
return Response(status_code=status.HTTP_200_OK)
|
|
|
|
checkout = get_checkout(api_key=template.sumup_api_key, checkout_id=payload.id)
|
|
if checkout is None:
|
|
logger.warning("Webhook SumUp : relecture du checkout impossible.")
|
|
return Response(status_code=status.HTTP_200_OK)
|
|
|
|
pending = session.exec(
|
|
select(PendingFormSubmission).where(
|
|
PendingFormSubmission.edition_id == edition.id,
|
|
PendingFormSubmission.checkout_id == payload.id,
|
|
)
|
|
).first()
|
|
if pending is not None:
|
|
_apply_pending_checkout_result(session, edition, pending, checkout, background)
|
|
return Response(status_code=status.HTTP_200_OK)
|
|
|
|
# Filet de sécurité pour d'anciennes inscriptions créées avant le passage
|
|
# à l'inscription en deux temps (voir CLAUDE.md § 2, Paiement de la
|
|
# cotisation) : un `Volunteer` directement lié à un checkout.
|
|
volunteer = session.exec(
|
|
select(Volunteer).where(
|
|
Volunteer.edition_id == edition.id, Volunteer.fee_checkout_id == payload.id
|
|
)
|
|
).first()
|
|
if volunteer is not None:
|
|
try:
|
|
new_status = FeePaymentStatus(checkout.status)
|
|
except ValueError:
|
|
return Response(status_code=status.HTTP_200_OK)
|
|
volunteer.fee_payment_status = new_status
|
|
if new_status == FeePaymentStatus.PAID and volunteer.fee_paid_at is None:
|
|
volunteer.fee_paid_at = local_now()
|
|
session.add(volunteer)
|
|
session.commit()
|
|
return Response(status_code=status.HTTP_200_OK)
|