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