Logs de diagnostic et rapprochement SumUp ↔ bénévole
- 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:
co-authored by
Claude Opus 5.5
parent
d722a81a87
commit
d0fa1e109c
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user