Inscription en deux temps, emails de confirmation et export Excel des bénévoles
ci-cd / build-backend (push) Successful in 30s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s

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:
AdamThomas
2026-09-30 07:41:58 +02:00
co-authored by Claude Sonnet 5
parent 68d42ee303
commit a2b748566b
34 changed files with 2318 additions and 291 deletions
+4 -2
View File
@@ -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
View File
@@ -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)
+2 -2
View File
@@ -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,
+47 -5
View File
@@ -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."""
+51
View File
@@ -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,
+16 -2
View File
@@ -4,7 +4,21 @@
(sans destinataire ni corps — RGPD). Voir CLAUDE.md.
"""
from app.email.branding import edition_logo_url
from app.email.sender import send, send_many
from app.email.templates import render_magic_link, render_planning
from app.email.templates import (
render_magic_link,
render_planning,
render_registration_confirmation,
render_registration_paid_confirmation,
)
__all__ = ["render_magic_link", "render_planning", "send", "send_many"]
__all__ = [
"edition_logo_url",
"render_magic_link",
"render_planning",
"render_registration_confirmation",
"render_registration_paid_confirmation",
"send",
"send_many",
]
+20
View File
@@ -0,0 +1,20 @@
"""URL publique du logo d'une édition, pour l'en-tête des emails (voir
`app/email/html.py`) — `None` tant qu'aucun logo n'a été importé sur le
formulaire public (`FormTemplate.logo`, `app/api/forms.py:set_logo`)."""
import uuid
from sqlmodel import Session, select
from app.config import get_settings
from app.models import FormTemplate
def edition_logo_url(session: Session, edition_id: uuid.UUID) -> str | None:
template = session.exec(
select(FormTemplate).where(FormTemplate.edition_id == edition_id)
).first()
if template is None or template.logo is None:
return None
base = get_settings().public_base_url.rstrip("/")
return f"{base}/api/editions/{edition_id}/form/logo"
+76
View File
@@ -0,0 +1,76 @@
"""Version HTML des emails — même thème que l'appli (couleurs/typographie de
`frontend/tailwind.config.js`), logo de l'édition en en-tête si défini.
Chaque email reste envoyé en `multipart/alternative` (texte + HTML, voir
`app/email/sender.py:build_message`) : le texte brut reste la source de
vérité (accessibilité, clients sans HTML, RGPD — rien de plus dans le HTML
que dans le texte déjà journalisable). Pas de CSS externe ni de police web
(rarement chargées par les clients mail) : mise en page par tableau, styles
en ligne, polices système en repli du style « Archivo »/« DM Mono »."""
import html as _html
_INK = "#14110E"
_CREAM = "#F5EFE1"
_PAPER = "#FFFDF7"
_YELLOW = "#FFD23F"
_FONT_DISPLAY = "Arial, Helvetica, sans-serif"
_FONT_MONO = "'Courier New', Courier, monospace"
def _paragraphs(body: str) -> str:
"""`\n\n` sépare les paragraphes, un simple `\n` reste un retour à la
ligne — même convention que les modèles texte (`app/email/templates.py`)."""
blocks = body.strip().split("\n\n")
return "".join(
f'<p style="margin:0 0 14px;">{_html.escape(block).replace(chr(10), "<br>")}</p>'
for block in blocks
if block.strip()
)
def render_html(*, subject: str, body: str, logo_url: str | None) -> str:
"""`logo_url` : voir `app/email/branding.py:edition_logo_url` — `None`
tant que l'édition n'a pas importé de logo, l'en-tête retombe alors sur
un simple bandeau texte (le sujet, en capitales)."""
if logo_url:
header = (
f'<img src="{_html.escape(logo_url)}" alt="" width="160" '
'style="max-height:64px;width:auto;display:block;margin:0 auto;'
'border:0;">'
)
else:
header = (
f'<span style="font-family:{_FONT_DISPLAY};font-weight:800;'
f"text-transform:uppercase;letter-spacing:1px;color:{_YELLOW};"
f'font-size:14px;">{_html.escape(subject)}</span>'
)
return f"""<!doctype html>
<html lang="fr">
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:24px 16px;background:{_CREAM};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<table role="presentation" width="520" cellpadding="0" cellspacing="0"
style="max-width:520px;width:100%;">
<tr>
<td style="background:{_INK};padding:18px;text-align:center;
border:3px solid {_INK};">
{header}
</td>
</tr>
<tr>
<td style="background:{_PAPER};border:3px solid {_INK};
border-top:none;padding:22px;font-size:13px;
line-height:1.6;color:{_INK};font-family:{_FONT_MONO};">
{_paragraphs(body)}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
+19 -6
View File
@@ -12,6 +12,7 @@ from collections.abc import Iterable
from email.message import EmailMessage
from app.config import Settings, get_settings
from app.email.html import render_html
logger = logging.getLogger("app.email")
@@ -32,12 +33,21 @@ def _smtp_error_summary(exc: smtplib.SMTPException) -> str:
return exc.__class__.__name__
def build_message(to: str, subject: str, body: str) -> EmailMessage:
def build_message(
to: str, subject: str, body: str, *, logo_url: str | None = None
) -> EmailMessage:
"""`logo_url` (voir `app/email/branding.py:edition_logo_url`) : ajoute une
version HTML au thème de l'appli (`app/email/html.py`) en alternative au
texte brut — le texte reste la version de référence, envoyée telle
quelle si aucun client ne préfère le HTML."""
message = EmailMessage()
message["From"] = get_settings().smtp_from
message["To"] = to
message["Subject"] = subject
message.set_content(body)
message.add_alternative(
render_html(subject=subject, body=body, logo_url=logo_url), subtype="html"
)
return message
@@ -111,10 +121,13 @@ def deliver_many(messages: list[EmailMessage]) -> None:
)
def send(to: str, subject: str, body: str) -> None:
deliver_many([build_message(to, subject, body)])
def send(to: str, subject: str, body: str, *, logo_url: str | None = None) -> None:
deliver_many([build_message(to, subject, body, logo_url=logo_url)])
def send_many(items: Iterable[tuple[str, str, str]]) -> None:
"""`items` = itérable de `(destinataire, sujet, corps)`."""
deliver_many([build_message(*item) for item in items])
def send_many(
items: Iterable[tuple[str, str, str]], *, logo_url: str | None = None
) -> None:
"""`items` = itérable de `(destinataire, sujet, corps)` — même logo pour
tous (un seul appel par édition, voir `app/api/notifications.py`)."""
deliver_many([build_message(*item, logo_url=logo_url) for item in items])
+29
View File
@@ -21,6 +21,35 @@ def render_magic_link(*, link: str, minutes: int) -> tuple[str, str]:
)
def render_registration_confirmation(*, first_name: str) -> tuple[str, str]:
"""Inscription prise en compte, sans paiement en ligne (case désactivée,
ou « je paierai sur place ») — voir `render_registration_paid_confirmation`
pour le cas d'un paiement confirmé."""
return (
t("email.registration_confirmation.subject"),
t("email.registration_confirmation.body", prenom=first_name),
)
def render_registration_paid_confirmation(
*, first_name: str, label: str, amount: float
) -> tuple[str, str]:
"""Inscription prise en compte **et** paiement en ligne confirmé — envoyé
une fois seulement, au moment de la promotion (`app/api/forms.py:
_promote_pending`). Sert de reçu (montant, type de paiement) en
l'absence de confirmation documentée que l'API SumUp Hosted Checkout en
envoie un elle-même."""
return (
t("email.registration_confirmation_paid.subject"),
t(
"email.registration_confirmation_paid.body",
prenom=first_name,
label=label,
montant=amount,
),
)
def _fill(template: str, values: dict[str, object]) -> str:
out = template
for key in _PLANNING_PLACEHOLDERS:
+3
View File
@@ -0,0 +1,3 @@
from app.exporting.xlsx import generate_volunteers_xlsx
__all__ = ["generate_volunteers_xlsx"]
+112
View File
@@ -0,0 +1,112 @@
"""Export Excel de la liste des bénévoles d'une édition — voir
`app/api/volunteers.py:export_volunteers`. Même bibliothèque que l'import
(`openpyxl`, `app/importing/parser.py`), en écriture cette fois.
Texte en dur (en-têtes de colonnes, libellés) : comme `app/sheets/pdf.py` et
`app/badges/pdf.py`, un document généré n'est pas un texte d'interface —
pas de `app.i18n.t()` ici, contrairement aux réponses de l'API."""
import io
import uuid
from openpyxl import Workbook
from openpyxl.styles import Font
from app.api.schemas import VolunteerRead
_HEADER = [
"Prénom",
"Nom",
"Email",
"Téléphone",
"Disponible",
"Postes souhaités",
"Artistes souhaités",
"Compagnons souhaités",
"Note de compagnon",
"Cotisation choisie",
"Montant (€)",
"Statut du paiement",
"Payée le",
"Mécénat — adresse",
"Mécénat — ville",
"Mécénat — code postal",
"Mécénat — pays",
"Commentaire",
"Inscrit le",
]
_FEE_STATUS_LABELS = {
"PENDING": "En attente",
"PAID": "Payée",
"FAILED": "Échouée",
"EXPIRED": "Expirée",
}
def _yes_no(value: bool) -> str:
return "Oui" if value else "Non"
def _join_names(ids: list[uuid.UUID], names: dict[uuid.UUID, str]) -> str:
return ", ".join(names[i] for i in ids if i in names)
def _fmt_dt(value) -> str:
return value.strftime("%d/%m/%Y %H:%M") if value else ""
def generate_volunteers_xlsx(
volunteers: list[VolunteerRead],
*,
position_names: dict[uuid.UUID, str],
artist_names: dict[uuid.UUID, str],
volunteer_names: dict[uuid.UUID, str],
) -> bytes:
"""`position_names`/`artist_names`/`volunteer_names` : cartes id → nom
construites une fois par l'appelant (pas de requête par bénévole) —
voir `app/api/volunteers.py:export_volunteers`."""
workbook = Workbook()
sheet = workbook.active
sheet.title = "Bénévoles"
sheet.append(_HEADER)
for cell in sheet[1]:
cell.font = Font(bold=True)
for v in volunteers:
sheet.append(
[
v.first_name,
v.last_name,
v.email or "",
v.phone or "",
_yes_no(v.self_declared_available),
_join_names(v.wished_position_ids, position_names),
_join_names(v.artist_ids, artist_names),
_join_names(v.wished_companion_ids, volunteer_names),
v.companion_wish_note or "",
v.fee_payment_label or "",
v.fee_amount if v.fee_amount is not None else "",
_FEE_STATUS_LABELS.get(v.fee_payment_status, ""),
_fmt_dt(v.fee_paid_at),
v.mecenat_address or "",
v.mecenat_city or "",
v.mecenat_postal_code or "",
v.mecenat_country or "",
v.comment or "",
_fmt_dt(v.created_at),
]
)
for column_cells in sheet.columns:
length = max(
(len(str(c.value)) for c in column_cells if c.value is not None),
default=8,
)
sheet.column_dimensions[column_cells[0].column_letter].width = min(
max(length + 2, 10), 40
)
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
+6
View File
@@ -49,6 +49,10 @@
"notification.no_recipients": "Aucun bénévole ciblé.",
"email.magic_link.subject": "Ton lien de connexion",
"email.magic_link.body": "Bonjour,\n\nVoici ton lien de connexion, valable {minutes} minutes :\n\n{link}\n\nSi tu n'es pas à l'origine de cette demande, ignore simplement cet email.",
"email.registration_confirmation.subject": "Ton inscription bénévole est confirmée",
"email.registration_confirmation.body": "Bonjour {prenom},\n\nTon inscription comme bénévole a bien été enregistrée. La coordination du festival prendra contact avec toi si besoin.\n\nÀ bientôt !",
"email.registration_confirmation_paid.subject": "Ton inscription bénévole est confirmée — paiement reçu",
"email.registration_confirmation_paid.body": "Bonjour {prenom},\n\nTon inscription comme bénévole a bien été enregistrée, et nous confirmons la réception de ton paiement :\n\n{label} — {montant} €\n\nLa coordination du festival prendra contact avec toi si besoin.\n\nÀ bientôt !",
"email.planning.default_subject": "Ton planning bénévole",
"email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !",
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
@@ -85,6 +89,8 @@
"form.invalid_sumup_amount": "Le montant doit être supérieur à 0.",
"form.unknown_payment_option": "Ce type de paiement n'existe pas pour cette édition.",
"form.duplicate_payment_option_label": "Un type de paiement porte déjà ce libellé.",
"form.pending_already_paid": "Cette inscription est déjà payée.",
"form.pending_not_awaiting_info": "Cette inscription n'attend pas ces informations.",
"form.too_many_position_wishes": "Deux vœux de poste au maximum.",
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.",
"position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.",
@@ -0,0 +1,106 @@
"""Inscription en deux temps + mécénat (cerfa)
Revision ID: 0018_pending_submissions_mecenat
Revises: 0017_form_payment_options
Create Date: 2026-09-29
Un bénévole qui choisit un paiement en ligne n'est plus créé en base tant que
le paiement n'est pas confirmé : `pending_form_submission` retient le
formulaire (et l'état du checkout SumUp) jusqu'à la promotion en `Volunteer`
(`app/api/forms.py:_promote_pending`) ou jusqu'à purge si jamais payé.
`form_payment_option.requires_mecenat_info` distingue un type de paiement
« mécénat » (étape d'adresse postale avant paiement, cerfa) ; les coordonnées
correspondantes vivent sur `volunteer.mecenat_*` une fois le bénévole promu.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0018_pending_submissions_mecenat"
down_revision: str | None = "0017_form_payment_options"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_FEE_STATUS_ENUM = sa.Enum(
"PENDING", "PAID", "FAILED", "EXPIRED",
name="feepaymentstatus", native_enum=False,
)
def upgrade() -> None:
op.add_column(
"form_payment_option",
sa.Column(
"requires_mecenat_info", sa.Boolean(), server_default="false", nullable=False
),
)
op.add_column("volunteer", sa.Column("mecenat_address", sa.String(), nullable=True))
op.add_column("volunteer", sa.Column("mecenat_city", sa.String(), nullable=True))
op.add_column("volunteer", sa.Column("mecenat_postal_code", sa.String(), nullable=True))
op.add_column("volunteer", sa.Column("mecenat_country", sa.String(), nullable=True))
op.create_table(
"pending_form_submission",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column("first_name", sa.String(), nullable=True),
sa.Column("last_name", sa.String(), nullable=True),
sa.Column("email", sa.String(), nullable=True),
sa.Column("phone", sa.String(), nullable=True),
sa.Column("comment", sa.String(), nullable=True),
sa.Column("companion_wish_note", sa.String(), nullable=True),
sa.Column("available", sa.Boolean(), server_default="false", nullable=False),
sa.Column("position_wish_ids_json", sa.String(), nullable=True),
sa.Column("artist_wish_ids_json", sa.String(), nullable=True),
sa.Column("photo", sa.LargeBinary(), nullable=True),
sa.Column("photo_content_type", sa.String(), nullable=True),
sa.Column("payment_label", sa.String(), nullable=False),
sa.Column("payment_amount", sa.Float(), nullable=False),
sa.Column(
"requires_mecenat_info", sa.Boolean(), server_default="false", nullable=False
),
sa.Column("mecenat_address", sa.String(), nullable=True),
sa.Column("mecenat_city", sa.String(), nullable=True),
sa.Column("mecenat_postal_code", sa.String(), nullable=True),
sa.Column("mecenat_country", sa.String(), nullable=True),
sa.Column("checkout_id", sa.String(), nullable=True),
sa.Column("checkout_status", _FEE_STATUS_ENUM, nullable=True),
sa.Column("promoted_volunteer_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["promoted_volunteer_id"], ["volunteer.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_pending_form_submission_edition_id",
"pending_form_submission",
["edition_id"],
)
op.create_index(
"ix_pending_form_submission_checkout_id",
"pending_form_submission",
["checkout_id"],
)
def downgrade() -> None:
op.drop_index(
"ix_pending_form_submission_checkout_id", table_name="pending_form_submission"
)
op.drop_index(
"ix_pending_form_submission_edition_id", table_name="pending_form_submission"
)
op.drop_table("pending_form_submission")
op.drop_column("volunteer", "mecenat_country")
op.drop_column("volunteer", "mecenat_postal_code")
op.drop_column("volunteer", "mecenat_city")
op.drop_column("volunteer", "mecenat_address")
op.drop_column("form_payment_option", "requires_mecenat_info")
+2
View File
@@ -18,6 +18,7 @@ from app.models.domain import (
FormPaymentOption,
FormTemplate,
MagicLinkToken,
PendingFormSubmission,
Position,
Shift,
Volunteer,
@@ -53,6 +54,7 @@ __all__ = [
"FormPaymentOption",
"FormTemplate",
"MagicLinkToken",
"PendingFormSubmission",
"Position",
"PositionCategory",
"Role",
+96
View File
@@ -211,6 +211,15 @@ class Volunteer(TimestampMixin, table=True):
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
)
fee_paid_at: dt.datetime | None = None
# Coordonnées collectées uniquement pour un type de paiement « mécénat »
# (`FormPaymentOption.requires_mecenat_info`), avant le paiement (voir
# `PendingFormSubmission` et `app/api/forms.py:submit_mecenat_info`) —
# conservées pour générer un reçu fiscal (cerfa) ultérieurement. `None`
# pour un bénévole n'ayant pas payé via une option de mécénat.
mecenat_address: str | None = None
mecenat_city: str | None = None
mecenat_postal_code: str | None = None
mecenat_country: str | None = None
wished_artists: list[Artist] = Relationship(
back_populates="fans",
@@ -545,6 +554,93 @@ class FormPaymentOption(TimestampMixin, table=True):
default=0,
sa_column=Column(Integer, nullable=False, server_default="0"),
)
# Type « mécénat » : avant le paiement, le bénévole passe par une étape
# supplémentaire (adresse postale) pour permettre l'édition d'un reçu
# fiscal (cerfa) — voir `Volunteer.mecenat_*` et
# `app/api/forms.py:submit_mecenat_info`.
requires_mecenat_info: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
class PendingFormSubmission(TimestampMixin, table=True):
"""Inscription en attente de paiement en ligne (voir CLAUDE.md § 2,
`Paiement de la cotisation`). Créée à la place d'un `Volunteer` quand le
bénévole choisit un type de paiement en ligne (`FormPaymentOption`) sur
le formulaire public — le `Volunteer` n'est créé qu'à la confirmation du
paiement (`app/api/forms.py:_promote_pending`), pour ne jamais compter en
base quelqu'un qui a rempli le formulaire sans aller au bout du
paiement.
Reprend les champs du formulaire (identiques à `Volunteer`, en plus
volatil) ; `position_wish_ids_json`/`artist_wish_ids_json` : listes
JSON stringifiées (pas d'association SQL pour une ligne éphémère), leurs
UUID sont résolus (silencieusement filtrés si un poste/artiste a
entretemps disparu — le paiement ne doit jamais échouer pour ça) au
moment de la promotion en `Volunteer`.
`id` sert aussi de jeton public (`GET .../form/public/pending/{id}`,
aucune authentification) : un UUID v4 est déjà pratiquement impossible
à deviner, comme un identifiant de session de paiement chez la plupart
des prestataires.
`payment_label`/`payment_amount`/`requires_mecenat_info` sont une copie
figée de l'option choisie (même principe que `Volunteer.fee_payment_label`)
— indépendante d'une modification de `FormPaymentOption` pendant que le
paiement est en cours. `checkout_id`/`checkout_status` suivent le
checkout SumUp *courant* : relancés (nouvel identifiant) à chaque
nouvelle tentative après un échec/une expiration
(`app/api/forms.py:retry_pending_payment`), sans jamais perdre les
informations déjà saisies.
`promoted_volunteer_id` : posé au moment du paiement confirmé (`PAID`) —
le `Volunteer` créé. Les champs nominatifs sont alors vidés (RGPD :
la même information vit désormais uniquement sur `Volunteer`), seuls
les champs de suivi du paiement restent. Une ligne jamais promue est
purgée après un délai (voir `_prune_stale_pending`)."""
__tablename__ = "pending_form_submission"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE"
)
first_name: str | None = None
last_name: str | None = None
email: str | None = None
phone: str | None = None
comment: str | None = None
companion_wish_note: str | None = None
available: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
position_wish_ids_json: str | None = None
artist_wish_ids_json: str | None = None
photo: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
photo_content_type: str | None = None
payment_label: str
payment_amount: float
requires_mecenat_info: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
mecenat_address: str | None = None
mecenat_city: str | None = None
mecenat_postal_code: str | None = None
mecenat_country: str | None = None
checkout_id: str | None = Field(default=None, index=True)
checkout_status: FeePaymentStatus | None = Field(
default=None,
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
)
promoted_volunteer_id: uuid.UUID | None = Field(
default=None, foreign_key="volunteer.id", ondelete="SET NULL"
)
class FormField(SQLModel, table=True):
+10
View File
@@ -0,0 +1,10 @@
"""Aide de test : les messages capturés par `outbox` (conftest.py) sont
`multipart/alternative` depuis l'ajout d'une version HTML (voir
`app/email/sender.py:build_message`) — `.get_content()` ne suffit plus à lui
seul, il faut préciser quelle partie lire."""
from email.message import EmailMessage
def plain_text(message: EmailMessage) -> str:
return message.get_body(preferencelist=("plain",)).get_content()
+5 -4
View File
@@ -2,6 +2,7 @@ from sqlmodel import select
from app.models import Assignment, MagicLinkToken, Role
from tests.factories import dt_at, make_position, make_shift, make_volunteer
from tests.mail import plain_text
def _url(edition):
@@ -38,17 +39,17 @@ def test_one_email_per_volunteer_with_an_address(client, edition, session, outbo
by_to = {m["To"]: m for m in outbox}
assert by_to["ana@e.fr"]["Subject"] == "Planning de Ana"
ana_body = by_to["ana@e.fr"].get_content()
ana_body = plain_text(by_to["ana@e.fr"])
assert "Tu as 2 créneau(x), 7.0 h." in ana_body
assert "Bar" in ana_body
assert "Tu as 0 créneau(x), 0.0 h." in by_to["bob@e.fr"].get_content()
assert "Tu as 0 créneau(x), 0.0 h." in plain_text(by_to["bob@e.fr"])
def test_default_template_is_used_when_body_omitted(client, edition, session, outbox):
make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
resp = client.post(_url(edition), json={})
assert resp.status_code == 200
assert outbox[0].get_content().startswith("Bonjour Ana,")
assert plain_text(outbox[0]).startswith("Bonjour Ana,")
def test_magic_link_is_embedded_when_placeholder_present(
@@ -61,7 +62,7 @@ def test_magic_link_is_embedded_when_placeholder_present(
_url(edition), json={"body": "Connecte-toi : {lien_connexion}"}
)
assert resp.status_code == 200
assert "/connexion?token=" in outbox[0].get_content()
assert "/connexion?token=" in plain_text(outbox[0])
assert len(session.exec(select(MagicLinkToken)).all()) == 1
+41
View File
@@ -1,6 +1,8 @@
import datetime as dt
import io
import uuid
from openpyxl import load_workbook
from sqlmodel import select
from app.models import Assignment, Availability, Role, Volunteer
@@ -389,3 +391,42 @@ def test_lead_can_read_any_volunteers_planning(
client = as_account(lead_account)
assert client.get(_url(edition, f"/{other.id}/planning")).status_code == 200
def test_export_volunteers_as_excel(client, edition, session):
position = make_position(session, edition, name="Bar central")
artist = make_artist(session, edition, name="Chilla")
volunteer = make_volunteer(
session, edition, first_name="Alex", last_name="Martin", email="alex@ex.fr"
)
client.patch(
_url(edition, f"/{volunteer.id}"),
json={
"wished_position_ids": [str(position.id)],
"artist_ids": [str(artist.id)],
},
)
resp = client.get(_url(edition, "/export"))
assert resp.status_code == 200
assert resp.headers["content-type"] == (
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
assert f"benevoles-{edition.year}.xlsx" in resp.headers["content-disposition"]
workbook = load_workbook(io.BytesIO(resp.content))
sheet = workbook.active
rows = list(sheet.iter_rows(values_only=True))
assert rows[0][0] == "Prénom"
row = rows[1]
assert row[0] == "Alex"
assert row[1] == "Martin"
assert row[2] == "alex@ex.fr"
assert row[5] == "Bar central"
assert row[6] == "Chilla"
def test_export_volunteers_is_coordinator_only(edition, make_account, as_account):
lead = make_account(Role.LEAD, email="lead@e.fr")
resp = as_account(lead).get(_url(edition, "/export"))
assert resp.status_code == 403
+2 -1
View File
@@ -4,6 +4,7 @@ from sqlmodel import select
from app.auth.tokens import hash_token
from app.models import MagicLinkToken, Role, local_now
from tests.mail import plain_text
def _url(edition, suffix):
@@ -31,7 +32,7 @@ def test_full_flow_magic_link_then_jwt_then_me(
assert resp.status_code == 202
assert len(outbox) == 1
assert outbox[0]["To"] == "ref@example.fr"
raw_token = outbox[0].get_content().split("?token=")[1].split()[0].strip()
raw_token = plain_text(outbox[0]).split("?token=")[1].split()[0].strip()
verified = raw_client.post(
_url(edition, "verify"), json={"token": raw_token}
+310 -24
View File
@@ -4,11 +4,13 @@ soumission publique (aucune authentification)."""
import base64
import datetime as dt
import io
import uuid
from app.auth.rate_limit import form_submission_limiter
from app.models import Role
from app.models import PendingFormSubmission, Role, local_now
from app.sumup import SumUpCheckout
from tests.factories import make_artist, make_position
from tests.mail import plain_text
_MERCHANT = "MXXXXX"
_API_KEY = "sup_sk_secret"
@@ -27,6 +29,24 @@ def _configure_sumup(client, edition, amount=12.5, label="Cotisation classique")
)
return resp.json()["id"]
def _pay(raw_client, edition, monkeypatch, checkout_id="co_1", status_after="PAID"):
"""Monte le checkout ensuite renvoyé par `get_checkout` sur `status_after`
(`PAID`/`FAILED`/`EXPIRED`) — les tests posent d'abord `create_checkout`
et soumettent eux-mêmes, cette aide ne fait que la partie webhook."""
monkeypatch.setattr(
"app.api.forms.get_checkout",
lambda **kw: SumUpCheckout(id=checkout_id, status=status_after, hosted_checkout_url=None),
)
return raw_client.post(
_form_url(edition, "/public/sumup-webhook"),
json={"event_type": "CHECKOUT_STATUS_CHANGED", "id": checkout_id},
)
def _pending_status(raw_client, edition, token):
return raw_client.get(_form_url(edition, f"/public/pending/{token}")).json()
# PNG 1×1 valide (transparent), le plus petit possible.
_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
@@ -202,7 +222,9 @@ def test_deleting_a_payment_option_keeps_the_volunteers_snapshot(
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
)
volunteer_id = resp.json()["volunteer_id"]
token = resp.json()["pending_token"]
_pay(raw_client, edition, monkeypatch, checkout_id="co_1")
volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"]
client.delete(_form_url(edition, f"/payment-options/{option_id}"))
@@ -294,11 +316,12 @@ def test_submitting_creates_a_volunteer(raw_client, client, edition, session):
_form_url(edition, "/fields"),
json={"fields": ["EMAIL", "PHONE", "AVAILABLE", "WILLING_TO_PAY_FEE"]},
)
# Pas de payment_option_id : « je paierai sur place » — pas de paiement
# en ligne, le bénévole est créé tout de suite.
resp = _submit(
raw_client, edition,
first_name="alex", last_name="martin", hcaptcha_token="x",
email="alex@example.org", phone="0600000000",
available="true", willing_to_pay_fee="true",
email="alex@example.org", phone="0600000000", available="true",
)
assert resp.status_code == 201
volunteer_id = resp.json()["volunteer_id"]
@@ -309,12 +332,32 @@ def test_submitting_creates_a_volunteer(raw_client, client, edition, session):
assert fiche["email"] == "alex@example.org"
assert fiche["self_declared_available"] is True
assert fiche["willing_to_pay_fee"] is True
assert fiche["fee_payment_status"] is None
# Le bénévole créé apparaît dans la liste de la coordination.
volunteers = client.get(f"/editions/{edition.id}/volunteers").json()
assert any(v["id"] == volunteer_id for v in volunteers)
def test_submitting_sends_a_registration_confirmation_email(raw_client, client, edition, outbox):
client.put(_form_url(edition, "/fields"), json={"fields": ["EMAIL"]})
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
email="alex@example.org",
)
assert resp.status_code == 201
assert len(outbox) == 1
assert outbox[0]["To"] == "alex@example.org"
assert "Alex" in plain_text(outbox[0])
def test_submitting_without_an_email_sends_no_confirmation(raw_client, edition, outbox):
resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x")
assert resp.status_code == 201
assert outbox == []
def test_submitting_ignores_fields_not_enabled(raw_client, client, edition):
# Aucun champ activé : email/téléphone envoyés quand même, ignorés.
resp = _submit(
@@ -329,6 +372,35 @@ def test_submitting_ignores_fields_not_enabled(raw_client, client, edition):
assert fiche["phone"] is None
def test_submitting_without_an_email_is_rejected_when_email_enabled(raw_client, client, edition):
client.put(_form_url(edition, "/fields"), json={"fields": ["EMAIL"]})
resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x")
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "import.row.missing_required_field"
def test_submitting_without_a_phone_is_rejected_when_phone_enabled(raw_client, client, edition):
client.put(_form_url(edition, "/fields"), json={"fields": ["PHONE"]})
resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x")
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "import.row.missing_required_field"
def test_submitting_without_checking_available_is_rejected_when_enabled(
raw_client, client, edition
):
client.put(_form_url(edition, "/fields"), json={"fields": ["AVAILABLE"]})
resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x")
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "import.row.missing_required_field"
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x", available="true",
)
assert resp.status_code == 201
def test_submitting_resolves_position_and_artist_wishes(
raw_client, client, edition, session
):
@@ -482,10 +554,11 @@ def test_submitting_with_a_rejected_captcha(raw_client, edition, monkeypatch):
assert resp.json()["detail"]["code"] == "form.captcha_failed"
def test_submitting_creates_a_sumup_checkout_when_a_payment_option_is_chosen(
def test_choosing_an_online_option_does_not_create_the_volunteer_yet(
raw_client, client, edition, monkeypatch
):
option_id = _configure_sumup(client, edition)
before = len(client.get(f"/editions/{edition.id}/volunteers").json())
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(
@@ -499,16 +572,86 @@ def test_submitting_creates_a_sumup_checkout_when_a_payment_option_is_chosen(
)
assert resp.status_code == 201
body = resp.json()
assert body["volunteer_id"] is None
assert body["pending_token"] is not None
assert body["sumup_checkout_url"] == "https://checkout.sumup.com/pay/co_123"
fiche = client.get(f"/editions/{edition.id}/volunteers/{body['volunteer_id']}").json()
assert fiche["fee_payment_status"] == "PENDING"
# Toujours rien en base tant que le paiement n'est pas confirmé.
after = len(client.get(f"/editions/{edition.id}/volunteers").json())
assert after == before
status = _pending_status(raw_client, edition, body["pending_token"])
assert status["status"] == "PENDING"
assert status["label"] == "Cotisation classique"
assert status["amount"] == 12.5
def test_paying_promotes_the_pending_submission_to_a_volunteer(
raw_client, client, edition, monkeypatch
):
option_id = _configure_sumup(client, edition)
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_123", status="PENDING", hosted_checkout_url="https://x"),
)
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
)
token = resp.json()["pending_token"]
webhook_resp = _pay(raw_client, edition, monkeypatch, checkout_id="co_123")
assert webhook_resp.status_code == 200
status = _pending_status(raw_client, edition, token)
assert status["status"] == "PAID"
volunteer_id = status["volunteer_id"]
assert volunteer_id is not None
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
assert fiche["first_name"] == "Alex"
assert fiche["fee_payment_status"] == "PAID"
assert fiche["fee_payment_label"] == "Cotisation classique"
assert fiche["fee_amount"] == 12.5
assert fiche["fee_paid_at"] is not None
assert fiche["willing_to_pay_fee"] is True
def test_submitting_without_choosing_an_option_does_not_create_a_checkout(
def test_payment_confirmation_sends_an_email_on_promotion(
raw_client, client, edition, monkeypatch, outbox
):
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE", "EMAIL"]})
client.patch(
_form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY}
)
option_id = client.post(
_form_url(edition, "/payment-options"),
json={"label": "Cotisation classique", "amount": 12.5},
).json()["id"]
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
)
_submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
email="alex@example.org",
payment_option_id=option_id,
)
assert outbox == [] # pas encore payé : pas d'email à ce stade
_pay(raw_client, edition, monkeypatch, checkout_id="co_1")
assert len(outbox) == 1
assert outbox[0]["To"] == "alex@example.org"
body = plain_text(outbox[0])
assert "Alex" in body
assert "Cotisation classique" in body
assert "12.5" in body
def test_choosing_to_pay_on_site_creates_the_volunteer_immediately(
raw_client, client, edition, monkeypatch
):
_configure_sumup(client, edition)
@@ -518,9 +661,16 @@ def test_submitting_without_choosing_an_option_does_not_create_a_checkout(
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
)
assert resp.status_code == 201
assert resp.json()["sumup_checkout_url"] is None
body = resp.json()
assert body["volunteer_id"] is not None
assert body["pending_token"] is None
assert body["sumup_checkout_url"] is None
assert called == []
fiche = client.get(f"/editions/{edition.id}/volunteers/{body['volunteer_id']}").json()
assert fiche["willing_to_pay_fee"] is True
assert fiche["fee_payment_status"] is None
def test_submitting_with_an_unknown_payment_option_is_rejected(raw_client, client, edition):
_configure_sumup(client, edition)
@@ -533,7 +683,7 @@ def test_submitting_with_an_unknown_payment_option_is_rejected(raw_client, clien
assert resp.json()["detail"]["code"] == "form.unknown_payment_option"
def test_a_failed_checkout_call_does_not_block_registration(
def test_a_failed_checkout_call_can_be_retried_without_resubmitting(
raw_client, client, edition, monkeypatch
):
option_id = _configure_sumup(client, edition)
@@ -546,36 +696,151 @@ def test_a_failed_checkout_call_does_not_block_registration(
assert resp.status_code == 201
body = resp.json()
assert body["sumup_checkout_url"] is None
fiche = client.get(f"/editions/{edition.id}/volunteers/{body['volunteer_id']}").json()
assert fiche["fee_payment_status"] is None
token = body["pending_token"]
assert _pending_status(raw_client, edition, token)["status"] == "PENDING"
# SumUp remarche : la relance réutilise les infos déjà saisies.
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_retry", status="PENDING", hosted_checkout_url="https://x"),
)
retry_resp = raw_client.post(_form_url(edition, f"/public/pending/{token}/retry-payment"))
assert retry_resp.status_code == 200
assert retry_resp.json()["sumup_checkout_url"] == "https://x"
_pay(raw_client, edition, monkeypatch, checkout_id="co_retry")
assert _pending_status(raw_client, edition, token)["status"] == "PAID"
def test_sumup_webhook_updates_the_payment_status(raw_client, client, edition, monkeypatch):
def test_retrying_after_an_expired_checkout(raw_client, client, edition, monkeypatch):
option_id = _configure_sumup(client, edition)
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_123", status="PENDING", hosted_checkout_url="https://x"),
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
)
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
)
volunteer_id = resp.json()["volunteer_id"]
token = resp.json()["pending_token"]
_pay(raw_client, edition, monkeypatch, checkout_id="co_1", status_after="EXPIRED")
assert _pending_status(raw_client, edition, token)["status"] == "EXPIRED"
monkeypatch.setattr(
"app.api.forms.get_checkout",
lambda **kw: SumUpCheckout(id="co_123", status="PAID", hosted_checkout_url=None),
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_2", status="PENDING", hosted_checkout_url="https://y"),
)
webhook_resp = raw_client.post(
_form_url(edition, "/public/sumup-webhook"),
json={"event_type": "CHECKOUT_STATUS_CHANGED", "id": "co_123"},
)
assert webhook_resp.status_code == 200
retry_resp = raw_client.post(_form_url(edition, f"/public/pending/{token}/retry-payment"))
assert retry_resp.status_code == 200
def test_retrying_an_already_paid_pending_submission_is_rejected(
raw_client, client, edition, monkeypatch
):
option_id = _configure_sumup(client, edition)
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
)
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
)
token = resp.json()["pending_token"]
_pay(raw_client, edition, monkeypatch, checkout_id="co_1")
retry_resp = raw_client.post(_form_url(edition, f"/public/pending/{token}/retry-payment"))
assert retry_resp.status_code == 422
assert retry_resp.json()["detail"]["code"] == "form.pending_already_paid"
def test_an_unknown_pending_token_is_a_404(raw_client, edition):
resp = raw_client.get(
_form_url(edition, "/public/pending/00000000-0000-0000-0000-000000000000")
)
assert resp.status_code == 404
def test_choosing_a_mecenat_option_requires_the_address_step_first(
raw_client, client, edition, monkeypatch
):
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
client.patch(
_form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY}
)
option_id = client.post(
_form_url(edition, "/payment-options"),
json={"label": "Mécénat", "amount": 50, "requires_mecenat_info": True},
).json()["id"]
called = []
monkeypatch.setattr("app.api.forms.create_checkout", lambda **kw: called.append(kw))
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
)
assert resp.status_code == 201
body = resp.json()
assert body["requires_mecenat_info"] is True
assert body["sumup_checkout_url"] is None
assert called == [] # pas de checkout avant d'avoir l'adresse
token = body["pending_token"]
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_don", status="PENDING", hosted_checkout_url="https://don"),
)
info_resp = raw_client.post(
_form_url(edition, f"/public/pending/{token}/mecenat-info"),
json={
"address": "1 rue du Festival",
"city": "Rennes",
"postal_code": "35000",
"country": "France",
},
)
assert info_resp.status_code == 200
assert info_resp.json()["sumup_checkout_url"] == "https://don"
_pay(raw_client, edition, monkeypatch, checkout_id="co_don")
volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"]
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
assert fiche["fee_payment_status"] == "PAID"
assert fiche["fee_paid_at"] is not None
assert fiche["mecenat_address"] == "1 rue du Festival"
assert fiche["mecenat_city"] == "Rennes"
assert fiche["mecenat_postal_code"] == "35000"
assert fiche["mecenat_country"] == "France"
def test_mecenat_info_requires_every_field(raw_client, client, edition, monkeypatch):
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
client.patch(
_form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY}
)
option_id = client.post(
_form_url(edition, "/payment-options"),
json={"label": "Mécénat", "amount": 50, "requires_mecenat_info": True},
).json()["id"]
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
)
token = resp.json()["pending_token"]
info_resp = raw_client.post(
_form_url(edition, f"/public/pending/{token}/mecenat-info"),
json={
"address": "1 rue du Festival",
"city": "Rennes",
"postal_code": "",
"country": "France",
},
)
assert info_resp.status_code == 422
def test_sumup_webhook_ignores_an_unknown_checkout(raw_client, client, edition, monkeypatch):
@@ -591,6 +856,27 @@ def test_sumup_webhook_ignores_an_unknown_checkout(raw_client, client, edition,
assert resp.status_code == 200
def test_stale_pending_submissions_are_pruned(raw_client, client, edition, session, monkeypatch):
option_id = _configure_sumup(client, edition)
monkeypatch.setattr("app.api.forms.create_checkout", lambda **kw: None)
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
)
token = resp.json()["pending_token"]
pending = session.get(PendingFormSubmission, uuid.UUID(token))
pending.created_at = local_now() - dt.timedelta(hours=49)
session.add(pending)
session.commit()
# N'importe quelle nouvelle soumission déclenche le nettoyage paresseux.
_submit(raw_client, edition, first_name="Sam", last_name="Dupuis", hcaptcha_token="x")
assert raw_client.get(_form_url(edition, f"/public/pending/{token}")).status_code == 404
def test_submitting_is_rate_limited_per_edition(raw_client, edition):
for _ in range(form_submission_limiter.max_attempts):
form_submission_limiter.hit(str(edition.id))
+1
View File
@@ -32,6 +32,7 @@ EXPECTED_TABLES = {
"form_template",
"form_field",
"form_payment_option",
"pending_form_submission",
}