Logs de diagnostic et rapprochement SumUp ↔ bénévole
ci-cd / build-backend (push) Successful in 38s
ci-cd / build-frontend (push) Successful in 20s
ci-cd / deploy (push) Successful in 13s

- Une ligne de log par requête (route modèle, statut, durée, code d'erreur,
  compte), identifiant X-Request-ID repris dans chaque log et affiché à
  l'utilisateur sous une erreur, exceptions non gérées tracées (500 JSON)
- Journal d'accès d'uvicorn coupé : il exposait le jeton du formulaire public
- Logs métier : paiement SumUp, connexion, import, plannings, solveur
- Nom du bénévole dans la description du checkout SumUp
- Code et identifiant de transaction SumUp enregistrés au paiement
  (migration 0023), affichés sur la fiche et dans l'export Excel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-12 18:54:35 +02:00
co-authored by Claude Opus 5.5
parent d722a81a87
commit d0fa1e109c
27 changed files with 798 additions and 16 deletions
+35
View File
@@ -84,7 +84,17 @@ def login(
or account.password_hash is None
or not verify_password(payload.password, account.password_hash)
):
# Cause réelle dans les logs seulement — la réponse reste la même
# (pas de divulgation). Jamais l'email saisi.
if account is None:
reason = "aucun compte pour cet email"
elif account.password_hash is None:
reason = f"compte {account.id} sans mot de passe (lien magique)"
else:
reason = f"mot de passe incorrect pour le compte {account.id}"
logger.info("Connexion refusée (édition %s) : %s", edition_id, reason)
raise _invalid_credentials()
logger.info("Connexion par mot de passe : compte %s (édition %s)", account.id, edition_id)
return _issue(account)
@@ -135,7 +145,19 @@ def request_magic_link(
tâche de fond (une erreur SMTP est journalisée, pas remontée)."""
magic_link_limiter.hit(f"{edition_id}:{payload.email.strip().lower()}")
account = _account_by_email(session, edition_id, payload.email)
if account is None:
logger.info(
"Lien magique demandé sans compte correspondant (édition %s) — rien envoyé "
"(un email sur une fiche bénévole ne crée pas d'accès)",
edition_id,
)
elif account.password_hash is not None:
logger.info(
"Lien magique demandé pour le compte %s, qui a un mot de passe — rien envoyé",
account.id,
)
if account is not None and account.password_hash is None:
logger.info("Lien magique émis pour le compte %s (édition %s)", account.id, edition_id)
settings = get_settings()
raw = issue_magic_link_token(session, account)
session.commit()
@@ -172,13 +194,26 @@ def verify_magic_link(
or token.used_at is not None
or token.expires_at < local_now()
):
if token is None:
reason = "jeton inconnu"
elif token.used_at is not None:
reason = f"jeton déjà utilisé (compte {token.account_id})"
else:
reason = f"jeton expiré (compte {token.account_id})"
logger.info("Lien magique refusé (édition %s) : %s", edition_id, reason)
raise _invalid_token()
account = session.get(Account, token.account_id)
if account is None or account.edition_id != edition_id:
logger.info(
"Lien magique refusé (édition %s) : compte %s absent ou d'une autre édition",
edition_id,
token.account_id,
)
raise _invalid_token()
token.used_at = local_now()
session.commit()
session.refresh(account)
logger.info("Connexion par lien magique : compte %s (édition %s)", account.id, edition_id)
return _issue(account)
+113 -3
View File
@@ -718,6 +718,11 @@ async def submit_public_form(
set_artists(session, volunteer, artist_ids)
session.commit()
session.refresh(volunteer)
logger.info(
"Formulaire public : bénévole %s créé (édition %s, sans paiement en ligne)",
volunteer.id,
edition.id,
)
if volunteer.email:
subject, body = render_registration_confirmation(first_name=volunteer.first_name)
background.add_task(
@@ -758,6 +763,14 @@ async def submit_public_form(
session.add(pending)
session.commit()
session.refresh(pending)
logger.info(
"Formulaire public : inscription en attente de paiement %s (édition %s, "
"montant %.2f, mécénat=%s)",
pending.id,
edition.id,
pending.payment_amount,
pending.requires_mecenat_info,
)
if pending.requires_mecenat_info:
return FormSubmissionResult(pending_token=pending.id, requires_mecenat_info=True)
@@ -783,19 +796,53 @@ def _create_pending_checkout(
amount=pending.payment_amount,
currency="EUR",
checkout_reference=f"{pending.id}-{uuid.uuid4().hex[:8]}",
description=f"{pending.payment_label} — édition {edition.year}",
description=_checkout_description(edition, pending),
return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook",
redirect_url=f"{base}/inscription/{template.access_token}?paiement={pending.id}",
)
if checkout is None:
logger.warning(
"Checkout SumUp non créé pour l'inscription en attente %s (édition %s) — "
"le bénévole devra réessayer",
pending.id,
edition.id,
)
return None
pending.checkout_id = checkout.id
pending.checkout_status = FeePaymentStatus.PENDING
session.add(pending)
session.commit()
logger.info(
"Checkout SumUp %s créé pour l'inscription en attente %s (édition %s)",
checkout.id,
pending.id,
edition.id,
)
if not checkout.hosted_checkout_url:
logger.warning(
"Checkout SumUp %s sans hosted_checkout_url — le paiement hébergé "
"est-il activé sur le compte marchand ?",
checkout.id,
)
return checkout.hosted_checkout_url
# Longueur prudente : la doc SumUp ne fixe pas de maximum pour `description`.
_CHECKOUT_DESCRIPTION_MAX = 255
def _checkout_description(edition: Edition, pending: PendingFormSubmission) -> str:
"""Libellé de la transaction côté SumUp (tableau de bord, exports) : le
nom du bénévole en tête, pour qu'un paiement s'y rapproche d'un coup
d'œil de la bonne personne — l'API de création de checkout n'a pas de
champ de métadonnées libre (voir `app/sumup/client.py:create_checkout`).
Donnée transmise au prestataire de paiement, pas un log : la règle RGPD
de `app/logging_config.py` ne s'applique pas ici."""
name = " ".join(p for p in (pending.first_name, pending.last_name) if p)
parts = [name, pending.payment_label, f"édition {edition.year}"]
return " — ".join(p for p in parts if p)[:_CHECKOUT_DESCRIPTION_MAX]
def _promote_pending(
session: Session,
edition: Edition,
@@ -824,6 +871,8 @@ def _promote_pending(
fee_checkout_id=checkout.id,
fee_payment_status=FeePaymentStatus.PAID,
fee_paid_at=local_now(),
fee_transaction_code=checkout.transaction_code,
fee_transaction_id=checkout.transaction_id,
mecenat_address=pending.mecenat_address,
mecenat_city=pending.mecenat_city,
mecenat_postal_code=pending.mecenat_postal_code,
@@ -866,6 +915,22 @@ def _promote_pending(
pending.mecenat_country = None
session.add(pending)
session.commit()
logger.info(
"Paiement confirmé : inscription en attente %s → bénévole %s (édition %s, "
"checkout %s, transaction %s, reçu mécénat %s)",
pending.id,
volunteer.id,
edition.id,
checkout.id,
checkout.transaction_code or "inconnue",
volunteer.mecenat_receipt_number or "-",
)
if not checkout.transaction_code:
logger.warning(
"Checkout SumUp %s payé sans code de transaction renvoyé — rapprochement "
"à faire depuis le tableau de bord SumUp",
checkout.id,
)
if volunteer.email:
# Paiement « mécénat » (adresse postale saisie) : le cerfa rempli part
@@ -906,7 +971,10 @@ def _send_paid_confirmation(
pdf = generate_mecenat_receipts_pdf([receipt])
attachments.append((pdf, "application/pdf", RECEIPT_FILENAME))
except Exception:
logger.exception("Reçu fiscal mécénat non généré — email envoyé sans pièce jointe")
logger.exception(
"Reçu fiscal mécénat n° %s non généré — email envoyé sans pièce jointe",
receipt.number or "?",
)
send(to, subject, body, logo=logo, attachments=attachments)
@@ -925,10 +993,21 @@ def _apply_pending_checkout_result(
try:
new_status = FeePaymentStatus(checkout.status)
except ValueError:
logger.warning(
"Checkout SumUp %s : statut inconnu %r ignoré", checkout.id, checkout.status
)
return
if new_status == FeePaymentStatus.PAID:
_promote_pending(session, edition, pending, checkout, background)
else:
if new_status != pending.checkout_status:
logger.info(
"Checkout SumUp %s (inscription en attente %s) : %s → %s",
checkout.id,
pending.id,
pending.checkout_status,
new_status,
)
pending.checkout_status = new_status
session.add(pending)
session.commit()
@@ -1043,6 +1122,12 @@ def retry_pending_payment(
)
template = _get_or_create_template(session, edition.id)
logger.info(
"Relance du paiement de l'inscription en attente %s (édition %s, statut %s)",
pending.id,
edition.id,
pending.checkout_status,
)
checkout_url = _create_pending_checkout(session, edition, template, pending)
return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url)
@@ -1059,13 +1144,23 @@ def sumup_webhook(
identifiant : jamais fait confiance sans relire l'état réel via l'API
(voir `app/sumup/client.py:get_checkout`). Répond toujours `200`, y
compris sur un événement inconnu — SumUp réessaie sinon inutilement."""
logger.info(
"Webhook SumUp reçu (édition %s, événement %s, checkout %s)",
edition.id,
payload.event_type,
payload.id,
)
template = _get_or_create_template(session, edition.id)
if not template.sumup_api_key:
logger.warning(
"Webhook SumUp ignoré : aucune clé API SumUp configurée (édition %s)",
edition.id,
)
return Response(status_code=status.HTTP_200_OK)
checkout = get_checkout(api_key=template.sumup_api_key, checkout_id=payload.id)
if checkout is None:
logger.warning("Webhook SumUp : relecture du checkout impossible.")
logger.warning("Webhook SumUp : relecture du checkout %s impossible.", payload.id)
return Response(status_code=status.HTTP_200_OK)
pending = session.exec(
@@ -1094,6 +1189,21 @@ def sumup_webhook(
volunteer.fee_payment_status = new_status
if new_status == FeePaymentStatus.PAID and volunteer.fee_paid_at is None:
volunteer.fee_paid_at = local_now()
if checkout.transaction_code and not volunteer.fee_transaction_code:
volunteer.fee_transaction_code = checkout.transaction_code
volunteer.fee_transaction_id = checkout.transaction_id
session.add(volunteer)
session.commit()
logger.info(
"Webhook SumUp : bénévole %s (checkout %s) → %s",
volunteer.id,
payload.id,
new_status,
)
return Response(status_code=status.HTTP_200_OK)
logger.warning(
"Webhook SumUp : checkout %s inconnu dans l'édition %s (inscription purgée ?)",
payload.id,
edition.id,
)
return Response(status_code=status.HTTP_200_OK)
+15 -1
View File
@@ -1,5 +1,7 @@
"""Endpoints d'import : analyse puis exécution."""
import logging
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from pydantic import ValidationError
from sqlmodel import Session
@@ -17,6 +19,8 @@ from app.importing.schemas import (
from app.importing.service import run_import, suggest_mapping, validate_mapping
from app.models import Account, Edition
logger = logging.getLogger("app.importing")
router = APIRouter(prefix="/editions/{edition_id}/import", tags=["import"])
_PREVIEW_ROWS = 5
@@ -107,9 +111,19 @@ async def execute(
},
)
return run_import(
report = run_import(
session=session,
edition=edition,
table=table,
mapping=parsed_mapping,
)
logger.info(
"Import %s (édition %s) : %d lignes — %d créés, %d mis à jour, %d ignorés",
"simulé" if report.dry_run else "exécuté",
edition.id,
report.total_rows,
report.created,
report.updated,
report.skipped,
)
return report
+9
View File
@@ -1,5 +1,6 @@
"""Envoi d'emails d'information aux bénévoles (récapitulatif de planning)."""
import logging
import uuid
from collections import defaultdict
@@ -18,6 +19,8 @@ from app.email import edition_logo, render_planning, send_many
from app.i18n import t
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
logger = logging.getLogger("app.notifications")
router = APIRouter(
prefix="/editions/{edition_id}/notifications", tags=["notifications"]
)
@@ -112,6 +115,12 @@ def send_planning_notification(
recipients.append(volunteer.id)
session.commit() # persiste les éventuels MagicLinkToken émis
logger.info(
"Récapitulatif de planning (édition %s) : %d emails en file, %d bénévoles sans email",
edition.id,
len(items),
skipped_no_email,
)
background.add_task(send_many, items, logo=edition_logo(session, edition.id))
return PlanningNotificationResult(
queued=len(items),
+4
View File
@@ -102,6 +102,10 @@ class VolunteerRead(ApiModel):
fee_amount: float | None
fee_payment_status: FeePaymentStatus | None
fee_paid_at: dt.datetime | None
# Transaction SumUp ayant réglé la cotisation (code du tableau de bord
# SumUp + identifiant technique) — `None` tant que non payée.
fee_transaction_code: str | None
fee_transaction_id: str | 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
+16
View File
@@ -2,6 +2,8 @@
coordination affecte elle-même, dans l'écran habituel, en tenant compte des
bénévoles remontés en tête de liste."""
import logging
import time
import uuid
from fastapi import APIRouter, Depends
@@ -14,6 +16,8 @@ from app.db import get_session
from app.models import Account, Edition
from app.solver import build_solver_input, solve
logger = logging.getLogger("app.solver")
router = APIRouter(prefix="/editions/{edition_id}/solver", tags=["solver"])
@@ -23,8 +27,20 @@ def run_solver(
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> SolverRunResult:
started = time.monotonic()
data = build_solver_input(session=session, edition=edition)
loaded = time.monotonic()
solution = solve(data)
logger.info(
"Solveur (édition %s) : %d créneaux, %d bénévoles, %d suggestions — "
"chargement %d ms, résolution %d ms",
edition.id,
len(data.shifts),
len(data.volunteers),
len(solution.suggestions),
(loaded - started) * 1000,
(time.monotonic() - loaded) * 1000,
)
return SolverRunResult(
suggestions=[
SolverSuggestion(