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
+33 -4
View File
@@ -205,8 +205,8 @@ les tables sauf les tables d'association.
- **Export bénévoles** (Excel, `app/exporting/xlsx.py` + `GET - **Export bénévoles** (Excel, `app/exporting/xlsx.py` + `GET
/editions/{id}/volunteers/export`, COORDINATION) : une ligne par /editions/{id}/volunteers/export`, COORDINATION) : une ligne par
bénévole, mêmes champs que la fiche (coordonnées, vœux, cotisation, bénévole, mêmes champs que la fiche (coordonnées, vœux, cotisation,
mécénat) — les vœux (postes/artistes/compagnons) sont résolus en noms mécénat, transaction SumUp — code et identifiant) — les vœux (postes/
lisibles, jamais en UUID. `openpyxl`, déjà utilisé en lecture par l'import artistes/compagnons) sont résolus en noms lisibles, jamais en UUID. `openpyxl`, déjà utilisé en lecture par l'import
(`app/importing/`), cette fois en écriture. Bouton « Exporter en Excel » (`app/importing/`), cette fois en écriture. Bouton « Exporter en Excel »
sur l'écran de configuration des bénévoles (`ConfigVolunteers.tsx`), même sur l'écran de configuration des bénévoles (`ConfigVolunteers.tsx`), même
mécanique de téléchargement que les badges/feuilles de poste (blob → mécanique de téléchargement que les badges/feuilles de poste (blob →
@@ -358,7 +358,17 @@ les tables sauf les tables d'association.
ou supprimée ensuite. `Volunteer.fee_checkout_id` (identifiant SumUp, ou supprimée ensuite. `Volunteer.fee_checkout_id` (identifiant SumUp,
jamais exposé) et `fee_payment_status` (`PENDING`/`PAID`/`FAILED`/ jamais exposé) et `fee_payment_status` (`PENDING`/`PAID`/`FAILED`/
`EXPIRED`, `NULL` tant qu'aucun checkout n'a été créé) suivent l'état ; `EXPIRED`, `NULL` tant qu'aucun checkout n'a été créé) suivent l'état ;
`fee_paid_at` est posé à la première confirmation `PAID`. Une fois promue, `fee_paid_at` est posé à la première confirmation `PAID`, avec la
transaction SumUp qui a réglé le checkout : `fee_transaction_code` (code
court du tableau de bord SumUp et de ses reçus, ex. `TEENSK4W2K` — lu sur
le checkout, ou à défaut sur son essai `SUCCESSFUL` dans `transactions`)
et `fee_transaction_id` (identifiant technique) — exposés sur la fiche
(section « Cotisation ») et dans l'export Excel, pour le rapprochement
comptable. Dans l'autre sens, l'API de création de checkout n'ayant
**aucun champ de métadonnées libre**, c'est la `description` du checkout
(« affichée dans les outils SumUp ») qui porte le nom du bénévole :
`Prénom Nom — libellé — édition AAAA` (`_checkout_description` ; donnée
transmise au prestataire de paiement, pas un log). Une fois promue,
la ligne `PendingFormSubmission` correspondante voit ses champs nominatifs la ligne `PendingFormSubmission` correspondante voit ses champs nominatifs
vidés (RGPD : l'information ne vit plus que sur `Volunteer`) ; une ligne vidés (RGPD : l'information ne vit plus que sur `Volunteer`) ; une ligne
jamais payée est purgée après 48 h sans être relancée jamais payée est purgée après 48 h sans être relancée
@@ -724,7 +734,8 @@ l'étape 21.)*
│ │ ├── main.py # instance FastAPI │ │ ├── main.py # instance FastAPI
│ │ ├── config.py # settings (pydantic-settings) │ │ ├── config.py # settings (pydantic-settings)
│ │ ├── db.py # engine + session │ │ ├── db.py # engine + session
│ │ ├── logging_config.py # RGPD : pas de PII dans les logs │ │ ├── logging_config.py # RGPD : pas de PII dans les logs ; identifiant de requête
│ ├── request_logging.py # une ligne de log par requête, X-Request-ID, erreurs 500
│ │ ├── api/ # routers + schemas Pydantic + deps │ │ ├── api/ # routers + schemas Pydantic + deps
│ │ │ # health, auth, accounts, editions, │ │ │ # health, auth, accounts, editions,
│ │ │ # day_parts, imports, artists, volunteers, │ │ │ # day_parts, imports, artists, volunteers,
@@ -785,6 +796,23 @@ Aucune dépendance locale requise hors Docker.
+ `locales/fr.json`. Frontend : fichier i18n dédié. Vrai même en monolingue. + `locales/fr.json`. Frontend : fichier i18n dédié. Vrai même en monolingue.
- **RGPD :** - **RGPD :**
- Pas de log des noms, emails, téléphones. On logue des identifiants. - Pas de log des noms, emails, téléphones. On logue des identifiants.
- **Logs** (`app/logging_config.py`, `app/request_logging.py`) : une ligne
par requête HTTP (`app.request` — méthode, route **modèle** + ses
identifiants, jamais l'URL brute ni un paramètre `*token*`, statut, durée,
code d'erreur métier `detail.code`, compte connecté) ; INFO / WARNING
(4xx) / ERROR (5xx), `/health` en DEBUG. Chaque requête a un identifiant
(`X-Request-ID`, repris s'il est fourni et bien formé) préfixé à toutes
ses lignes de log, tâches de fond comprises, et affiché à l'utilisateur
sous une erreur (`ErrorNote` : « Référence à communiquer ») — `grep` de
cette référence dans les logs du conteneur pour diagnostiquer. Erreur 422
de validation : emplacement + type, jamais la valeur saisie. Exception non
gérée : trace complète + `500 {detail: {code: "error.internal",
request_id}}`. Journal d'accès d'uvicorn **désactivé** (il écrivait la
chaîne de requête, donc le `access_token` du formulaire public). Logs
métier sur les parcours opaques : paiement SumUp (inscription en attente,
checkout créé/refusé avec le code d'erreur SumUp, changements de statut,
promotion, webhook), connexion (cause réelle d'un refus, côté logs
seulement), import, envoi de plannings, solveur (durées).
- `Edition.retention_days` : durée de rétention paramétrable. - `Edition.retention_days` : durée de rétention paramétrable.
- Endpoint de suppression complète d'un bénévole : *hard delete* en cascade. - Endpoint de suppression complète d'un bénévole : *hard delete* en cascade.
- **Dates et heures :** stockées naïves, interprétées en heure locale - **Dates et heures :** stockées naïves, interprétées en heure locale
@@ -875,6 +903,7 @@ Aucune dépendance locale requise hors Docker.
| 40 | Écran Statistiques (`/statistiques`, COORDINATION, `GET /editions/{id}/stats`) : inscriptions (nombre, évolution cumulée), réponses au formulaire, artistes demandés, cotisations (en ligne / sur place / aucune, par type, mécénat, paiements jamais aboutis), besoins par journée de festival face aux disponibilités déclarées et aux affectations, places pourvues et vœux par poste — graphiques SVG maison avec infobulles et vues tableau | à valider | | 40 | Écran Statistiques (`/statistiques`, COORDINATION, `GET /editions/{id}/stats`) : inscriptions (nombre, évolution cumulée), réponses au formulaire, artistes demandés, cotisations (en ligne / sur place / aucune, par type, mécénat, paiements jamais aboutis), besoins par journée de festival face aux disponibilités déclarées et aux affectations, places pourvues et vœux par poste — graphiques SVG maison avec infobulles et vues tableau | à valider |
| 41 | Début de « journée de festival » indépendant de l'ordre des créneaux types : début du plus long creux qu'ils laissent sur 24 h (`validation.engine.day_boundary`, miroir `lib/planning.ts:dayBoundaryFromDayParts`). Avant, un créneau type d'ordre 0 déplacé à 21:00 faisait commencer chaque journée des grilles (besoins, vue bénévoles) à 21:00, jusqu'à 03:00 le lendemain | à valider | | 41 | Début de « journée de festival » indépendant de l'ordre des créneaux types : début du plus long creux qu'ils laissent sur 24 h (`validation.engine.day_boundary`, miroir `lib/planning.ts:dayBoundaryFromDayParts`). Avant, un créneau type d'ordre 0 déplacé à 21:00 faisait commencer chaque journée des grilles (besoins, vue bénévoles) à 21:00, jusqu'à 03:00 le lendemain | à valider |
| 42 | Retrait des disponibilités déclarées (table `availability` supprimée, migration `0022` ; API, éditeur de la fiche bénévole, badge de couverture du panneau d'affectation, colonnes des statistiques) et des limites de temps dans la journée (règles `OUTSIDE_AVAILABILITY`, `DAILY_HOURS_EXCEEDED`, `INSUFFICIENT_REST`, malus de disponibilité et de repos du solveur, filtre « Surchargés » de la vue bénévoles) — seul le chevauchement reste contrôlé. La case « je suis disponible » du formulaire public est conservée | à valider | | 42 | Retrait des disponibilités déclarées (table `availability` supprimée, migration `0022` ; API, éditeur de la fiche bénévole, badge de couverture du panneau d'affectation, colonnes des statistiques) et des limites de temps dans la journée (règles `OUTSIDE_AVAILABILITY`, `DAILY_HOURS_EXCEEDED`, `INSUFFICIENT_REST`, malus de disponibilité et de repos du solveur, filtre « Surchargés » de la vue bénévoles) — seul le chevauchement reste contrôlé. La case « je suis disponible » du formulaire public est conservée | à valider |
| 43 | Logs de diagnostic (ligne par requête avec route modèle, statut, durée, code d'erreur et compte ; identifiant `X-Request-ID` repris dans chaque log et affiché à l'utilisateur sous une erreur ; exceptions non gérées tracées ; journal d'accès d'uvicorn coupé car il exposait le jeton du formulaire public ; logs métier SumUp, connexion, import, plannings, solveur) et rapprochement SumUp ↔ bénévole : nom du bénévole dans la description du checkout (pas de métadonnées libres dans l'API), code et identifiant de transaction enregistrés au paiement (`Volunteer.fee_transaction_code`/`fee_transaction_id`, migration `0023`), affichés sur la fiche et dans l'export Excel | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+35
View File
@@ -84,7 +84,17 @@ def login(
or account.password_hash is None or account.password_hash is None
or not verify_password(payload.password, account.password_hash) 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() raise _invalid_credentials()
logger.info("Connexion par mot de passe : compte %s (édition %s)", account.id, edition_id)
return _issue(account) return _issue(account)
@@ -135,7 +145,19 @@ def request_magic_link(
tâche de fond (une erreur SMTP est journalisée, pas remontée).""" tâche de fond (une erreur SMTP est journalisée, pas remontée)."""
magic_link_limiter.hit(f"{edition_id}:{payload.email.strip().lower()}") magic_link_limiter.hit(f"{edition_id}:{payload.email.strip().lower()}")
account = _account_by_email(session, edition_id, payload.email) 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: 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() settings = get_settings()
raw = issue_magic_link_token(session, account) raw = issue_magic_link_token(session, account)
session.commit() session.commit()
@@ -172,13 +194,26 @@ def verify_magic_link(
or token.used_at is not None or token.used_at is not None
or token.expires_at < local_now() 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() raise _invalid_token()
account = session.get(Account, token.account_id) account = session.get(Account, token.account_id)
if account is None or account.edition_id != edition_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() raise _invalid_token()
token.used_at = local_now() token.used_at = local_now()
session.commit() session.commit()
session.refresh(account) session.refresh(account)
logger.info("Connexion par lien magique : compte %s (édition %s)", account.id, edition_id)
return _issue(account) return _issue(account)
+113 -3
View File
@@ -718,6 +718,11 @@ async def submit_public_form(
set_artists(session, volunteer, artist_ids) set_artists(session, volunteer, artist_ids)
session.commit() session.commit()
session.refresh(volunteer) 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: if volunteer.email:
subject, body = render_registration_confirmation(first_name=volunteer.first_name) subject, body = render_registration_confirmation(first_name=volunteer.first_name)
background.add_task( background.add_task(
@@ -758,6 +763,14 @@ async def submit_public_form(
session.add(pending) session.add(pending)
session.commit() session.commit()
session.refresh(pending) 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: if pending.requires_mecenat_info:
return FormSubmissionResult(pending_token=pending.id, requires_mecenat_info=True) return FormSubmissionResult(pending_token=pending.id, requires_mecenat_info=True)
@@ -783,19 +796,53 @@ def _create_pending_checkout(
amount=pending.payment_amount, amount=pending.payment_amount,
currency="EUR", currency="EUR",
checkout_reference=f"{pending.id}-{uuid.uuid4().hex[:8]}", 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", return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook",
redirect_url=f"{base}/inscription/{template.access_token}?paiement={pending.id}", redirect_url=f"{base}/inscription/{template.access_token}?paiement={pending.id}",
) )
if checkout is None: 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 return None
pending.checkout_id = checkout.id pending.checkout_id = checkout.id
pending.checkout_status = FeePaymentStatus.PENDING pending.checkout_status = FeePaymentStatus.PENDING
session.add(pending) session.add(pending)
session.commit() 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 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( def _promote_pending(
session: Session, session: Session,
edition: Edition, edition: Edition,
@@ -824,6 +871,8 @@ def _promote_pending(
fee_checkout_id=checkout.id, fee_checkout_id=checkout.id,
fee_payment_status=FeePaymentStatus.PAID, fee_payment_status=FeePaymentStatus.PAID,
fee_paid_at=local_now(), fee_paid_at=local_now(),
fee_transaction_code=checkout.transaction_code,
fee_transaction_id=checkout.transaction_id,
mecenat_address=pending.mecenat_address, mecenat_address=pending.mecenat_address,
mecenat_city=pending.mecenat_city, mecenat_city=pending.mecenat_city,
mecenat_postal_code=pending.mecenat_postal_code, mecenat_postal_code=pending.mecenat_postal_code,
@@ -866,6 +915,22 @@ def _promote_pending(
pending.mecenat_country = None pending.mecenat_country = None
session.add(pending) session.add(pending)
session.commit() 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: if volunteer.email:
# Paiement « mécénat » (adresse postale saisie) : le cerfa rempli part # 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]) pdf = generate_mecenat_receipts_pdf([receipt])
attachments.append((pdf, "application/pdf", RECEIPT_FILENAME)) attachments.append((pdf, "application/pdf", RECEIPT_FILENAME))
except Exception: 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) send(to, subject, body, logo=logo, attachments=attachments)
@@ -925,10 +993,21 @@ def _apply_pending_checkout_result(
try: try:
new_status = FeePaymentStatus(checkout.status) new_status = FeePaymentStatus(checkout.status)
except ValueError: except ValueError:
logger.warning(
"Checkout SumUp %s : statut inconnu %r ignoré", checkout.id, checkout.status
)
return return
if new_status == FeePaymentStatus.PAID: if new_status == FeePaymentStatus.PAID:
_promote_pending(session, edition, pending, checkout, background) _promote_pending(session, edition, pending, checkout, background)
else: 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 pending.checkout_status = new_status
session.add(pending) session.add(pending)
session.commit() session.commit()
@@ -1043,6 +1122,12 @@ def retry_pending_payment(
) )
template = _get_or_create_template(session, edition.id) 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) checkout_url = _create_pending_checkout(session, edition, template, pending)
return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url) 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 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 (voir `app/sumup/client.py:get_checkout`). Répond toujours `200`, y
compris sur un événement inconnu — SumUp réessaie sinon inutilement.""" 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) template = _get_or_create_template(session, edition.id)
if not template.sumup_api_key: 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) return Response(status_code=status.HTTP_200_OK)
checkout = get_checkout(api_key=template.sumup_api_key, checkout_id=payload.id) checkout = get_checkout(api_key=template.sumup_api_key, checkout_id=payload.id)
if checkout is None: 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) return Response(status_code=status.HTTP_200_OK)
pending = session.exec( pending = session.exec(
@@ -1094,6 +1189,21 @@ def sumup_webhook(
volunteer.fee_payment_status = new_status volunteer.fee_payment_status = new_status
if new_status == FeePaymentStatus.PAID and volunteer.fee_paid_at is None: if new_status == FeePaymentStatus.PAID and volunteer.fee_paid_at is None:
volunteer.fee_paid_at = local_now() 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.add(volunteer)
session.commit() 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) return Response(status_code=status.HTTP_200_OK)
+15 -1
View File
@@ -1,5 +1,7 @@
"""Endpoints d'import : analyse puis exécution.""" """Endpoints d'import : analyse puis exécution."""
import logging
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from pydantic import ValidationError from pydantic import ValidationError
from sqlmodel import Session 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.importing.service import run_import, suggest_mapping, validate_mapping
from app.models import Account, Edition from app.models import Account, Edition
logger = logging.getLogger("app.importing")
router = APIRouter(prefix="/editions/{edition_id}/import", tags=["import"]) router = APIRouter(prefix="/editions/{edition_id}/import", tags=["import"])
_PREVIEW_ROWS = 5 _PREVIEW_ROWS = 5
@@ -107,9 +111,19 @@ async def execute(
}, },
) )
return run_import( report = run_import(
session=session, session=session,
edition=edition, edition=edition,
table=table, table=table,
mapping=parsed_mapping, 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).""" """Envoi d'emails d'information aux bénévoles (récapitulatif de planning)."""
import logging
import uuid import uuid
from collections import defaultdict 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.i18n import t
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
logger = logging.getLogger("app.notifications")
router = APIRouter( router = APIRouter(
prefix="/editions/{edition_id}/notifications", tags=["notifications"] prefix="/editions/{edition_id}/notifications", tags=["notifications"]
) )
@@ -112,6 +115,12 @@ def send_planning_notification(
recipients.append(volunteer.id) recipients.append(volunteer.id)
session.commit() # persiste les éventuels MagicLinkToken émis 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)) background.add_task(send_many, items, logo=edition_logo(session, edition.id))
return PlanningNotificationResult( return PlanningNotificationResult(
queued=len(items), queued=len(items),
+4
View File
@@ -102,6 +102,10 @@ class VolunteerRead(ApiModel):
fee_amount: float | None fee_amount: float | None
fee_payment_status: FeePaymentStatus | None fee_payment_status: FeePaymentStatus | None
fee_paid_at: dt.datetime | 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 # Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un
# type de paiement « mécénat » (voir `FormPaymentOption.requires_mecenat_info`). # type de paiement « mécénat » (voir `FormPaymentOption.requires_mecenat_info`).
mecenat_address: str | None mecenat_address: str | None
+16
View File
@@ -2,6 +2,8 @@
coordination affecte elle-même, dans l'écran habituel, en tenant compte des coordination affecte elle-même, dans l'écran habituel, en tenant compte des
bénévoles remontés en tête de liste.""" bénévoles remontés en tête de liste."""
import logging
import time
import uuid import uuid
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@@ -14,6 +16,8 @@ from app.db import get_session
from app.models import Account, Edition from app.models import Account, Edition
from app.solver import build_solver_input, solve from app.solver import build_solver_input, solve
logger = logging.getLogger("app.solver")
router = APIRouter(prefix="/editions/{edition_id}/solver", tags=["solver"]) router = APIRouter(prefix="/editions/{edition_id}/solver", tags=["solver"])
@@ -23,8 +27,20 @@ def run_solver(
_guard: Account = Depends(require_staff), _guard: Account = Depends(require_staff),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> SolverRunResult: ) -> SolverRunResult:
started = time.monotonic()
data = build_solver_input(session=session, edition=edition) data = build_solver_input(session=session, edition=edition)
loaded = time.monotonic()
solution = solve(data) 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( return SolverRunResult(
suggestions=[ suggestions=[
SolverSuggestion( SolverSuggestion(
+5 -1
View File
@@ -4,7 +4,7 @@ import uuid
from collections.abc import Callable from collections.abc import Callable
import jwt import jwt
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlmodel import Session from sqlmodel import Session
@@ -31,6 +31,7 @@ def _forbidden() -> HTTPException:
def get_current_account( def get_current_account(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> Account: ) -> Account:
@@ -44,6 +45,9 @@ def get_current_account(
account = session.get(Account, account_id) account = session.get(Account, account_id)
if account is None: if account is None:
raise _unauthorized() raise _unauthorized()
# Repris sur la ligne d'accès de la requête (`app/request_logging.py`) :
# qui a rencontré l'erreur, par identifiant seulement.
request.state.account_id = account.id
return account return account
+4
View File
@@ -29,6 +29,8 @@ _HEADER = [
"Montant (€)", "Montant (€)",
"Statut du paiement", "Statut du paiement",
"Payée le", "Payée le",
"Transaction SumUp (code)",
"Transaction SumUp (identifiant)",
"Mécénat — adresse", "Mécénat — adresse",
"Mécénat — ville", "Mécénat — ville",
"Mécénat — code postal", "Mécénat — code postal",
@@ -114,6 +116,8 @@ def generate_volunteers_xlsx(
v.fee_amount if v.fee_amount is not None else "", v.fee_amount if v.fee_amount is not None else "",
_FEE_STATUS_LABELS.get(v.fee_payment_status, ""), _FEE_STATUS_LABELS.get(v.fee_payment_status, ""),
_fmt_dt(v.fee_paid_at), _fmt_dt(v.fee_paid_at),
_safe_text(v.fee_transaction_code or ""),
_safe_text(v.fee_transaction_id or ""),
_safe_text(v.mecenat_address or ""), _safe_text(v.mecenat_address or ""),
_safe_text(v.mecenat_city or ""), _safe_text(v.mecenat_city or ""),
_safe_text(v.mecenat_postal_code or ""), _safe_text(v.mecenat_postal_code or ""),
+26 -1
View File
@@ -1,4 +1,5 @@
import logging import logging
from contextvars import ContextVar
from app.config import get_settings from app.config import get_settings
@@ -7,6 +8,19 @@ from app.config import get_settings
# téléphone dans les logs. On journalise des identifiants (UUID/entier) et des # téléphone dans les logs. On journalise des identifiants (UUID/entier) et des
# codes. Toute nouvelle instruction de log doit respecter cette règle. # codes. Toute nouvelle instruction de log doit respecter cette règle.
# Identifiant de la requête HTTP en cours (voir `app/request_logging.py`),
# repris sur chaque ligne de log émise pendant son traitement — y compris
# dans ses tâches de fond (`BackgroundTasks`, même contexte) — pour retrouver
# d'un `grep` tout ce qui s'est passé lors de l'erreur qu'un utilisateur
# signale (la référence lui est affichée, voir `frontend/.../state.tsx`).
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
class RequestIdFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = request_id_var.get()
return True
def configure_logging() -> None: def configure_logging() -> None:
# `force=True` : on réinstalle le handler racine même si l'import de # `force=True` : on réinstalle le handler racine même si l'import de
@@ -14,6 +28,17 @@ def configure_logging() -> None:
# sinon les logs `app.*` n'apparaissent pas dans la sortie du conteneur. # sinon les logs `app.*` n'apparaissent pas dans la sortie du conteneur.
logging.basicConfig( logging.basicConfig(
level=get_settings().log_level, level=get_settings().log_level,
format="%(asctime)s %(levelname)s %(name)s %(message)s", format="%(asctime)s %(levelname)s [%(request_id)s] %(name)s %(message)s",
force=True, force=True,
) )
# Filtre posé sur le handler (pas sur un logger) : il voit aussi les
# lignes des bibliothèques (uvicorn, sqlalchemy…) propagées à la racine.
for handler in logging.getLogger().handlers:
handler.addFilter(RequestIdFilter())
# Journal d'accès d'uvicorn remplacé par `app/request_logging.py` : le
# sien écrit l'URL complète, chaîne de requête comprise — donc le jeton
# `access_token` du formulaire public (CLAUDE.md § 2, `FormTemplate`).
logging.getLogger("uvicorn.access").disabled = True
# Bavard au niveau INFO (une ligne par appel SumUp/hCaptcha), sans
# intérêt à côté des logs de l'appli.
logging.getLogger("httpx").setLevel(logging.WARNING)
+3
View File
@@ -23,6 +23,7 @@ from app.api.stats import router as stats_router
from app.api.volunteers import router as volunteer_router from app.api.volunteers import router as volunteer_router
from app.config import get_settings from app.config import get_settings
from app.logging_config import configure_logging from app.logging_config import configure_logging
from app.request_logging import REQUEST_ID_HEADER, install_request_logging
configure_logging() configure_logging()
@@ -56,7 +57,9 @@ app.add_middleware(
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
expose_headers=[REQUEST_ID_HEADER],
) )
install_request_logging(app)
app.include_router(health_router) app.include_router(health_router)
app.include_router(auth_router) app.include_router(auth_router)
@@ -0,0 +1,40 @@
"""Transaction SumUp de la cotisation
Revision ID: 0023_fee_transaction
Revises: 0022_drop_availability
Create Date: 2026-10-12
`volunteer.fee_transaction_code` (code affiché dans le tableau de bord SumUp,
ex. `TEENSK4W2K`) et `fee_transaction_id` (identifiant technique) : la
transaction qui a réglé la cotisation, posée à la confirmation du paiement —
voir `app/api/forms.py:_promote_pending`. Les bénévoles déjà payés restent
à `NULL`.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0023_fee_transaction"
down_revision: str | None = "0022_drop_availability"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"volunteer", sa.Column("fee_transaction_code", sa.String(), nullable=True)
)
op.create_index(
"ix_volunteer_fee_transaction_code", "volunteer", ["fee_transaction_code"]
)
op.add_column(
"volunteer", sa.Column("fee_transaction_id", sa.String(), nullable=True)
)
def downgrade() -> None:
op.drop_column("volunteer", "fee_transaction_id")
op.drop_index("ix_volunteer_fee_transaction_code", table_name="volunteer")
op.drop_column("volunteer", "fee_transaction_code")
+7
View File
@@ -211,6 +211,13 @@ class Volunteer(TimestampMixin, table=True):
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True), sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
) )
fee_paid_at: dt.datetime | None = None fee_paid_at: dt.datetime | None = None
# Transaction SumUp qui a réglé la cotisation (`app/sumup/client.py:
# SumUpCheckout`), posée à la confirmation du paiement : le code court
# (`TEENSK4W2K`) est celui du tableau de bord SumUp et de ses reçus —
# rapprochement comptable paiement ↔ bénévole. Exposés (fiche, export
# Excel), contrairement à `fee_checkout_id`.
fee_transaction_code: str | None = Field(default=None, index=True)
fee_transaction_id: str | None = None
# Coordonnées collectées uniquement pour un type de paiement « mécénat » # Coordonnées collectées uniquement pour un type de paiement « mécénat »
# (`FormPaymentOption.requires_mecenat_info`), avant le paiement (voir # (`FormPaymentOption.requires_mecenat_info`), avant le paiement (voir
# `PendingFormSubmission` et `app/api/forms.py:submit_mecenat_info`) — # `PendingFormSubmission` et `app/api/forms.py:submit_mecenat_info`) —
+130
View File
@@ -0,0 +1,130 @@
"""Journal des requêtes HTTP — une ligne par requête, pour diagnostiquer
après coup une erreur rencontrée par un utilisateur (voir CLAUDE.md § 8,
RGPD : identifiants et codes seulement).
- Chaque requête reçoit un identifiant (`X-Request-ID`, repris s'il est
fourni et bien formé, généré sinon), renvoyé dans l'en-tête de la réponse
et préfixé à toutes les lignes de log émises pendant son traitement
(`app/logging_config.py:request_id_var`).
- La ligne d'accès donne la route **modèle** (`/editions/{edition_id}/…`) et
ses paramètres d'identifiants, jamais l'URL brute : la chaîne de requête
peut porter un jeton (`access_token` du formulaire public), et les
paramètres de chemin nommés `*token*` (inscription en attente) sont omis.
- Code d'erreur métier (`detail.code`) et compte connecté ajoutés quand ils
existent ; détails d'une erreur de validation 422 (emplacement + type,
jamais la valeur saisie).
- Une exception non gérée est journalisée avec sa trace et renvoie un 500
JSON `{detail: {code: "error.internal", request_id}}` plutôt qu'un texte
brut.
"""
import logging
import re
import time
import uuid
from fastapi import FastAPI, HTTPException, Request
from fastapi.exception_handlers import (
http_exception_handler,
request_validation_exception_handler,
)
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.logging_config import request_id_var
logger = logging.getLogger("app.request")
REQUEST_ID_HEADER = "X-Request-ID"
_INCOMING_ID = re.compile(r"^[A-Za-z0-9-]{8,64}$")
# Sondes de santé (Docker, Caddy) : pas une ligne INFO toutes les 10 s.
_QUIET_PATHS = {"/health"}
def _route_label(request: Request) -> str:
route = request.scope.get("route")
template = getattr(route, "path", None) or "<non routé>"
params = {
key: value
for key, value in (request.scope.get("path_params") or {}).items()
if "token" not in key
}
if not params:
return template
return f"{template} " + " ".join(f"{k}={v}" for k, v in params.items())
def install_request_logging(app: FastAPI) -> None:
@app.middleware("http")
async def log_requests(request: Request, call_next):
incoming = request.headers.get(REQUEST_ID_HEADER, "")
request_id = incoming if _INCOMING_ID.match(incoming) else uuid.uuid4().hex[:12]
reset = request_id_var.set(request_id)
started = time.monotonic()
try:
try:
response = await call_next(request)
except Exception:
logger.exception(
"Exception non gérée sur %s %s", request.method, _route_label(request)
)
response = JSONResponse(
status_code=500,
content={"detail": {"code": "error.internal", "request_id": request_id}},
)
duration_ms = (time.monotonic() - started) * 1000
response.headers[REQUEST_ID_HEADER] = request_id
status_code = response.status_code
if status_code >= 500:
level = logging.ERROR
elif status_code >= 400:
level = logging.WARNING
elif request.url.path in _QUIET_PATHS or request.method == "OPTIONS":
level = logging.DEBUG
else:
level = logging.INFO
extras = []
error_code = getattr(request.state, "error_code", None)
if error_code:
extras.append(f"code={error_code}")
account_id = getattr(request.state, "account_id", None)
if account_id:
extras.append(f"compte={account_id}")
logger.log(
level,
"%s %s → %d (%d ms)%s",
request.method,
_route_label(request),
status_code,
duration_ms,
(" " + " ".join(extras)) if extras else "",
)
return response
finally:
request_id_var.reset(reset)
@app.exception_handler(StarletteHTTPException)
async def log_http_exception(request: Request, exc: HTTPException):
detail = exc.detail
if isinstance(detail, dict) and detail.get("code"):
request.state.error_code = detail["code"]
violations = detail.get("violations")
if violations:
# Codes de violation seulement (`SHIFT_OVERLAP`…) : les
# paramètres peuvent porter des horaires, pas utiles ici.
request.state.error_code += " violations=" + ",".join(
sorted({str(v.get("code")) for v in violations if isinstance(v, dict)})
)
return await http_exception_handler(request, exc)
@app.exception_handler(RequestValidationError)
async def log_validation_error(request: Request, exc: RequestValidationError):
# Emplacement + type d'erreur, jamais `input` (la valeur saisie, qui
# peut être un nom, un email…).
request.state.error_code = "validation " + "; ".join(
f"{'.'.join(str(p) for p in err.get('loc', ()))}:{err.get('type')}"
for err in exc.errors()[:10]
)
return await request_validation_exception_handler(request, exc)
+70 -3
View File
@@ -9,6 +9,7 @@ l'inscription au formulaire public n'échoue jamais pour autant.
""" """
import logging import logging
import time
from dataclasses import dataclass from dataclasses import dataclass
import httpx import httpx
@@ -24,9 +25,17 @@ class SumUpCheckout:
id: str id: str
status: str # PENDING / PAID / FAILED / EXPIRED (voir FeePaymentStatus) status: str # PENDING / PAID / FAILED / EXPIRED (voir FeePaymentStatus)
hosted_checkout_url: str | None hosted_checkout_url: str | None
# Transaction qui a réglé le checkout (renseignés par SumUp une fois
# payé, `None` avant) : `transaction_code` est le code court affiché dans
# le tableau de bord SumUp et sur ses reçus (ex. `TEENSK4W2K`) — c'est
# lui que la coordination recherche pour rapprocher un paiement d'un
# bénévole ; `transaction_id` est l'identifiant technique (API).
transaction_code: str | None = None
transaction_id: str | None = None
def _call(method: str, path: str, api_key: str, json: dict | None = None) -> dict | None: def _call(method: str, path: str, api_key: str, json: dict | None = None) -> dict | None:
started = time.monotonic()
try: try:
resp = httpx.request( resp = httpx.request(
method, method,
@@ -36,19 +45,71 @@ def _call(method: str, path: str, api_key: str, json: dict | None = None) -> dic
timeout=_TIMEOUT_SECONDS, timeout=_TIMEOUT_SECONDS,
) )
resp.raise_for_status() resp.raise_for_status()
return resp.json() data = resp.json()
except httpx.HTTPError as exc: except httpx.HTTPStatusError as exc:
# Le corps d'erreur SumUp (`error_code`/`message`, voir leur doc) dit
# pourquoi l'appel est refusé (identifiants invalides, montant,
# référence déjà utilisée…) — il ne contient que des codes, pas de
# données du payeur.
logger.warning(
"Échec de l'appel API SumUp (%s %s) : HTTP %d %s",
method,
path,
exc.response.status_code,
_error_summary(exc.response),
)
return None
except (httpx.HTTPError, ValueError) as exc:
logger.warning( logger.warning(
"Échec de l'appel API SumUp (%s %s) : %s", method, path, exc.__class__.__name__ "Échec de l'appel API SumUp (%s %s) : %s", method, path, exc.__class__.__name__
) )
return None return None
logger.debug(
"API SumUp %s %s : OK en %d ms",
method,
path,
(time.monotonic() - started) * 1000,
)
return data
def _error_summary(resp: httpx.Response) -> str:
try:
body = resp.json()
except ValueError:
return ""
if isinstance(body, list): # SumUp renvoie parfois une liste d'erreurs
body = body[0] if body else {}
if not isinstance(body, dict):
return ""
return " ".join(
str(body[key])[:200] for key in ("error_code", "message", "param") if body.get(key)
)
def _to_checkout(data: dict) -> SumUpCheckout: def _to_checkout(data: dict) -> SumUpCheckout:
transaction_code = data.get("transaction_code")
transaction_id = data.get("transaction_id")
if not transaction_code:
# Selon la version de l'API, seuls les essais de paiement
# (`transactions`) portent le code : on retient celui qui a abouti.
successful = next(
(
tx
for tx in data.get("transactions") or []
if isinstance(tx, dict) and tx.get("status") == "SUCCESSFUL"
),
None,
)
if successful is not None:
transaction_code = successful.get("transaction_code")
transaction_id = transaction_id or successful.get("id")
return SumUpCheckout( return SumUpCheckout(
id=data["id"], id=data["id"],
status=data["status"], status=data["status"],
hosted_checkout_url=data.get("hosted_checkout_url"), hosted_checkout_url=data.get("hosted_checkout_url"),
transaction_code=transaction_code,
transaction_id=transaction_id,
) )
@@ -65,7 +126,13 @@ def create_checkout(
) -> SumUpCheckout | None: ) -> SumUpCheckout | None:
"""Crée un checkout hébergé (`hosted_checkout.enabled=true`) : la page de """Crée un checkout hébergé (`hosted_checkout.enabled=true`) : la page de
paiement SumUp est à `hosted_checkout_url`. `None` en cas d'échec (réseau, paiement SumUp est à `hosted_checkout_url`. `None` en cas d'échec (réseau,
identifiants invalides…).""" identifiants invalides…).
L'API de création n'accepte pas de métadonnées libres : `description`
(« affichée dans les outils SumUp ») et `checkout_reference` sont les
seuls champs du marchand repris sur la transaction — c'est donc par eux
que passe le nom du bénévole (voir `app/api/forms.py:
_checkout_description`)."""
data = _call( data = _call(
"POST", "POST",
"/v0.1/checkouts", "/v0.1/checkouts",
+21
View File
@@ -588,3 +588,24 @@ def test_mecenat_receipts_is_coordinator_only(
lead = make_account(Role.LEAD, email="lead@e.fr") lead = make_account(Role.LEAD, email="lead@e.fr")
resp = as_account(lead).get(_url(edition, "/mecenat-receipts")) resp = as_account(lead).get(_url(edition, "/mecenat-receipts"))
assert resp.status_code == 403 assert resp.status_code == 403
def test_export_gives_the_sumup_transaction(client, edition, session):
volunteer = make_volunteer(session, edition, first_name="Sam", last_name="B")
_mark_mecenat_paid(session, volunteer)
volunteer.fee_transaction_code = "TEENSK4W2K"
volunteer.fee_transaction_id = "4ba8a7ba-0f61-4c8a-9b5c-2f3e0b4a8a11"
session.add(volunteer)
session.commit()
resp = client.get(_url(edition, "/export"))
rows = list(load_workbook(io.BytesIO(resp.content)).active.iter_rows(values_only=True))
code = rows[0].index("Transaction SumUp (code)")
ident = rows[0].index("Transaction SumUp (identifiant)")
assert rows[1][code] == "TEENSK4W2K"
assert rows[1][ident] == "4ba8a7ba-0f61-4c8a-9b5c-2f3e0b4a8a11"
fiche = client.get(_url(edition, f"/{volunteer.id}")).json()
assert fiche["fee_transaction_code"] == "TEENSK4W2K"
# L'identifiant du checkout, lui, reste interne.
assert "fee_checkout_id" not in fiche
+50
View File
@@ -716,6 +716,56 @@ def test_paying_promotes_the_pending_submission_to_a_volunteer(
assert fiche["willing_to_pay_fee"] is True assert fiche["willing_to_pay_fee"] is True
def test_checkout_description_names_the_volunteer(raw_client, client, edition, monkeypatch):
# Pas de champ de métadonnées libre à la création d'un checkout SumUp :
# c'est la description (visible dans le tableau de bord SumUp) qui porte
# le nom, pour rapprocher la transaction du bénévole.
option_id = _configure_sumup(client, edition)
calls = []
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: calls.append(kw)
or SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
)
_submit(
raw_client, edition,
first_name="alex", last_name="martin", hcaptcha_token="x",
payment_option_id=option_id,
)
assert calls[0]["description"] == (
f"Alex Martin — Cotisation classique — édition {edition.year}"
)
def test_promotion_records_the_sumup_transaction(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"),
)
token = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
).json()["pending_token"]
monkeypatch.setattr(
"app.api.forms.get_checkout",
lambda **kw: SumUpCheckout(
id="co_1",
status="PAID",
hosted_checkout_url=None,
transaction_code="TEENSK4W2K",
transaction_id="tx-uuid-1",
),
)
volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"]
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
assert fiche["fee_transaction_code"] == "TEENSK4W2K"
assert fiche["fee_transaction_id"] == "tx-uuid-1"
def test_payment_confirmation_sends_an_email_on_promotion( def test_payment_confirmation_sends_an_email_on_promotion(
raw_client, client, edition, monkeypatch, outbox raw_client, client, edition, monkeypatch, outbox
): ):
+78
View File
@@ -0,0 +1,78 @@
"""Journal des requêtes (`app/request_logging.py`) : référence de requête,
route modèle sans jeton ni chaîne de requête, erreurs détaillées sans
valeur saisie."""
import logging
import pytest
from fastapi.testclient import TestClient
from app.main import app
def test_each_response_carries_a_request_id(client):
resp = client.get("/health")
assert len(resp.headers["X-Request-ID"]) >= 8
def test_a_well_formed_incoming_request_id_is_kept(client):
resp = client.get("/health", headers={"X-Request-ID": "caddy-1234abcd"})
assert resp.headers["X-Request-ID"] == "caddy-1234abcd"
def test_a_malformed_incoming_request_id_is_replaced(client):
resp = client.get("/health", headers={"X-Request-ID": "x\nforged log line"})
assert resp.headers["X-Request-ID"] != "x\nforged log line"
def test_access_line_uses_the_route_template_and_hides_tokens(raw_client, edition, caplog):
with caplog.at_level(logging.INFO, logger="app.request"):
raw_client.get(f"/editions/{edition.id}/form/public?token=secret-access-token")
line = next(r.getMessage() for r in caplog.records if r.name == "app.request")
assert "/editions/{edition_id}/form/public" in line
assert f"edition_id={edition.id}" in line
assert "secret-access-token" not in caplog.text
def test_business_error_code_is_logged(client, edition, caplog):
with caplog.at_level(logging.INFO, logger="app.request"):
resp = client.get(f"/editions/{edition.id}/volunteers/00000000-0000-0000-0000-000000000000")
assert resp.status_code == 404
record = next(r for r in caplog.records if r.name == "app.request")
assert record.levelno == logging.WARNING
assert "code=error.not_found" in record.getMessage()
def test_validation_error_logs_the_field_but_not_the_value(client, edition, caplog):
with caplog.at_level(logging.INFO, logger="app.request"):
resp = client.post(
f"/editions/{edition.id}/volunteers",
json={"first_name": "Alexandra-Secret", "last_name": 12345678},
)
assert resp.status_code == 422
assert "last_name" in caplog.text
assert "Alexandra-Secret" not in caplog.text
@pytest.fixture
def boom_route():
def boom():
raise RuntimeError("panne")
app.add_api_route("/__boom", boom)
yield
app.router.routes = [r for r in app.router.routes if getattr(r, "path", None) != "/__boom"]
def test_unhandled_exception_returns_a_reference(boom_route, caplog):
with caplog.at_level(logging.ERROR, logger="app.request"):
resp = TestClient(app).get("/__boom")
assert resp.status_code == 500
body = resp.json()
assert body["detail"]["code"] == "error.internal"
assert body["detail"]["request_id"] == resp.headers["X-Request-ID"]
assert "RuntimeError" in caplog.text
+59
View File
@@ -0,0 +1,59 @@
"""Client SumUp : lecture de la transaction d'un checkout payé, et
diagnostic d'un appel refusé (journalisé sans données du payeur)."""
import logging
import httpx
from app.sumup import client as sumup
def test_transaction_is_read_from_the_checkout():
checkout = sumup._to_checkout(
{
"id": "co_1",
"status": "PAID",
"transaction_code": "TEENSK4W2K",
"transaction_id": "tx-1",
}
)
assert checkout.transaction_code == "TEENSK4W2K"
assert checkout.transaction_id == "tx-1"
def test_transaction_falls_back_to_the_successful_attempt():
checkout = sumup._to_checkout(
{
"id": "co_1",
"status": "PAID",
"transactions": [
{"id": "tx-failed", "transaction_code": "TFAILED", "status": "FAILED"},
{"id": "tx-ok", "transaction_code": "TOK", "status": "SUCCESSFUL"},
],
}
)
assert checkout.transaction_code == "TOK"
assert checkout.transaction_id == "tx-ok"
def test_unpaid_checkout_has_no_transaction():
checkout = sumup._to_checkout({"id": "co_1", "status": "PENDING"})
assert checkout.transaction_code is None
assert checkout.transaction_id is None
def test_refused_call_logs_the_sumup_error_code(monkeypatch, caplog):
request = httpx.Request("POST", "https://api.sumup.com/v0.1/checkouts")
response = httpx.Response(
409,
json={"error_code": "DUPLICATED_CHECKOUT", "message": "Checkout already exists"},
request=request,
)
monkeypatch.setattr(httpx, "request", lambda *a, **kw: response)
with caplog.at_level(logging.WARNING, logger="app.sumup"):
result = sumup.get_checkout(api_key="k", checkout_id="co_1")
assert result is None
assert "HTTP 409" in caplog.text
assert "DUPLICATED_CHECKOUT" in caplog.text
+6 -2
View File
@@ -70,7 +70,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
? ((detail as { violations: [] }).violations ?? []) ? ((detail as { violations: [] }).violations ?? [])
: []; : [];
if (res.status === 401 && !path.includes('/auth/')) notifyUnauthorized(); if (res.status === 401 && !path.includes('/auth/')) notifyUnauthorized();
throw new ApiError(res.status, code, violations); const requestId = res.headers.get('X-Request-ID');
if (res.status >= 500) {
console.error(`API ${res.status} ${path} — référence ${requestId ?? '?'}`, detail);
}
throw new ApiError(res.status, code, violations, requestId);
} }
return body as T; return body as T;
} }
@@ -99,7 +103,7 @@ export function createHttpApi(baseUrl: string): PlanningApi {
// Corps non JSON (ex. erreur réseau) : garde le code générique. // Corps non JSON (ex. erreur réseau) : garde le code générique.
} }
if (res.status === 401) notifyUnauthorized(); if (res.status === 401) notifyUnauthorized();
throw new ApiError(res.status, code, []); throw new ApiError(res.status, code, [], res.headers.get('X-Request-ID'));
} }
return res.blob(); return res.blob();
} }
+4
View File
@@ -299,6 +299,8 @@ export function buildMockState(): MockState {
mecenat_country: null, mecenat_country: null,
fee_payment_status: null, fee_payment_status: null,
fee_paid_at: null, fee_paid_at: null,
fee_transaction_code: null,
fee_transaction_id: null,
created_at: NOW, created_at: NOW,
updated_at: NOW, updated_at: NOW,
}); });
@@ -316,6 +318,8 @@ export function buildMockState(): MockState {
fee_amount: mecenat ? 40 : 15, fee_amount: mecenat ? 40 : 15,
fee_payment_status: 'PAID', fee_payment_status: 'PAID',
fee_paid_at: v.created_at, fee_paid_at: v.created_at,
fee_transaction_code: `TDEMO${String(i).padStart(5, '0')}`,
fee_transaction_id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
...(mecenat ...(mecenat
? { mecenat_address: '1 rue du Festival', mecenat_city: 'Rennes', mecenat_postal_code: '35000', mecenat_country: 'France' } ? { mecenat_address: '1 rue du Festival', mecenat_city: 'Rennes', mecenat_postal_code: '35000', mecenat_country: 'France' }
: {}), : {}),
+8
View File
@@ -244,6 +244,8 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
mecenat_country: pending.mecenatCountry, mecenat_country: pending.mecenatCountry,
fee_payment_status: 'PAID', fee_payment_status: 'PAID',
fee_paid_at: now, fee_paid_at: now,
fee_transaction_code: `TMOCK${String(state.seq).padStart(5, '0')}`,
fee_transaction_id: `mock-transaction-${state.seq}`,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
@@ -1005,6 +1007,8 @@ export function createMockApi(): PlanningApi {
mecenat_country: null, mecenat_country: null,
fee_payment_status: null, fee_payment_status: null,
fee_paid_at: null, fee_paid_at: null,
fee_transaction_code: null,
fee_transaction_id: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
@@ -1310,6 +1314,8 @@ export function createMockApi(): PlanningApi {
mecenat_country: null, mecenat_country: null,
fee_payment_status: null, fee_payment_status: null,
fee_paid_at: null, fee_paid_at: null,
fee_transaction_code: null,
fee_transaction_id: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
@@ -1778,6 +1784,8 @@ export function createMockApi(): PlanningApi {
mecenat_country: null, mecenat_country: null,
fee_payment_status: null, fee_payment_status: null,
fee_paid_at: null, fee_paid_at: null,
fee_transaction_code: null,
fee_transaction_id: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
+6
View File
@@ -117,6 +117,12 @@ export interface VolunteerRead {
fee_amount: number | null; fee_amount: number | null;
fee_payment_status: FeePaymentStatus | null; fee_payment_status: FeePaymentStatus | null;
fee_paid_at: string | null; fee_paid_at: string | null;
/** Transaction SumUp qui a réglé la cotisation : `fee_transaction_code` est
* le code affiché dans le tableau de bord SumUp (ex. `TEENSK4W2K`),
* `fee_transaction_id` l'identifiant technique. `null` tant que non payée
* (ou payée avant que l'appli ne l'enregistre). */
fee_transaction_code: string | null;
fee_transaction_id: string | null;
/** Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un type /** Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un type
* de paiement « mécénat ». */ * de paiement « mécénat ». */
mecenat_address: string | null; mecenat_address: string | null;
+3
View File
@@ -233,6 +233,9 @@ export class ApiError extends Error {
public readonly status: number, public readonly status: number,
public readonly code: string, public readonly code: string,
public readonly violations: Violation[] = [], public readonly violations: Violation[] = [],
/** En-tête `X-Request-ID` de la réponse : référence à communiquer pour
* retrouver l'erreur dans les logs du serveur (`app/request_logging.py`). */
public readonly requestId: string | null = null,
) { ) {
super(code); super(code);
this.name = 'ApiError'; this.name = 'ApiError';
+5
View File
@@ -35,6 +35,11 @@ export function ErrorNote({ error }: { error: unknown }) {
<span className="mt-1 block font-mono text-[10px] font-normal opacity-80"> <span className="mt-1 block font-mono text-[10px] font-normal opacity-80">
{known ? t(code) : code} {known ? t(code) : code}
</span> </span>
{error instanceof ApiError && error.requestId ? (
<span className="mt-1 block select-all font-mono text-[10px] font-normal opacity-80">
{t('app.error.reference', { id: error.requestId })}
</span>
) : null}
</div> </div>
); );
} }
+7
View File
@@ -7,7 +7,9 @@
"app.loading": "Chargement…", "app.loading": "Chargement…",
"common.now": "MAINTENANT", "common.now": "MAINTENANT",
"app.error.generic": "Une erreur est survenue", "app.error.generic": "Une erreur est survenue",
"app.error.reference": "Référence à communiquer : {id}",
"error.generic": "Erreur inattendue", "error.generic": "Erreur inattendue",
"error.internal": "Erreur interne du serveur",
"error.not_found": "Ressource introuvable", "error.not_found": "Ressource introuvable",
"error.forbidden": "Accès refusé", "error.forbidden": "Accès refusé",
"error.unauthorized": "Authentification requise", "error.unauthorized": "Authentification requise",
@@ -307,6 +309,11 @@
"config.vol.fee.mecenat": "Mécénat — reçu fiscal", "config.vol.fee.mecenat": "Mécénat — reçu fiscal",
"config.vol.fee.onSite": "Paiera sur place", "config.vol.fee.onSite": "Paiera sur place",
"config.vol.fee.none": "aucune cotisation déclarée", "config.vol.fee.none": "aucune cotisation déclarée",
"config.vol.fee.paidAt": "Payée le",
"config.vol.fee.transaction": "Transaction SumUp",
"config.vol.fee.transactionHint": "Code de la transaction tel qu'affiché dans le tableau de bord SumUp : recherchez-le là-bas pour retrouver ce paiement. Côté SumUp, la description de la transaction porte aussi le nom du bénévole.",
"config.vol.fee.transactionUnknown": "non enregistrée (paiement antérieur à ce suivi)",
"config.vol.fee.transactionId": "Identifiant",
"config.vol.fee.onSiteShort": "Sur place", "config.vol.fee.onSiteShort": "Sur place",
"config.vol.fee.onlineShort": "En ligne", "config.vol.fee.onlineShort": "En ligne",
"config.vol.fee.mecenatShort": "Mécénat", "config.vol.fee.mecenatShort": "Mécénat",
+41 -1
View File
@@ -37,7 +37,7 @@ import { ErrorNote, Loading } from '@/components/state';
import { feeMode } from '@/lib/fee'; import { feeMode } from '@/lib/fee';
import { foldAccents } from '@/lib/planning'; import { foldAccents } from '@/lib/planning';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { shortDayLabel } from '@/lib/time'; import { datePart, shortDayLabel, timePart } from '@/lib/time';
import { cx } from '@/lib/cx'; import { cx } from '@/lib/cx';
const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [ const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [
@@ -273,6 +273,7 @@ function FeeSection({
{volunteer.fee_payment_label} — {volunteer.fee_amount} € {volunteer.fee_payment_label} — {volunteer.fee_amount} €
</p> </p>
) : null} ) : null}
{volunteer.fee_payment_status === 'PAID' ? <FeeTransaction volunteer={volunteer} /> : null}
{volunteer.mecenat_address ? ( {volunteer.mecenat_address ? (
<p className="font-mono text-[10.5px] leading-relaxed opacity-70"> <p className="font-mono text-[10.5px] leading-relaxed opacity-70">
{t('config.vol.mecenatAddress')} : {volunteer.mecenat_address},{' '} {t('config.vol.mecenatAddress')} : {volunteer.mecenat_address},{' '}
@@ -298,6 +299,45 @@ function FeeSection({
); );
} }
/**
* Rapprochement avec SumUp : date du paiement et transaction qui l'a réglé.
* Le code (`TEENSK4W2K`) est celui que montre le tableau de bord SumUp —
* c'est lui qu'on y recherche pour retrouver le paiement de ce bénévole.
* Absent pour un paiement confirmé avant que l'appli ne l'enregistre.
*/
function FeeTransaction({ volunteer }: { volunteer: VolunteerRead }) {
const paidAt = volunteer.fee_paid_at;
return (
<dl className="grid grid-cols-[auto_1fr] gap-x-[10px] gap-y-[3px] font-mono text-[10.5px]">
{paidAt ? (
<>
<dt className="opacity-70">{t('config.vol.fee.paidAt')}</dt>
<dd>
{datePart(paidAt).split('-').reverse().join('/')} {timePart(paidAt)}
</dd>
</>
) : null}
<dt className="flex items-center gap-[4px] opacity-70">
{t('config.vol.fee.transaction')}{' '}
<HelpTip text={t('config.vol.fee.transactionHint')} />
</dt>
<dd className="min-w-0">
{volunteer.fee_transaction_code ? (
<span className="select-all break-all font-bold">{volunteer.fee_transaction_code}</span>
) : (
<span className="opacity-60">{t('config.vol.fee.transactionUnknown')}</span>
)}
</dd>
{volunteer.fee_transaction_id ? (
<>
<dt className="opacity-70">{t('config.vol.fee.transactionId')}</dt>
<dd className="min-w-0 select-all break-all opacity-70">{volunteer.fee_transaction_id}</dd>
</>
) : null}
</dl>
);
}
/** /**
* Postes dont ce bénévole est référent (`Position.lead_account_id`). Cocher * Postes dont ce bénévole est référent (`Position.lead_account_id`). Cocher
* un poste crée ou met à niveau son accès de connexion en RÉFÉRENT (LEAD) — * un poste crée ou met à niveau son accès de connexion en RÉFÉRENT (LEAD) —