Inscription en deux temps, emails de confirmation et export Excel des bénévoles
Formulaire public — paiement en ligne : - Le bénévole n'est plus compté en base tant qu'un paiement en ligne choisi n'est pas confirmé (PendingFormSubmission, promu en Volunteer à la confirmation, webhook ou vérification en direct). - Type de paiement « mécénat » : étape d'adresse postale avant le paiement, conservée sur le bénévole pour un reçu fiscal (cerfa) ultérieur. - Page de confirmation au retour de SumUp, avec relance possible après échec/expiration sans ressaisir le formulaire. - « Je paierai sur place » remplace « je ne participe pas » ; tous les champs activés par la coordination sont désormais obligatoires. - La fiche bénévole n'affiche plus l'état des cases à cocher, seulement le résultat réel du paiement (libellé, montant, statut). Emails : - Email de confirmation d'inscription à la création du bénévole (tient lieu de reçu de paiement quand une cotisation est payée en ligne). - Mise en forme HTML au thème de l'application (logo de l'édition en en-tête) pour tous les emails envoyés, texte brut conservé en repli. Export Excel des bénévoles (COORDINATION) : coordonnées, vœux résolus en noms lisibles, cotisation et mécénat. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
68d42ee303
commit
a2b748566b
@@ -29,7 +29,7 @@ from app.auth.rate_limit import login_limiter, magic_link_limiter
|
||||
from app.auth.tokens import create_access_token, hash_token
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.email import render_magic_link, send
|
||||
from app.email import edition_logo_url, render_magic_link, send
|
||||
from app.models import Account, MagicLinkToken, local_now
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
@@ -143,7 +143,9 @@ def request_magic_link(
|
||||
link=build_magic_link_url(raw),
|
||||
minutes=settings.magic_link_ttl_minutes,
|
||||
)
|
||||
background.add_task(send, account.email, subject, body)
|
||||
background.add_task(
|
||||
send, account.email, subject, body, logo_url=edition_logo_url(session, edition_id)
|
||||
)
|
||||
if not settings.email_enabled:
|
||||
# Aide au dev sans SMTP : le jeton (non nominatif) est journalisé.
|
||||
logger.info(
|
||||
|
||||
+455
-100
@@ -20,12 +20,14 @@ 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,
|
||||
@@ -38,7 +40,12 @@ from sqlalchemy import delete
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api._common import owned_or_404, set_artists, set_wished_positions
|
||||
from app.api._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,
|
||||
@@ -48,6 +55,8 @@ from app.api.schemas import (
|
||||
FormSubmissionResult,
|
||||
FormTemplateRead,
|
||||
FormTemplateUpdate,
|
||||
MecenatInfoSubmit,
|
||||
PendingSubmissionStatus,
|
||||
PublicArtistOption,
|
||||
PublicFormConfig,
|
||||
PublicPaymentOption,
|
||||
@@ -58,6 +67,12 @@ 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,
|
||||
@@ -68,11 +83,12 @@ from app.models import (
|
||||
FormFieldType,
|
||||
FormPaymentOption,
|
||||
FormTemplate,
|
||||
PendingFormSubmission,
|
||||
Position,
|
||||
Volunteer,
|
||||
local_now,
|
||||
)
|
||||
from app.sumup import create_checkout, get_checkout
|
||||
from app.sumup import SumUpCheckout, create_checkout, get_checkout
|
||||
|
||||
logger = logging.getLogger("app.forms")
|
||||
|
||||
@@ -83,6 +99,11 @@ _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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -211,6 +232,7 @@ def create_payment_option(
|
||||
label=payload.label.strip(),
|
||||
amount=payload.amount,
|
||||
order=payload.order,
|
||||
requires_mecenat_info=payload.requires_mecenat_info,
|
||||
)
|
||||
session.add(option)
|
||||
try:
|
||||
@@ -360,11 +382,79 @@ def get_public_form(
|
||||
description=template.description,
|
||||
sumup_enabled=_sumup_enabled(template, options),
|
||||
payment_options=[
|
||||
PublicPaymentOption(id=o.id, label=o.label, amount=o.amount) for o in options
|
||||
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)."""
|
||||
@@ -405,6 +495,7 @@ def _check_one_artist_wish_per_day(
|
||||
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(...),
|
||||
@@ -415,7 +506,6 @@ async def submit_public_form(
|
||||
comment: str | None = Form(None),
|
||||
companion_wish_note: str | None = Form(None),
|
||||
available: bool = Form(False),
|
||||
willing_to_pay_fee: bool = Form(False),
|
||||
payment_option_id: str | None = Form(None),
|
||||
position_wish_ids: str | None = Form(None),
|
||||
artist_wish_ids: str | None = Form(None),
|
||||
@@ -424,6 +514,7 @@ async def submit_public_form(
|
||||
# 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:
|
||||
@@ -443,6 +534,26 @@ async def submit_public_form(
|
||||
|
||||
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 []
|
||||
)
|
||||
@@ -450,12 +561,15 @@ async def submit_public_form(
|
||||
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:
|
||||
@@ -471,109 +585,339 @@ async def submit_public_form(
|
||||
status_code=422, detail={"code": "form.unknown_payment_option"}
|
||||
)
|
||||
|
||||
volunteer = Volunteer(
|
||||
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 or "").strip() or None) if FormFieldType.EMAIL in fields else None,
|
||||
phone=((phone or "").strip() or None) if FormFieldType.PHONE in fields else None,
|
||||
comment=(
|
||||
(comment or "").strip() or None if FormFieldType.COMMENT in fields else None
|
||||
),
|
||||
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 or "").strip() or None
|
||||
if FormFieldType.COMPANION_WISH in fields
|
||||
else None
|
||||
companion_wish_note if FormFieldType.COMPANION_WISH in fields else None
|
||||
),
|
||||
self_declared_available=(
|
||||
available if FormFieldType.AVAILABLE in fields else False
|
||||
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
|
||||
),
|
||||
# Choisir un type de paiement vaut « je participe » même sans cocher
|
||||
# la case (le formulaire public n'affiche d'ailleurs plus qu'un choix
|
||||
# unique dès qu'au moins un type est configuré, voir PublicSignupPage).
|
||||
willing_to_pay_fee=(
|
||||
(willing_to_pay_fee or payment_option is not None)
|
||||
if FormFieldType.WILLING_TO_PAY_FEE in fields
|
||||
else False
|
||||
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 FormFieldType.PHOTO in fields and photo is not None and photo.filename:
|
||||
content_type = photo.content_type or ""
|
||||
if content_type not in _ALLOWED_PHOTO_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "form.photo_type_not_supported"}
|
||||
)
|
||||
content = await photo.read()
|
||||
if len(content) > _MAX_PHOTO_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413, detail={"code": "form.photo_too_large"}
|
||||
)
|
||||
if content:
|
||||
volunteer.photo = content
|
||||
volunteer.photo_content_type = content_type
|
||||
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()
|
||||
|
||||
if FormFieldType.POSITION_WISHES in fields:
|
||||
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)
|
||||
if FormFieldType.ARTIST_WISHES in fields:
|
||||
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)
|
||||
|
||||
checkout_url = None
|
||||
if (
|
||||
payment_option is not None
|
||||
and template.sumup_merchant_code
|
||||
and template.sumup_api_key
|
||||
):
|
||||
checkout_url = _create_fee_checkout(
|
||||
session, edition, template, volunteer, payment_option
|
||||
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,
|
||||
)
|
||||
|
||||
return FormSubmissionResult(volunteer_id=volunteer.id, sumup_checkout_url=checkout_url)
|
||||
background.add_task(
|
||||
send,
|
||||
volunteer.email,
|
||||
subject,
|
||||
body,
|
||||
logo_url=edition_logo_url(session, edition.id),
|
||||
)
|
||||
return volunteer
|
||||
|
||||
|
||||
def _create_fee_checkout(
|
||||
def _apply_pending_checkout_result(
|
||||
session: Session,
|
||||
edition: Edition,
|
||||
template: FormTemplate,
|
||||
volunteer: Volunteer,
|
||||
option: FormPaymentOption,
|
||||
) -> str | None:
|
||||
"""Crée le checkout SumUp du type de paiement choisi — best effort (comme
|
||||
l'envoi d'emails) : un échec de l'appel API ne remet jamais en cause
|
||||
l'inscription déjà enregistrée. `option.label`/`option.amount` sont
|
||||
recopiés sur le bénévole (`fee_payment_label`/`fee_amount`) avant même
|
||||
l'appel, pour garder une trace du choix même si l'appel échoue."""
|
||||
volunteer.fee_payment_label = option.label
|
||||
volunteer.fee_amount = option.amount
|
||||
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()
|
||||
|
||||
base = get_settings().public_base_url.rstrip("/")
|
||||
checkout = create_checkout(
|
||||
api_key=template.sumup_api_key,
|
||||
merchant_code=template.sumup_merchant_code,
|
||||
amount=option.amount,
|
||||
currency="EUR",
|
||||
checkout_reference=str(volunteer.id),
|
||||
description=f"{option.label} — édition {edition.year}",
|
||||
return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook",
|
||||
redirect_url=f"{base}/inscription?paiement=ok",
|
||||
)
|
||||
if checkout is not None:
|
||||
volunteer.fee_checkout_id = checkout.id
|
||||
volunteer.fee_payment_status = FeePaymentStatus.PENDING
|
||||
session.add(volunteer)
|
||||
|
||||
@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()
|
||||
return checkout.hosted_checkout_url if checkout is not None else None
|
||||
|
||||
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:
|
||||
@@ -583,29 +927,40 @@ def sumup_webhook(
|
||||
(voir `app/sumup/client.py:get_checkout`). Répond toujours `200`, y
|
||||
compris sur un événement inconnu — SumUp réessaie sinon inutilement."""
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
if template.sumup_api_key:
|
||||
checkout = get_checkout(api_key=template.sumup_api_key, checkout_id=payload.id)
|
||||
volunteer = (
|
||||
session.exec(
|
||||
select(Volunteer).where(
|
||||
Volunteer.edition_id == edition.id,
|
||||
Volunteer.fee_checkout_id == payload.id,
|
||||
)
|
||||
).first()
|
||||
if checkout is not None
|
||||
else None
|
||||
if 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,
|
||||
)
|
||||
if checkout is not None and volunteer is not None:
|
||||
try:
|
||||
new_status = FeePaymentStatus(checkout.status)
|
||||
except ValueError:
|
||||
new_status = None
|
||||
if new_status is not None:
|
||||
volunteer.fee_payment_status = new_status
|
||||
if new_status == FeePaymentStatus.PAID and volunteer.fee_paid_at is None:
|
||||
volunteer.fee_paid_at = local_now()
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
elif checkout is None:
|
||||
logger.warning("Webhook SumUp : relecture du checkout impossible.")
|
||||
).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)
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.api.schemas import (
|
||||
from app.auth.deps import require_coordinator
|
||||
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
|
||||
from app.db import get_session
|
||||
from app.email import render_planning, send_many
|
||||
from app.email import edition_logo_url, render_planning, send_many
|
||||
from app.i18n import t
|
||||
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
|
||||
|
||||
@@ -112,7 +112,7 @@ def send_planning_notification(
|
||||
recipients.append(volunteer.id)
|
||||
|
||||
session.commit() # persiste les éventuels MagicLinkToken émis
|
||||
background.add_task(send_many, items)
|
||||
background.add_task(send_many, items, logo_url=edition_logo_url(session, edition.id))
|
||||
return PlanningNotificationResult(
|
||||
queued=len(items),
|
||||
skipped_no_email=skipped_no_email,
|
||||
|
||||
@@ -102,6 +102,12 @@ class VolunteerRead(ApiModel):
|
||||
fee_amount: float | None
|
||||
fee_payment_status: FeePaymentStatus | None
|
||||
fee_paid_at: dt.datetime | None
|
||||
# Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un
|
||||
# type de paiement « mécénat » (voir `FormPaymentOption.requires_mecenat_info`).
|
||||
mecenat_address: str | None
|
||||
mecenat_city: str | None
|
||||
mecenat_postal_code: str | None
|
||||
mecenat_country: str | None
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
@@ -526,6 +532,7 @@ class FormPaymentOptionRead(ApiModel):
|
||||
label: str
|
||||
amount: float
|
||||
order: int
|
||||
requires_mecenat_info: bool
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
@@ -534,12 +541,14 @@ class FormPaymentOptionCreate(ApiModel):
|
||||
label: str
|
||||
amount: float
|
||||
order: int = 0
|
||||
requires_mecenat_info: bool = False
|
||||
|
||||
|
||||
class FormPaymentOptionUpdate(ApiModel):
|
||||
label: str | None = None
|
||||
amount: float | None = None
|
||||
order: int | None = None
|
||||
requires_mecenat_info: bool | None = None
|
||||
|
||||
|
||||
class PublicPositionOption(ApiModel):
|
||||
@@ -560,6 +569,7 @@ class PublicPaymentOption(ApiModel):
|
||||
id: uuid.UUID
|
||||
label: str
|
||||
amount: float
|
||||
requires_mecenat_info: bool
|
||||
|
||||
|
||||
class PublicFormConfig(ApiModel):
|
||||
@@ -582,14 +592,46 @@ class PublicFormConfig(ApiModel):
|
||||
|
||||
|
||||
class FormSubmissionResult(ApiModel):
|
||||
volunteer_id: uuid.UUID
|
||||
# Lien de paiement hébergé SumUp, si un checkout a pu être créé (voir
|
||||
# `app/api/forms.py:submit_public_form`) — `None` si le bénévole n'a
|
||||
# choisi aucun type de paiement, si SumUp n'est pas configuré, ou en cas
|
||||
# d'échec de l'appel API (best effort, l'inscription reste valide).
|
||||
"""Trois formes possibles, selon le choix de paiement (voir
|
||||
`app/api/forms.py:submit_public_form`) :
|
||||
- aucun paiement en ligne choisi (case désactivée, ou « je paierai sur
|
||||
place ») : `volunteer_id` seul, le bénévole est déjà en base ;
|
||||
- paiement en ligne, type « mécénat » : `pending_token` +
|
||||
`requires_mecenat_info=True`, rien en base encore — direction l'étape
|
||||
d'adresse (`submit_mecenat_info`) ;
|
||||
- paiement en ligne, type simple : `pending_token` + `sumup_checkout_url`,
|
||||
direction la page de paiement SumUp. Toujours rien en base tant que le
|
||||
paiement n'est pas confirmé (voir `PendingFormSubmission`)."""
|
||||
|
||||
volunteer_id: uuid.UUID | None = None
|
||||
pending_token: uuid.UUID | None = None
|
||||
requires_mecenat_info: bool = False
|
||||
sumup_checkout_url: str | None = None
|
||||
|
||||
|
||||
class MecenatInfoSubmit(ApiModel):
|
||||
"""Étape d'adresse postale pour un type de paiement « mécénat »
|
||||
(`POST .../form/public/pending/{token}/mecenat-info`) — tous obligatoires,
|
||||
nécessaires à l'édition d'un reçu fiscal (cerfa)."""
|
||||
|
||||
address: str
|
||||
city: str
|
||||
postal_code: str
|
||||
country: str
|
||||
|
||||
|
||||
class PendingSubmissionStatus(ApiModel):
|
||||
"""Réponse de `GET .../form/public/pending/{token}` — interrogée en
|
||||
direct par la page de confirmation jusqu'à ce que le statut ne soit plus
|
||||
`PENDING`. Relit toujours l'état réel auprès de SumUp (jamais seulement
|
||||
la dernière valeur connue), comme le webhook."""
|
||||
|
||||
status: FeePaymentStatus
|
||||
label: str
|
||||
amount: float
|
||||
volunteer_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class SumUpWebhookPayload(ApiModel):
|
||||
"""Corps envoyé par SumUp à `return_url` — voir la doc SumUp sur les
|
||||
webhooks. Le seul événement existant à ce jour."""
|
||||
|
||||
@@ -29,6 +29,7 @@ from app.auth.deps import (
|
||||
require_staff,
|
||||
)
|
||||
from app.db import get_session
|
||||
from app.exporting import generate_volunteers_xlsx
|
||||
from app.models import (
|
||||
Account,
|
||||
Artist,
|
||||
@@ -169,6 +170,56 @@ def list_volunteers(
|
||||
return [_read(session, v) for v in volunteers]
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_volunteers(
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
"""Export Excel de la liste des bénévoles — vœux (postes, artistes,
|
||||
compagnons) résolus en noms lisibles, pas en UUID. Déclaré avant
|
||||
`/{volunteer_id}` : un chemin statique doit être enregistré avant le
|
||||
chemin dynamique qui le suivrait sinon FastAPI essaierait de parser
|
||||
« export » comme un UUID."""
|
||||
volunteers_orm = list(
|
||||
session.exec(
|
||||
select(Volunteer)
|
||||
.where(Volunteer.edition_id == edition.id)
|
||||
.order_by(Volunteer.last_name, Volunteer.first_name)
|
||||
)
|
||||
)
|
||||
volunteers = [_read(session, v) for v in volunteers_orm]
|
||||
position_names = {
|
||||
p.id: p.name
|
||||
for p in session.exec(
|
||||
select(Position).where(Position.edition_id == edition.id)
|
||||
)
|
||||
}
|
||||
artist_names = {
|
||||
a.id: a.name
|
||||
for a in session.exec(select(Artist).where(Artist.edition_id == edition.id))
|
||||
}
|
||||
volunteer_names = {
|
||||
v.id: f"{v.first_name} {v.last_name}" for v in volunteers_orm
|
||||
}
|
||||
|
||||
content = generate_volunteers_xlsx(
|
||||
volunteers,
|
||||
position_names=position_names,
|
||||
artist_names=artist_names,
|
||||
volunteer_names=volunteer_names,
|
||||
)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
),
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="benevoles-{edition.year}.xlsx"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{volunteer_id}", response_model=VolunteerRead)
|
||||
def get_volunteer(
|
||||
volunteer_id: uuid.UUID,
|
||||
|
||||
Reference in New Issue
Block a user