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(
|
||||
|
||||
@@ -4,7 +4,7 @@ import uuid
|
||||
from collections.abc import Callable
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlmodel import Session
|
||||
|
||||
@@ -31,6 +31,7 @@ def _forbidden() -> HTTPException:
|
||||
|
||||
|
||||
def get_current_account(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Account:
|
||||
@@ -44,6 +45,9 @@ def get_current_account(
|
||||
account = session.get(Account, account_id)
|
||||
if account is None:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ _HEADER = [
|
||||
"Montant (€)",
|
||||
"Statut du paiement",
|
||||
"Payée le",
|
||||
"Transaction SumUp (code)",
|
||||
"Transaction SumUp (identifiant)",
|
||||
"Mécénat — adresse",
|
||||
"Mécénat — ville",
|
||||
"Mécénat — code postal",
|
||||
@@ -114,6 +116,8 @@ def generate_volunteers_xlsx(
|
||||
v.fee_amount if v.fee_amount is not None else "",
|
||||
_FEE_STATUS_LABELS.get(v.fee_payment_status, ""),
|
||||
_fmt_dt(v.fee_paid_at),
|
||||
_safe_text(v.fee_transaction_code or ""),
|
||||
_safe_text(v.fee_transaction_id or ""),
|
||||
_safe_text(v.mecenat_address or ""),
|
||||
_safe_text(v.mecenat_city or ""),
|
||||
_safe_text(v.mecenat_postal_code or ""),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from contextvars import ContextVar
|
||||
|
||||
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
|
||||
# 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:
|
||||
# `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.
|
||||
logging.basicConfig(
|
||||
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,
|
||||
)
|
||||
# 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)
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.api.stats import router as stats_router
|
||||
from app.api.volunteers import router as volunteer_router
|
||||
from app.config import get_settings
|
||||
from app.logging_config import configure_logging
|
||||
from app.request_logging import REQUEST_ID_HEADER, install_request_logging
|
||||
|
||||
configure_logging()
|
||||
|
||||
@@ -56,7 +57,9 @@ app.add_middleware(
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=[REQUEST_ID_HEADER],
|
||||
)
|
||||
install_request_logging(app)
|
||||
|
||||
app.include_router(health_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")
|
||||
@@ -211,6 +211,13 @@ class Volunteer(TimestampMixin, table=True):
|
||||
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
|
||||
)
|
||||
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 »
|
||||
# (`FormPaymentOption.requires_mecenat_info`), avant le paiement (voir
|
||||
# `PendingFormSubmission` et `app/api/forms.py:submit_mecenat_info`) —
|
||||
|
||||
@@ -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)
|
||||
@@ -9,6 +9,7 @@ l'inscription au formulaire public n'échoue jamais pour autant.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
@@ -24,9 +25,17 @@ class SumUpCheckout:
|
||||
id: str
|
||||
status: str # PENDING / PAID / FAILED / EXPIRED (voir FeePaymentStatus)
|
||||
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:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
resp = httpx.request(
|
||||
method,
|
||||
@@ -36,19 +45,71 @@ def _call(method: str, path: str, api_key: str, json: dict | None = None) -> dic
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.HTTPError as exc:
|
||||
data = resp.json()
|
||||
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(
|
||||
"Échec de l'appel API SumUp (%s %s) : %s", method, path, exc.__class__.__name__
|
||||
)
|
||||
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:
|
||||
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(
|
||||
id=data["id"],
|
||||
status=data["status"],
|
||||
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:
|
||||
"""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,
|
||||
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(
|
||||
"POST",
|
||||
"/v0.1/checkouts",
|
||||
|
||||
@@ -588,3 +588,24 @@ def test_mecenat_receipts_is_coordinator_only(
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
resp = as_account(lead).get(_url(edition, "/mecenat-receipts"))
|
||||
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
|
||||
|
||||
@@ -716,6 +716,56 @@ def test_paying_promotes_the_pending_submission_to_a_volunteer(
|
||||
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(
|
||||
raw_client, client, edition, monkeypatch, outbox
|
||||
):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user