Files
benevoles/CLAUDE.md
T
AdamThomasandClaude Opus 5.5 ced0855d47
ci-cd / build-backend (push) Successful in 30s
ci-cd / deploy (push) Successful in 13s
ci-cd / build-frontend (push) Successful in 22s
Refonte du créateur de badges et nombre de badges par bénévole
Créateur de badges refait d'après la maquette : champs sur le recto et le
verso (BadgeField.face, verso placé en miroir dans le PDF pour le
recto-verso), fond de zone, réduction automatique du texte, photo ovale,
initiales sans photo, nom sur deux lignes (migration 0026) ; éditeur en
trois colonnes (champs, aperçu recto/verso avec de vrais bénévoles,
inspecteur), formats prédéfinis, annuler, flèches, zoom, repères de
découpe, dépôt d'image sur l'aperçu, contrôle de tous les badges avant
génération.

Nombre de badges par bénévole (Volunteer.badge_count, 1 par défaut,
migration 0027) : champ sur la fiche, autant d'exemplaires sur les
planches, total affiché à la génération, colonne dans l'export Excel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:20:42 +01:00

988 lines
75 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md — Gestion des bénévoles
Application web de gestion des bénévoles pour un festival associatif (50–200
bénévoles, 4 jours montage/démontage inclus). Interface en français, code en
anglais.
Ce fichier est le contrat de travail. Il est tenu à jour à chaque étape.
---
## 1. Vocabulaire du domaine (glossaire FR ↔ EN)
Le métier parle français, le code et la base parlent anglais. Toute entité,
colonne, route et variable utilise la colonne « Code / base ».
| Métier (brief) | Code / base |
|------------------------------------|-----------------------------------------------|
| Édition | `Edition` |
| Bénévole | `Volunteer` |
| Poste | `Position` |
| Référent d'un poste | `Position.lead_account_id` → `Account` |
| Créneau | `Shift` |
| Affectation | `Assignment` |
| Artiste programmé | `Artist` (`name`, `performance_date`, `start_time`, `end_time`) |
| Vœu « je veux voir cet artiste » | `VolunteerArtist` (N-N `Volunteer` ↔ `Artist`) |
| Vœu « je voudrais ce poste » | `VolunteerPositionWish` (N-N `Volunteer` ↔ `Position`) |
| Vœu « je voudrais être avec ce bénévole » | `VolunteerCompanionWish` (`volunteer_id`, `companion_id`, à sens unique) |
| Compte (utilisateur connecté) | `Account` |
| Découpage de journée (paramétrable)| `DayPart` (`code`, `label`, `start_time`, `end_time`, `order`) |
| Lien magique | `MagicLinkToken` |
Énumérations :
| Métier | Code |
|------------------------------------|-----------------------------------------------|
| Origine d'affectation | `SOLVER` / `MANUAL` |
| Verrouillée | `Assignment.locked: bool` |
| Rôle | `COORDINATOR` / `LEAD` / `VOLUNTEER` |
| Catégorie de poste | `BAR` / `TICKETING` / `WELCOME` / `PARKING` / `CLEANING` / `CATERING` / `SETUP` / `STAGE` / `OTHER` |
### Pas de disponibilités déclarées
Les fenêtres de disponibilité (`Availability`) ont été retirées à l'étape 42
(table supprimée, migration `0022`) : l'appli ne connaît que les
**`Assignment`**, *décisions* de l'organisation (« tu tiens le bar samedi
18 h – 22 h »). Seule subsiste la case informative « je suis disponible » du
formulaire public (`Volunteer.self_declared_available`), sans aucun effet sur
les affectations.
---
## 2. Modèle de domaine
Défini dans `backend/app/models/` (`enums.py`, `base.py`, `domain.py`).
Clés primaires `UUID`. Champs d'audit `created_at` / `updated_at` sur toutes
les tables sauf les tables d'association.
- **`Edition`** : `year`, `name` (opt.), `start_date`, `end_date`,
`retention_days` (durée de rétention des données nominatives en jours,
`NULL` = pas de purge).
- **`DayPart`** (« créneau type ») : découpage d'une journée propre à l'édition
(nombre et bornes libres). `code`, `label`, `start_time`, `end_time`
(`end_time < start_time` ⇒ passe minuit), `order`. Unique (`edition_id`,
`code`) ; `code` figé après création. Sert à générer les créneaux 0/0 des
postes.
CRUD `GET/POST/PATCH/DELETE /editions/{id}/day-parts` (COORDINATION pour
l'écriture).
- **`Volunteer`** : `email` (nullable, **pas unique** — plusieurs bénévoles
peuvent partager une même adresse, ex. un couple ou une famille ; c'est le
couple (email, nom + prénom) qui identifie un bénévole, pas l'email seul),
`first_name`, `last_name`, `phone`, `comment`. Pas de date de naissance,
taille de t-shirt, régime, ni compétences. Porte trois listes de vœux,
exposées par la fiche (`artist_ids`, `wished_position_ids`,
`wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
Renseignables aussi via le formulaire public (§ 9) : `photo`/
`photo_content_type` (image, servie séparément par
`GET .../volunteers/{id}/photo`, COORD/LEAD — la coordination peut aussi
l'ajouter, la remplacer ou la retirer depuis la fiche, section « Photo » :
`PUT`/`DELETE .../volunteers/{id}/photo`, COORDINATION, même validation
que le formulaire, `app/api/_common.py:read_photo`), `companion_wish_note` (« je
voudrais être avec » en texte libre — la personne visée n'existe pas
forcément encore en base au moment de l'inscription, donc pas de lien
direct vers un `VolunteerCompanionWish` ; la coordination la résout à la
main depuis la fiche une fois les deux bénévoles inscrits),
`self_declared_available` (case à cocher **purement informative**, sans
effet sur les affectations) et `willing_to_pay_fee` (« je participe à la
cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/
`fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le
paiement SumUp plus bas). `badge_text` : texte du badge propre à ce bénévole
(champ `FREE_TEXT`, voir `BadgeField`), saisi par la coordination sur la
fiche (« Texte du badge ») — jamais par le formulaire public — et qui
remplace pour lui le texte commun de l'éditeur. `badge_count` : nombre
d'exemplaires de son badge imprimés sur les planches (1 par défaut, 1 à
100 — `CHECK >= 1` en base, borne haute à la saisie contre une faute de
frappe), « Nombre de badges » sur la fiche.
- **`Artist`** : programmation de l'édition (`name`, `performance_date`,
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
lendemain). Unique (`edition_id`, `name`, `performance_date`). La
coordination gère la liste ; elle est présentée par jour. Les bénévoles y
choisissent les artistes qu'ils veulent voir (`volunteer_artist`) — le
solveur (section 4) évite de les affecter pendant ce passage. CRUD
`GET/POST/PATCH/DELETE /editions/{id}/artists` (lecture : tout compte de
l'édition ; écriture : COORDINATION).
- Vœux de postes : `volunteer_position_wish` (« postes que le bénévole
voudrait tenir »), distinct de `Assignment` (décision) et de `Position.
lead_account_id` (référent).
- Vœux de compagnon : `volunteer_companion_wish` (« bénévoles avec qui je
voudrais être affecté », 0 à N). **Saisi à sens unique** (A souhaite B sans
que B fasse le geste en retour) **mais traité comme réciproque par le
solveur** (section 4) : si A souhaite B, B profite aussi de la présence de
A, même s'il ne l'a rien demandé — la réciprocité est artificielle, calculée
à la volée (le loader du solveur, pas la table elle-même, qui reste à sens
unique et éditable indépendamment par chacun). `CHECK (volunteer_id <>
companion_id)` : un bénévole ne peut pas se souhaiter lui-même.
- **`Position`** : `name`, `location`, `category` (`PositionCategory`),
`lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte) —
doit désigner un compte `Role.LEAD` de la même édition (`POST`/`PATCH`
`/positions` le vérifient, 422 `position.lead_must_be_referent_account`
sinon). Se gère depuis la fiche du bénévole (`ConfigVolunteers.tsx` :
section « Référent des postes ») plutôt que depuis le poste : cocher un
poste y crée ou met à niveau (`PATCH /accounts/{id}` : voir plus bas) son
compte en `LEAD` — à sa prochaine connexion il voit l'écran référent
(`/mobile-referent`) plutôt que l'écran bénévole. Décocher ne fait que
retirer le poste, sans jamais rétrograder le compte automatiquement : un
compte `LEAD` qui ne référence plus aucun poste (tous décochés) reste donc
`LEAD`, mais `/mobile-referent` détecte ce cas et affiche alors, si le
compte est rattaché à une fiche bénévole, sa vue de bénévole classique
(`VolunteerHomeView`) plutôt qu'un écran référent vide.
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont
il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at`
(peut finir le lendemain), `min_staff`, `ideal_staff`. **Aucun créneau
n'est généré automatiquement à la création d'un `Position`** : la
coordination crée elle-même ceux dont elle a besoin, à l'horaire de son
choix (`POST /editions/{id}/shifts`, sans lien à un `DayPart`,
`day_part_id: null`) — écran « Postes & créneaux » (`ConfigGrid.tsx`), un
formulaire heure de début / heure de fin par jour. Repartir des créneaux
types reste possible en option, à la demande :
`POST /editions/{id}/positions/{pid}/generate-shifts` génère un `Shift` à
0/0 par jour de l'édition et par `DayPart` manquant (bouton « Générer les
créneaux », idempotent — n'écrase jamais un créneau déjà généré, même
modifié depuis).
- **`Assignment`** : `volunteer_id`, `shift_id`, `source` (`AssignmentSource`,
défaut `MANUAL`), `locked` (défaut `false`). Unique (`volunteer_id`,
`shift_id`). **Pas de statut** : l'affectation est valide dès sa création
(aucune étape de confirmation) ; la retirer, c'est supprimer la ligne.
`GET /editions/{id}/volunteers/{vid}/planning` (lecture : COORD / LEAD ou le
bénévole lui-même) renvoie son planning enrichi (poste, lieu, référent du
poste + son téléphone si son compte est rattaché à une fiche bénévole) sans
jamais donner à un compte VOLUNTEER l'accès en lecture à `positions`/
`shifts` de toute l'édition — c'est l'écran bénévole (`/mobile-benevole`)
qui en a besoin.
- **`Account`** : `email` (unique par édition), `role` (`Role`), `edition_id`,
`volunteer_id` (nullable, unique).
- **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`,
`used_at`.
- **`BadgeTemplate`** : recto/verso d'une édition (une ligne, créée à la
demande). `front_image`/`front_image_content_type` (fond du recto),
`back_image`/`back_image_content_type` (verso — **le même pour tous les
bénévoles**). Images stockées en base (`LargeBinary`), pas de volume de
fichiers à gérer. **Le format du badge n'est pas fixe** :
`front_image_width_mm`/`front_image_height_mm` sont déduits des pixels de
l'image du recto à l'import (résolution supposée 300 dpi — voir
`app/badges/pdf.py:image_size_mm`), donc le badge prend la forme de
l'image importée ; `NULL` tant qu'aucune image n'est importée, l'éditeur
et la génération retombant alors sur le format CR80 par défaut (85,6 ×
54 mm, `DEFAULT_BADGE_WIDTH_MM`/`HEIGHT_MM`). Le verso est ajusté (sans
déformation, `preserveAspectRatio=True`) à ce même format.
- **`BadgeField`** : un champ positionné sur le **recto ou le verso**
(`face` ∈ `FRONT` / `BACK` ; `field_type` ∈ `FULL_NAME` / `POSITIONS` /
`CATEGORY` / `ROLE` / `PHOTO` / `FREE_TEXT`, au plus un par face et par
édition — unique (`edition_id`, `face`, `field_type`), 422
`badge.duplicate_field_type` sinon ; `PUT .../badges/template/fields`
remplace tout, les deux faces dans la même liste), coordonnées `x_mm`/
`y_mm`/`width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du
badge, `font_size`, `align`, `bold`, `color`, `bg_color` (fond plein de la
zone, `NULL` = transparent, `#rrggbb` sinon 422 `badge.invalid_color`),
`auto_fit` (taille réduite par pas de 0,5 pt, plancher 4 pt, jusqu'à ce que
tout tienne — `app/badges/pdf.py:fit_text` ; sans, les lignes en trop ne
sont pas imprimées), `photo_shape` (`RECT` / `OVAL`, champ `PHOTO`).
`FREE_TEXT` et `ROLE` sont centrés verticalement dans leur cadre (règle
dérivée du type), les autres en haut. `FULL_NAME` = prénom, puis nom à la
ligne. Édité visuellement depuis l'écran « Badges »
(`frontend/src/pages/badges/` — refonte d'après `design/Créateur de
badges.dc.html` : liste des champs recto/verso, aperçu des deux faces avec
les données d'un vrai bénévole, inspecteur, formats prédéfinis via
`PUT .../template/size`, annuler, flèches, zoom, repères de découpe
1,5 mm, contrôle de tous les badges avant génération — `issues.ts`).
L'aperçu reprend la mise en page du PDF à l'identique
(`badges/layout.ts`, avec les métriques Helvetica de ReportLab recopiées
dans `helveticaMetrics.ts`, pas une mesure du navigateur) : toute
règle de mise en page changée dans `app/badges/pdf.py` doit l'être aussi
dans `layout.ts`, sinon le contrôle avant génération ment.
`POSITIONS` est un **tableau** (`app/api/badges.py:_positions_table`,
dessiné par `app/badges/pdf.py:_draw_table`) des créneaux affectés,
**regroupés par jour de festival** (§ 3 — une soirée qui passe minuit
reste sur le jour où elle a commencé) : le jour en en-tête sur toute la
largeur (« Samedi 11 »), puis une ligne par créneau, colonne horaire
(`HH:MM–HH:MM`, à sa largeur naturelle) et colonne poste (le reste,
repliée si besoin), dans l'ordre chronologique. Pas de référent sur le
badge (choix de la coordination). Une ligne qui ne tient plus dans la
hauteur du cadre n'est pas imprimée ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ;
`ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent ») ;
`FREE_TEXT` imprime `Volunteer.badge_text` s'il est renseigné, sinon le
texte commun saisi dans l'éditeur (`BadgeField.text`, ignoré pour les
autres types), sinon rien ; `PHOTO` imprime
`Volunteer.photo` dans le cadre du champ (style de texte ignoré), recadrée
au ratio du cadre sans déformation, orientation EXIF appliquée et réduite
à 300 dpi (`app/badges/pdf.py:_fit_photo` — une photo de téléphone de
plusieurs Mo répétée sur 200 badges alourdirait le PDF) ; sans photo ou
photo illisible, les **initiales** du bénévole sont imprimées à la
place (fond `#E7E0CE`, même forme) plutôt que de faire échouer la
planche ; `OVAL` découpe la photo en ellipse. Le texte d'un champ passe à la ligne pour tenir dans la largeur
de son cadre (`app/badges/pdf.py:wrap_text` — entre deux mots, ou au
milieu d'un mot trop long seul ; les `\n` restent des retours à la
ligne), et ce qui dépasse la hauteur du cadre n'est pas imprimé — comme
l'aperçu de l'éditeur, qui fait de même côté navigateur. Avant, chaque
ligne s'imprimait d'un bloc et débordait du cadre. L'éditeur ramène tout champ à l'intérieur du badge
(`badges/model.ts:fitToBadge`) et met le champ sélectionné au premier
plan : les positions par défaut visent un CR80 paysage, et sur un badge
plus étroit un champ pouvait tomber hors du badge, avec des bornes de
glisser-déposer négatives qui le bloquaient — bug corrigé une fois (photo
impossible à déplacer ni redimensionner).
`POST /editions/{id}/badges/generate` (COORDINATION, `{volunteer_ids?}` —
absent = tous ; chaque bénévole y figure `badge_count` fois, à la suite)
génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est
pour rien : juste ReportLab) : pages recto personnalisées suivies, planche
par planche, de la page de verso correspondante (image de verso puis
champs `BACK` de chaque bénévole) — impression recto-verso classique
(retournement bord long) : sur la page de verso, chaque badge est placé
**en miroir horizontal** (colonne `cols-1-col`, `_cell_origin(mirrored=
True)`) pour tomber derrière son recto. Indispensable depuis que le verso
est personnalisé ; avant, un verso identique pour tous masquait le
décalage.
- **Feuilles de poste** (PDF, `app/sheets/pdf.py` + `app/api/position_sheets.py`) :
aucune nouvelle entité — dérivées de `Position`/`Shift`/`Assignment`/
`Volunteer` existants. Complément des badges (un badge identifie un
bénévole ; une feuille identifie un poste pour la journée) : une page par
poste et par jour, avec son référent (`Position.lead_account_id`, nom +
téléphone — « à désigner » si aucun) en en-tête, puis les créneaux du jour
avec les bénévoles affectés et leur téléphone, pour affichage sur place.
`POST /editions/{id}/position-sheets/generate` (COORDINATION,
`{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune
affectation n'apparaît pas (rien à imprimer).
- **Statistiques** (`GET /editions/{id}/stats`, COORDINATION,
`app/api/stats.py` ; écran `/statistiques`, `StatsPage.tsx`) : aucune
nouvelle entité, tout est recalculé à chaque appel. Inscriptions (total,
7 derniers jours, évolution cumulée par jour d'ajout `created_at`,
jours sans inscription inclus), réponses (part des bénévoles ayant
renseigné chaque information, artistes demandés), cotisations (mode de
paiement — même règle que `lib/fee.ts:feeMode` —, montants par type,
mécénat, `PendingFormSubmission` jamais payées), besoins face aux
bénévoles : par **journée de festival** (§ 3), heures × personnes
demandées (min/idéal) vs heures affectées ; par poste, places min/idéales, places **pourvues plafonnées
créneau par créneau à l'effectif idéal** (`filled` — un sureffectif ne
compense jamais un trou ailleurs ; `assigned` garde le total brut) et
nombre de vœux. Graphiques en SVG maison (`components/charts.tsx`,
aucune bibliothèque), palette validée (daltonisme/contraste) sur le fond
`paper`, chaque graphique doublé d'une vue tableau. Le mock calcule les
mêmes chiffres (`buildStats`) et étale les dates d'inscription du jeu
de démo (`registeredAt`).
- **Export bénévoles** (Excel, `app/exporting/xlsx.py` + `GET
/editions/{id}/volunteers/export`, COORDINATION) : une ligne par
bénévole, mêmes champs que la fiche (coordonnées, vœux, cotisation,
mécénat, transaction SumUp — code et identifiant) — les vœux (postes/
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 »
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 →
`<a download>` généré côté client, voir `BadgesPage.tsx`).
- **Reçus fiscaux mécénat** (PDF, `app/mecenat/pdf.py` + `GET
/editions/{id}/volunteers/mecenat-receipts`, COORDINATION) : un cerfa
11580*05 officiel (`app/mecenat/cerfa-mecenat-particuliers.pdf`, PDF sans
champ AcroForm — les informations sont donc dessinées par-dessus la page 2
du modèle, aux coordonnées mesurées sur ce document précis) par bénévole
ayant payé un type de paiement « mécénat » (repérés par
`Volunteer.fee_payment_status == PAID` et `mecenat_address` renseigné —
voir `Paiement de la cotisation` plus bas), regroupés dans un seul PDF
(2 pages par reçu, comme le cerfa). Seuls l'encadré « Donateur » (nom,
prénom, adresse), le montant (en chiffres **et en toutes lettres**,
`app/mecenat/number_words.py:amount_in_words` — conversion française pure,
testée séparément des coordonnées PDF vu le nombre d'exceptions
orthographiques : accord de « quatre-vingts »/« cents », « et onze »
irrégulier à 71) et la date du versement sont complétés. L'encadré
« Organisme bénéficiaire » (page 1), les cases articles 200/978 et la
signature (page 2) sont **pré-remplis dans le modèle PDF lui-même** par
l'association (pas en base) : changer d'association = remplacer le
fichier, en gardant la même mise en page (les coordonnées de l'overlay
n'ont été mesurées que sur ce document). **Numéro d'ordre** du reçu
(case en haut de la page 1) : `Volunteer.mecenat_receipt_number`, unique,
`AAAA` (année du paiement) + rang sur 5 chiffres (`202600001`), une seule
numérotation pour toutes les éditions, repartant à 1 chaque année —
attribué à la confirmation du paiement (`_promote_pending`) ou, pour un
bénévole qui n'en a pas encore, au premier export (dans l'ordre des
paiements), puis **jamais réattribué** (un reçu regénéré garde son
numéro). `app/mecenat/receipts.py:assign_receipt_number`, sérialisé par
un verrou `pg_advisory_xact_lock` (webhook et sondage peuvent confirmer
deux paiements à la fois).
`pypdf` (nouvelle dépendance) fusionne l'overlay ReportLab sur la page du
modèle. Bouton « Reçus mécénat (PDF) » sur l'écran de configuration des
bénévoles, même mécanique de téléchargement que l'export Excel ;
422 `mecenat.no_receipts` si aucun bénévole ne qualifie.
- **`FormTemplate`** : configuration du formulaire public d'inscription
d'une édition (une ligne, créée à la demande). `is_open` coupe les
nouvelles soumissions sans effacer la configuration. `access_token`
(aléatoire, `secrets.token_urlsafe(16)` à la création) protège l'URL du
formulaire public : celle-ci est `/inscription/{access_token}`, jamais
`/inscription` seul — sans le jeton, impossible de deviner ou de tomber
sur le lien (`GET .../form/public`, `GET .../form/logo` et
`POST .../form/public/submit` l'exigent en plus de `edition_id`,
comparaison en temps constant via `secrets.compare_digest`, 404 générique
sinon). `POST .../form/regenerate-token` (COORDINATION) le renouvelle —
l'ancien lien cesse aussitôt de fonctionner, à repartager. Les endpoints
liés à une soumission en attente de paiement (`submit_mecenat_info`,
`get_pending_status`, `retry_pending_payment`, le webhook SumUp) ne sont
pas concernés : déjà protégés par le jeton propre à chaque soumission. `sumup_merchant_code`/
`sumup_api_key` sont les identifiants du compte marchand SumUp pour le
paiement de la cotisation (`app/sumup/`) — `sumup_api_key` est un secret,
jamais renvoyé par l'API (`FormTemplateRead.sumup_api_key_set: bool` à la
place ; chaîne vide en écriture ⇒ l'efface).
- **`FormPaymentOption`** : un type de paiement de la cotisation (ex.
« Cotisation classique », « Mécénat ») — `label` + `amount` (montant
fixe), `order`, `requires_mecenat_info` (voir plus bas), `free_amount`
(**montant libre** : le bénévole saisit lui-même son montant sur le
formulaire public — `payment_amount` à la soumission, virgule ou point,
2 décimales max — et `amount` en devient le **minimum** ; plafond
10 000 € contre une faute de frappe, 422 `form.invalid_free_amount`
sinon, `app/api/forms.py:_free_amount`. Indépendant de la case mécénat,
mais pensé pour elle : un don libre avec reçu fiscal). Liste libre par
édition, autant que la coordination veut (comme les créneaux types
`DayPart`), gérée depuis l'écran « Inscriptions » (bloc « Types de
paiement »). CRUD `GET/POST/PATCH/DELETE /editions/{id}/form/payment-options`
(COORDINATION). Unique (`edition_id`, `label`) — 409
`form.duplicate_payment_option_label` sinon ; montant `> 0` requis — 422
`form.invalid_sumup_amount` sinon. Le bénévole en choisit au plus un à
l'inscription (voir `Paiement de la cotisation` plus bas) ; supprimer une
option n'affecte jamais les bénévoles qui l'avaient déjà choisie (copie
figée sur `Volunteer`).
- **`FormField`** : un champ activé sur le formulaire (`field_type` ∈
`EMAIL` / `PHONE` / `POSITION_WISHES` / `ARTIST_WISHES` / `COMPANION_WISH`
/ `AVAILABLE` / `WILLING_TO_PAY_FEE` / `PHOTO` / `COMMENT`, au plus un par
édition — `PUT .../form/fields` remplace tout). **Nom et prénom sont
toujours collectés**, indispensables à la création du `Volunteer` : ils ne
sont pas dans cette liste, contrairement aux neuf autres. **Un champ activé
est obligatoire** à la soumission (422 `import.row.missing_required_field`
sinon) — `EMAIL`/`PHONE` non vides, `AVAILABLE` coché ; le formulaire
public n'est plus une simple collecte d'infos facultatives. Deux règles
propres à ce formulaire (pas à la fiche bénévole classique, qui reste sans
limite) : au plus **deux** vœux de poste, et au plus **un** artiste
souhaité par jour de festival (`app/api/forms.py:_check_one_artist_wish_per_day`).
`GET/PUT/PATCH /editions/{id}/form[...]` (COORDINATION) configurent ; le
reste des endpoints sous ce routeur est public (**aucune authentification**) :
`GET .../form/public` et `POST .../form/public/submit` (protégés par
hCaptcha, `app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` — désactivée si
absente, comme `EMAIL_ENABLED=false` — et un filet de sécurité global par
édition, `app/auth/rate_limit.py:form_submission_limiter`), plus, pour le
paiement en ligne (voir plus bas), `POST .../form/public/pending/{token}
/mecenat-info`, `GET .../form/public/pending/{token}`,
`POST .../form/public/pending/{token}/retry-payment` et
`POST .../form/public/sumup-webhook` (appelé par SumUp lui-même, donc sans
hCaptcha possible). Un bénévole créé directement (sans paiement en ligne)
apparaît immédiatement dans la liste de la coordination, comme n'importe
quel autre bénévole.
- **Paiement de la cotisation (SumUp)** — deux chemins selon le choix du
bénévole (le formulaire public n'affiche ce choix, à la place de la case
`WILLING_TO_PAY_FEE` toute seule, que si `sumup_enabled` sur
`PublicFormConfig` est vrai, c.-à-d. identifiants SumUp renseignés **et**
au moins un type de paiement configuré ; sinon la case reste une simple
déclaration à cocher, obligatoire comme les autres, comme avant SumUp) :
- **« Je paierai sur place »** (pas de paiement en ligne) : le `Volunteer`
est créé tout de suite, comme n'importe quelle autre soumission.
- **Un type de paiement en ligne choisi** : **rien n'est compté en base
tant que le paiement n'est pas confirmé** — la soumission crée une
ligne `PendingFormSubmission` (mêmes champs que le formulaire, plus
volatils) à la place du `Volunteer`, pour ne jamais compter en base
quelqu'un qui a rempli le formulaire sans aller au bout du paiement.
Si l'option choisie porte `requires_mecenat_info` (ex. « Mécénat ») :
`FormSubmissionResult.requires_mecenat_info=true` — le front-end
enchaîne sur une étape d'adresse postale
(`POST .../pending/{token}/mecenat-info` : `address`/`city`/
`postal_code`/`country`, tous obligatoires) avant de créer le checkout,
pour permettre l'édition d'un reçu fiscal (cerfa) plus tard ; ces
coordonnées vivent sur `Volunteer.mecenat_address`/`mecenat_city`/
`mecenat_postal_code`/`mecenat_country` une fois le bénévole créé.
Sinon, le checkout est créé tout de suite. Dans les deux cas, le
checkout hébergé (`app/sumup/client.py:create_checkout`, API SumUp
`POST /v0.1/checkouts`, `hosted_checkout.enabled=true`, montant et
libellé de l'option choisie, `redirect_url` pointant vers
`/inscription?paiement={pending_id}`) renvoie son URL dans
`FormSubmissionResult.sumup_checkout_url` — le navigateur y est
entièrement redirigé (`window.location.href`, quitte le site). Un
échec de l'appel API SumUp (réseau, identifiants invalides…) ne perd
jamais les informations déjà saisies : `sumup_checkout_url` vaut alors
`None` et le front-end propose de réessayer
(`POST .../pending/{token}/retry-payment`, qui ouvre un nouveau
checkout — un `checkout_reference` n'est réutilisable qu'une fois côté
SumUp).
Au retour du bénévole sur `/inscription?paiement={token}`, la page de
confirmation interroge en direct `GET .../form/public/pending/{token}`
(toutes les 3 s, jusqu'à 40 fois) : cet endpoint relit toujours l'état
réel du checkout auprès de SumUp (`get_checkout`) à chaque appel — jamais
seulement la dernière valeur connue en base — et **c'est cette confirmation
qui crée enfin le `Volunteer`** (`app/api/forms.py:_promote_pending`),
résolution des vœux de poste/artiste y compris (silencieusement filtrés
s'ils ont entretemps disparu — le paiement a déjà eu lieu, rien ne doit
plus faire échouer la création). Le webhook SumUp
(`POST .../form/public/sumup-webhook`, `event_type:
CHECKOUT_STATUS_CHANGED` + `id`) fait la même chose de son côté,
indépendamment (pas de dépendance stricte à ce que le navigateur reste
ouvert) ; les deux chemins partagent la même fonction d'application du
résultat, idempotente. Le libellé et le montant de l'option choisie sont
recopiés **au moment du choix** sur `Volunteer.fee_payment_label`/
`fee_amount` — une copie figée, jamais recalculée si l'option est éditée
ou supprimée ensuite. `Volunteer.fee_checkout_id` (identifiant SumUp,
jamais exposé) et `fee_payment_status` (`PENDING`/`PAID`/`FAILED`/
`EXPIRED`, `NULL` tant qu'aucun checkout n'a été créé) suivent l'état ;
`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
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
(`_prune_stale_pending`, nettoyage paresseux à chaque soumission/relance,
même principe que `app/auth/rate_limit.py`). Visible par la coordination
sur la fiche du bénévole (`ConfigVolunteers.tsx`, libellé + montant +
statut — jamais l'état de la case à cocher elle-même), en lecture seule
(pas de bascule manuelle).
Conventions techniques :
- Multi-édition : toute entité métier porte un `edition_id`.
- Énumérations stockées en `VARCHAR` + `CHECK` (`native_enum=False`), pas de
type ENUM natif PostgreSQL.
- Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les
`edition_id` et sur les liens vers `volunteer`, `shift`, `position`,
`artist`, `account`. La suppression complète d'un bénévole efface
donc ses `Assignment`, vœux (artistes, postes, compagnons —
dans les deux sens : ceux qu'il a émis et ceux qui le visaient) et son
`Account`.
- Dates/heures **naïves**, heure locale Europe/Paris (`TZ` du conteneur +
`tzdata`). `app.models.local_now()` pour les horodatages.
### Rôles et accès (`app/auth/`)
- **`COORDINATOR`** : accès complet, construit et publie les plannings.
- **`LEAD`** (référent) : lecture sur toute l'édition, écriture limitée aux
postes dont il est `lead_account_id` (et à leurs créneaux et affectations).
- **`VOLUNTEER`** : lecture de sa seule fiche et de ses seules affectations ;
aucune écriture.
Matrice appliquée par les endpoints :
| Ressource | COORDINATOR | LEAD | VOLUNTEER |
|-----------|-------------|------|-----------|
| `volunteers` liste/écriture | ✔ | liste seule | — |
| `volunteers/{id}` lecture | ✔ | ✔ | soi-même |
| `positions`/`shifts` lecture | ✔ | ✔ | — |
| `positions`/`shifts` écriture | ✔ | ses postes | — |
| `assignments` écriture | ✔ | ses postes | — |
| `assignments` lecture | ✔ | ✔ | les siennes |
| `artists` lecture / écriture | ✔ / ✔ | ✔ / — | ✔ / — |
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
| `badges` (config + génération) | ✔ | — | — |
| `position-sheets` (génération) | ✔ | — | — |
| `form` (config) | ✔ | — | — |
| `stats` | ✔ | — | — |
| `form/public`, `form/public/submit` | *(aucune authentification — voir § 2, `FormField`)* |||
**Authentification — deux systèmes selon le compte**, une même session JWT
HS256 (`Authorization: Bearer <jwt>`, TTL `session_ttl_hours`) à l'arrivée.
Le compte porte `password_hash` : défini ⇒ mot de passe, `NULL` ⇒ lien magique
(`Account.auth_method` = `"password"` / `"magic_link"`).
*Mot de passe* — comptes `COORDINATOR` :
1. `POST /editions/{id}/auth/login` `{email, password}` → JWT + `AccountRead`.
Échec = `401 auth.invalid_credentials` (aucune divulgation). Limité à 5
tentatives/minute par (édition, email) — `429 error.too_many_requests`
au-delà (`app/auth/rate_limit.py`, en mémoire, clé par email — pas par IP,
voir le commentaire du module).
2. `POST /editions/{id}/auth/change-password` `{current_password, new_password}`
(compte courant). Hachage **bcrypt**, longueur mini `MIN_LENGTH` (10).
*Lien magique* — comptes `LEAD` / `VOLUNTEER` (sans mot de passe) :
3. `POST /editions/{id}/auth/magic-link` `{email}` → `202` **constant**. Si un
compte **sans mot de passe** correspond, un `MagicLinkToken` est créé
(jeton aléatoire, **haché SHA-256** en base, TTL `magic_link_ttl_minutes`).
Limité à 3 demandes/5 min par (édition, email) — même
`429 error.too_many_requests`.
4. **Envoi d'email stubé** : le lien est écrit dans les logs (`app.auth`, INFO,
`jeton=…` — identifiants seulement, aucune donnée nominative).
5. `POST /editions/{id}/auth/verify` `{token}` → JWT + `AccountRead`. Jeton à
usage unique.
`GET /auth/me` → compte courant.
La coordination crée les comptes via `POST /editions/{id}/accounts` : un
`COORDINATOR` **exige** un `password` (≥ 10) ; un `LEAD` / `VOLUNTEER` le
**refuse** (lien magique). Un bénévole sans email n'a pas de `Account` — et
inversement, **renseigner un email sur la fiche d'un bénévole ne crée aucun
compte** : c'est un champ de la fiche, pas une inscription. Tant qu'aucun
`Account` ne lui est explicitement donné, `auth/magic-link` ne trouve
personne à qui écrire pour cet email et ne fait rien (202 quoi qu'il arrive,
pour ne jamais révéler si un compte existe). Front : bouton « Donner un accès
de connexion » sur la fiche du bénévole (`ConfigVolunteers.tsx`), qui appelle
cet endpoint avec `role=VOLUNTEER`.
`PATCH /editions/{id}/accounts/{account_id}` change uniquement le `role`, et
seulement entre `LEAD` et `VOLUNTEER` (lien magique dans les deux cas) — 422
`account.role_change_not_allowed` si `COORDINATOR` est impliqué dans un sens
ou l'autre (mot de passe en jeu, jamais touché par cette route). C'est ce qui
met à niveau le compte d'un bénévole en référent quand on le désigne
référent d'un poste (voir `Position.lead_account_id` plus haut).
### Emails (`app/email/`)
Envoi par **SMTP** (auto-hébergé). `EMAIL_ENABLED=false` (défaut dev/tests) ⇒
les emails sont seulement journalisés (`app.email`, INFO, **sans destinataire
ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
`PUBLIC_BASE_URL` (pour les liens). Ne jamais mettre de vraies valeurs dans
`config.py` : elles vivent dans `.env` (non versionné).
- Remise « best effort » : une erreur SMTP (connexion, auth, rejet) est
journalisée avec le message du serveur mais ne fait pas échouer l'appel HTTP.
- Le **port 465** implique le SSL (SMTPS) : `open_smtp` bascule
automatiquement, même si `SMTP_SSL=false` / `SMTP_STARTTLS=true`. Port 587 =
STARTTLS.
- Beaucoup de fournisseurs (OVH…) exigent une `From` = boîte authentifiée ou
alias validé.
- Diagnostic : `docker compose run --rm backend uv run python -m app.email.check
<destinataire>` — affiche la config effective et la cause exacte d'un échec.
- **Mise en forme** : chaque email part en `multipart/alternative` — texte
brut (source de vérité, utilisé par les tests) **et** une version HTML au
thème de l'appli (`app/email/html.py:render_html` — couleurs/typographie
de `frontend/tailwind.config.js`, mise en page par tableau et styles en
ligne, sans police web ni CSS externe, pour rester lisible dans le plus de
clients mail possible). Construite par `build_message`
(`app/email/sender.py`), pour **tous** les emails envoyés, sans exception.
Logo de l'édition en en-tête si elle en a importé un pour son formulaire
public (`app/email/branding.py:edition_logo`, `FormTemplate.logo`) ; à
défaut, un bandeau texte avec le sujet. Intégré en pièce jointe « inline »
(Content-ID, `LOGO_CID`) plutôt que via une URL vers `GET .../form/logo` :
cette route exige désormais le `access_token` du formulaire public (§ 2,
`FormTemplate`) — une URL externe dans un email l'aurait exposé en clair à
quiconque lit la source du message, pas seulement à son destinataire.
- **Lien magique** : `POST .../auth/magic-link` envoie l'email contenant
`PUBLIC_BASE_URL` + `magic_link_path` + `?token=`. Sans SMTP, le jeton (non
nominatif) reste journalisé pour le dev.
- **Récapitulatif de planning** : `POST /editions/{id}/notifications/planning`
(COORDINATION), corps `{ subject?, body?, volunteer_ids? }`. `subject`/`body`
sont des **modèles** avec `{prenom}`, `{nom}`, `{nb_creneaux}`, `{nb_heures}`,
`{creneaux}`, `{lien_connexion}` (si présent ⇒ un lien magique personnel est
émis pour le compte du bénévole). `volunteer_ids` absent ⇒ tous les bénévoles
de l'édition qui ont un email. Envoi en tâche de fond (`BackgroundTasks`,
une connexion SMTP réutilisée). Réponse : `{ queued, skipped_no_email,
recipients }`.
- **Confirmation d'inscription** (`app/api/forms.py`) : dès qu'un `Volunteer`
est effectivement créé et a un email — que ce soit tout de suite (pas de
paiement en ligne) ou à la promotion d'une `PendingFormSubmission`
(paiement confirmé, voir § 2 `Paiement de la cotisation`) — un email de
confirmation part en tâche de fond (`BackgroundTasks`, best effort, comme
les autres). Deux modèles (`render_registration_confirmation`/
`render_registration_paid_confirmation`, `app/email/templates.py`) : le
second ajoute le libellé et le montant payés, et tient lieu de **reçu de
paiement** — l'API SumUp Hosted Checkout utilisée ici ne documente pas de
mécanisme pour que SumUp envoie elle-même un reçu au payeur (son
« envoi automatique de reçu » vise le lecteur de carte/point de vente, pas
ce mode d'intégration), donc l'appli ne compte pas dessus.
Paiement « mécénat » (adresse postale saisie) : ce même email porte en
pièce jointe le cerfa 11580*05 rempli du bénévole
(`app/mecenat/receipts.py:receipt_for` + `generate_mecenat_receipts_pdf`,
le même rendu que l'export coordination — organisme bénéficiaire et
signature pré-remplis dans le modèle). Généré dans la tâche de fond
(`_send_paid_confirmation`) ; un échec de génération envoie l'email sans
pièce jointe plutôt que rien.
---
## 3. Moteur de validation des affectations — cœur du produit
Module isolé `backend/app/validation/`. **Tests écrits avant l'implémentation**,
un cas passant + un cas déclenchant par règle.
Retourne une liste de violations typées `BLOCKING` ou `WARNING` (aucune règle
`BLOCKING` aujourd'hui depuis l'étape 44 : le type reste prévu par le contrat
de l'API). Chaque
violation est un **code** + des paramètres structurés, jamais une phrase (la
traduction se fait via `locales/fr.json`).
| Code | Sévérité | Règle |
|---------------------------|------------|-------------------------------------------------------------|
| `SHIFT_OVERLAP` | `WARNING` | Deux créneaux d'un même bénévole se chevauchent. |
**Deux postes en même temps sont permis** (étape 44) : le chevauchement ne
bloque plus l'affectation, il remonte en avertissement dans le panneau
d'affectation (« Déjà affecté sur … au même moment »). C'est le solveur qui
évite de le proposer (malus, § 4).
**Aucune limite de temps dans la journée** (étape 42) : plus de cumul
journalier maximal (`DAILY_HOURS_EXCEEDED`, ex-8 h), plus de repos minimal
entre deux créneaux (`INSUFFICIENT_REST`, ex-8 h), plus de contrôle de
disponibilité (`OUTSIDE_AVAILABILITY`). Ni la validation ni le solveur n'en
tiennent compte.
Sémantique figée :
- Toutes les affectations du bénévole comptent pour le chevauchement (une
affectation = une décision, toujours active).
- « Journée de festival » (plus utilisée par la validation elle-même, mais par
les grilles, les feuilles de poste, les statistiques et le chaînage du
solveur) : commence au **début du plus long creux** que
laissent les `DayPart` sur le cadran de 24 h, c.-à-d. à l'heure où le
dernier créneau type de la journée se termine (Matin 08–13, Après-midi
13–19, Soirée 19–02 ⇒ journée de 02:00 à 02:00). **Indépendant de
`DayPart.order`**, qui n'est qu'un ordre d'affichage figé à la création
et ne suit pas les heures modifiées ensuite. Avant, c'était le début du
`DayPart` de plus petit `order`, ce qui pouvait donner une journée de
21:00 à 03:00 le lendemain dans les grilles (bug corrigé une fois). Sans
creux (24 h couvertes) : début du premier par `order` ; sans `DayPart` :
minuit.
**Même « journée de festival » partout** (`app/validation/engine.py:
day_boundary`/`festival_day`, miroir front `lib/planning.ts:festivalDay`) :
une soirée qui passe minuit (22h–23h, 23h–00h, 00h–01h…) reste d'un seul
tenant sur le jour où elle a commencé — grille des besoins, écran « Postes
& créneaux », feuilles de poste, chaînage du solveur. À la saisie d'un
créneau sur la carte d'un jour (`ConfigGrid.tsx`,
`shiftDatesForFestivalDay`), une heure de début avant cette borne désigne
la nuit qui suit (00:00 sur le samedi ⇒ dimanche 00:00), pas le petit
matin du même jour calendaire.
- Créneaux jointifs (`end_at == start_at`) : **pas** un chevauchement.
- Une violation `BLOCKING` empêche l'enregistrement, sans dérogation.
- Une violation `WARNING` est retournée à l'appelant mais n'empêche rien
(l'équipe connaît des exceptions légitimes).
---
## 4. Solveur de suggestions (OR-Tools CP-SAT)
Module isolé `backend/app/solver/` (mêmes conventions que `validation/` :
`models.py` types purs, `engine.py` fonction pure `solve()`, `loader.py` seul
point de contact avec la base). **Ne place jamais personne** : il calcule,
pour chaque créneau encore sous son effectif idéal, une liste ordonnée de
bénévoles candidats. La coordination affecte elle-même dans l'écran habituel
(grille des besoins) ; l'appli fait simplement remonter ces candidats en tête
de la colonne des choix quand on ouvre le détail d'un poste. Aucune
affectation n'est jamais persistée par cet endpoint.
- `POST /editions/{id}/solver/suggestions` (COORDINATION ou référent) :
recalcule à la demande (rien n'est mis en cache côté serveur) et renvoie
`{ suggestions: [{ shift_id, volunteer_ids }], shifts_considered,
volunteers_considered }`, `volunteer_ids` du meilleur au moins bon.
- **Contraintes dures** : jamais deux créneaux qui se chevauchent suggérés au
même bénévole dans un même calcul ; jamais un bénévole suggéré sur un
créneau où il est déjà affecté. L'effectif idéal est un plafond dur (le
solveur ne sur-staffe jamais). Un bénévole **déjà affecté à un autre poste
au même moment** reste proposable (la validation ne fait qu'avertir, § 3)
mais avec un fort malus (`_ALREADY_BUSY_PENALTY`, 300 — plus que tous les
bonus cumulés et que la base d'un créneau au-dessus de son minimum) : en
pratique, il n'est proposé que pour un créneau sous son effectif minimal,
et seulement faute de mieux.
- Tout le reste est un terme de l'objectif (maximisation), pas une
contrainte — cohérent avec le moteur de validation, qui reste seul juge au
moment où la coordination confirme une affectation :
- un créneau encore sous son effectif **minimal** prime largement sur tout
le reste (le solveur doit presque toujours proposer quelqu'un pour un
trou urgent, quitte à ce que ce soit un pis-aller) ;
- bonus si le poste fait partie des vœux du bénévole
(`VolunteerPositionWish`) ;
- malus si le bénévole a déjà (affectation décidée) le créneau
immédiatement précédent, le même jour — **n'importe quel poste**
(`SolverShift.predecessor_shift_ids`) : limite les enchaînements de
shifts plutôt que de faire tourner un poste en particulier ;
- malus si le créneau chevauche le passage d'un artiste que le bénévole
veut voir (`VolunteerArtist` + `Artist.start_time`/`end_time`) — lui
ferait manquer le passage ;
- bonus par compagnon souhaité (`VolunteerCompanionWish`, **réciproque** :
si A souhaite B, B en profite aussi) déjà affecté (affectation décidée)
au créneau — plusieurs compagnons présents cumulent le bonus ;
- léger malus pour les bénévoles déjà bien chargés (répartition de la
charge).
- **Aucune limite de temps** : ni cumul journalier, ni repos minimal, ni
disponibilité (§ 3).
- Simplification assumée : chaînage et compagnons ne sont vérifiés
que contre les affectations **déjà décidées**, pas entre deux suggestions
du même calcul (deux compagnons suggérés ensemble dans le même calcul ne
se renforcent pas mutuellement) — la validation finale à la confirmation
reste seule juge de ce cas limite.
**Classement par défaut, sans lancer le solveur** — `frontend/src/lib/planning.ts`
(`candidateChanceScore`) porte une version simplifiée du même scoring (vœu de
poste, conflit d'artiste, compagnons souhaités déjà présents sur le créneau,
déjà affecté ailleurs au même moment, charge déjà affectée ; pas de
chaînage). C'est ce
qui classe la colonne des candidats dans le panneau d'affectation dès
l'ouverture du détail d'un poste, sans requête réseau. Si le solveur a
effectivement tourné (`POST .../solver/suggestions`), ses bénévoles retenus
passent malgré tout en tête (ce sont eux le résultat d'une vraie optimisation
jointe, pas juste un score local).
**Colonnes de la grille des besoins** (`GrillePage.tsx`) — un vrai axe des
temps : une colonne = 30 min, bornée par jour à la plage réellement utilisée
(du créneau le plus matinal au plus tardif, arrondi à la demi-heure
englobante — pas les 24 h complètes). Les créneaux gardent des horaires
libres (`ShiftCreate`, § 2) ; ils sont replacés dans cette grille fixe en
fusionnant les cases qu'ils couvrent, arrondis à la tranche englobante s'ils
ne tombent pas pile sur un multiple de 30 min (`buildTimeGrid` /
`placeInTimeGrid` dans `frontend/src/lib/planning.ts`). **Chaque cellule
reçoit sa position de grille explicitement** (`gridColumn`/`gridRow`
calculés dans `GrillePage.tsx`, pas seulement `gridColumn: span N`) : un
poste dont les créneaux ne totalisent pas exactement le nombre de tranches
d'un jour (deux créneaux adjacents mais non alignés sur 30 min, désormais
courant depuis la création à la main) ne doit jamais décaler les lignes
suivantes — bug corrigé une fois (postes manquants au-delà du premier), un
positionnement implicite (auto-flow) recréerait la même classe de bug.
Vérifié par `scripts/smoke.tsx:checkGridLayout` (inspecte les `grid-row`
rendus, pas seulement l'absence de crash — un décalage CSS n'est pas visible
dans un simple rendu SSR). Hiérarchie de `z-index` volontairement large et
sans égalité entre les éléments sticky de la grille (coins figés 20/21 >
en-têtes jour/heure 12/11 > libellés de poste 10) : deux éléments sticky à
égalité de `z-index` peuvent se chevaucher de façon imprévisible pendant un
défilement selon le navigateur. L'infobulle au survol d'une cellule (détail
du créneau) est en `position: fixed`, positionnée par JS
(`lib/useTooltipPosition.ts`, réutilisé par `HelpTip`) plutôt qu'ancrée en
`absolute` sur la cellule : un ancrage `absolute` la découpait au bord de la
zone de défilement (`overflow-auto`) et la coinçait sous les en-têtes figés
quel que soit son propre `z-index` (un enfant ne dépasse jamais le contexte
d'empilement de son parent) — bug corrigé une fois (infobulle invisible,
« sous tout »), un ancrage `absolute`/`z-index` recréerait la même classe de
bug. Largeur adaptative par jour (`columnWidthPx`,
en fonction du nombre de tranches) : peu de tranches ⇒ colonnes larges,
beaucoup ⇒ largeur compacte avec défilement horizontal — jamais de colonnes
étroites par défaut.
La « Vue bénévoles » (`VolunteerViewPage.tsx`) reste sur l'ancien modèle,
distinct : une colonne par **créneau** (signature `starts_at`/`ends_at`)
plutôt qu'un axe temporel continu (`buildLattice` / `LatticeColumn`), plus
adapté à son tableau bénévole × créneau.
---
## 5. Périmètre
### Premier incrément (en cours)
1. Modèle de données complet + migrations Alembic.
2. Import CSV/Excel (identité des bénévoles : nom, email, téléphone,
commentaire), en deux
temps : analyse (colonnes détectées, aperçu) puis exécution avec mapping
validé. Rapport ligne par ligne. Détection des doublons : upsert sur
(`email`, `last_name` + `first_name`) — l'email seul ne suffit pas,
plusieurs bénévoles peuvent le partager (couple, famille) ; un même email
revu avec un nom différent crée un bénévole de plus (signalé, jamais
ignoré) ; sans email, détection sur (`last_name`, `first_name`) signalée
sans upsert automatique. Mode simulation qui ne persiste rien.
3. API CRUD sur `Position`, `Shift`, `Volunteer`, `Assignment`.
4. Moteur de validation (section 3).
5. Endpoint matrice d'une édition :
`[{ position, shifts: [{ shift, min_staff, ideal_staff, assigned }] }]`.
6. Jeu de données de démonstration : 120 bénévoles, 15 postes, 4 jours,
quelques trous d'effectif, programmation
(artistes les jours de festival) et vœux des bénévoles (postes, artistes)
pour que le solveur ait matière à arbitrer.
7. Solveur de suggestions OR-Tools CP-SAT (section 4) — en cours.
### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte)
SMS · pointage sur site · fonctions d'IA. *(L'envoi d'emails par SMTP est en
place — voir § 2. Le formulaire public d'inscription est en place — voir § 2
et § 9 ; ce n'était pas prévu à l'origine, retiré de cette liste à
l'étape 21.)*
---
## 6. Arborescence
```
.
├── CLAUDE.md
├── README.md
├── docker-compose.yml # dev : db + backend (hot reload)
├── .env.example
├── .gitea/workflows/ci-cd.yml # CI/CD : tests → build (registre Gitea) → SSH sur le VPS
├── deploy/
│ ├── docker-compose.prod.yml # db + backend + frontend, ports en 127.0.0.1
│ ├── Caddyfile.benevoles # bloc à ajouter au Caddy du VPS (reverse proxy)
│ └── README.md # secrets/variables CI/CD requis, mise en service du VPS
├── docker/postgres-init/ # création de la base de test au 1er boot
├── backend/
│ ├── pyproject.toml # géré par uv
│ ├── Dockerfile
│ ├── alembic.ini
│ ├── app/
│ │ ├── main.py # instance FastAPI
│ │ ├── config.py # settings (pydantic-settings)
│ │ ├── db.py # engine + session
│ │ ├── 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
│ │ │ # health, auth, accounts, editions,
│ │ │ # day_parts, imports, artists, volunteers,
│ │ │ # positions, shifts,
│ │ │ # assignments, solver, matrix, notifications,
│ │ │ # badges, position_sheets, forms, stats
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
│ │ ├── email/ # SMTP (sender) + rendu texte (templates) et HTML (html.py) + logo (branding.py)
│ │ ├── importing/ # import CSV/Excel (parser, text, service)
│ │ ├── exporting/ # export bénévoles en Excel (xlsx.py, openpyxl)
│ │ ├── models/ # tables SQLModel
│ │ ├── validation/ # moteur pur (models, engine) + loader (base)
│ │ ├── solver/ # suggestions OR-Tools (models, engine, loader)
│ │ ├── badges/ # génération PDF des planches (pdf.py, ReportLab)
│ │ ├── sheets/ # génération PDF des feuilles de poste (pdf.py)
│ │ ├── mecenat/ # reçus fiscaux cerfa (pdf.py, pypdf + ReportLab) + le cerfa officiel
│ │ ├── forms/ # hCaptcha (captcha.py) — voir api/forms.py
│ │ ├── sumup/ # client API SumUp (client.py) — paiement de la cotisation
│ │ ├── bootstrap.py # instance vierge : édition + 1er compte
│ │ ├── seed.py # jeu de démonstration (python -m app.seed)
│ │ ├── i18n/
│ │ │ ├── __init__.py # t(key, **params)
│ │ │ └── locales/fr.json
│ │ └── migrations/ # Alembic (env.py, versions/)
│ └── tests/
│ ├── conftest.py # vraie base de test PostgreSQL
│ └── ...
└── frontend/ # React + Vite + TS + Tailwind (étape ultérieure)
```
---
## 7. Commandes
Aucune dépendance locale requise hors Docker.
| Action | Commande |
|-------------------------------|-------------------------------------------------------------------------|
| Lancer en dev | `docker compose up --build` |
| API + docs interactives | http://localhost:8000/docs |
| Tests backend | `docker compose run --rm backend uv run pytest` |
| Couverture | `docker compose run --rm backend uv run pytest --cov=app` |
| Lint | `docker compose run --rm backend uv run ruff check .` |
| Format | `docker compose run --rm backend uv run ruff format .` |
| Créer une migration | `docker compose run --rm backend uv run alembic revision --autogenerate -m "..."` |
| Appliquer les migrations | `docker compose run --rm backend uv run alembic upgrade head` |
| Vérifier migration ↔ modèle | `docker compose run --rm backend uv run alembic check` (après `upgrade head`) |
| Instance vierge (édition + 1er compte) | `docker compose run --rm backend uv run python -m app.bootstrap --name … --start … --end … --email … --password …` |
| Charger le jeu de démo | `docker compose run --rm backend uv run python -m app.seed` (après `alembic upgrade head`) |
---
## 8. Conventions
- **Code, base, noms de fichiers en anglais.** Le français vit dans les
fichiers de traduction et les commits.
- **Aucun texte visible par l'utilisateur en dur.** Backend : `app.i18n.t(key)`
+ `locales/fr.json`. Frontend : fichier i18n dédié. Vrai même en monolingue.
- **RGPD :**
- 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.
- 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
Europe/Paris (heure murale). La « journée de festival » est en heure
locale.
- **Validation TDD :** tests avant implémentation, un cas passant + un cas
déclenchant par règle.
- **Git :** un commit atomique par étape validée, message en français.
- **Étapes courtes :** après chaque étape, montrer le résultat et attendre la
validation avant de continuer.
---
## 9. Déploiement
- Dépôt Gitea (instance auto-hébergée). VPS avec Docker + un Caddy déjà
installé en service système (hors de ce dépôt), qui reçoit tout le trafic
public en HTTPS et fait reverse proxy vers les conteneurs. Nécessite un
runner Gitea Actions (`act_runner`, exécuteur Docker) attaché au dépôt.
- `.gitea/workflows/ci-cd.yml` : à chaque push, tests (backend + frontend) ;
sur `main` seulement, build des images `backend` et `frontend` (registre de
conteneurs intégré à Gitea) puis SSH sur le VPS → copie de
`deploy/docker-compose.prod.yml` → `pull` → migration Alembic → `up -d`.
- Le frontend est un **build statique** (`frontend/Dockerfile.prod`, servi par
nginx) : `VITE_API_MODE`/`VITE_EDITION_ID` sont figées au moment du build,
pas à l'exécution — l'édition de production doit donc exister avant la
première construction de cette image (voir `deploy/README.md`).
- Secrets/variables Actions requis (voir `deploy/README.md`) : secrets
`REGISTRY_TOKEN`, `SSH_KEY` ; variables `REGISTRY_HOST`,
`REGISTRY_NAMESPACE`, `REGISTRY_USER`, `SSH_HOST`, `SSH_USER`,
`DEPLOY_PATH`, `VITE_EDITION_ID`, `VITE_HCAPTCHA_SITE_KEY` (optionnelle —
clé de site hCaptcha, publique, figée au build du frontend comme
`VITE_EDITION_ID` ; vide ⇒ formulaire public sans vérification anti-robot).
- Le VPS porte un fichier `.env` (non versionné) avec `POSTGRES_USER`,
`POSTGRES_PASSWORD`, `POSTGRES_DB`, `JWT_SECRET`, `PUBLIC_BASE_URL`,
`HCAPTCHA_SECRET_KEY` (optionnelle, secret hCaptcha — voir § 2, `FormField`)
et les variables `EMAIL_ENABLED` / `SMTP_*` (voir `deploy/README.md`).
- `backend` et `frontend` n'écoutent que sur `127.0.0.1` (voir
`docker-compose.prod.yml`) : `deploy/Caddyfile.benevoles` est le bloc à
ajouter au Caddyfile du serveur pour les exposer publiquement (`/api/*` vers
le backend avec le préfixe retiré, le reste vers le frontend).
---
## 10. Journal des étapes
| Étape | Description | État |
|-------|-------------------------------------------------------|----------|
| 1 | Squelette : monorepo, Docker, CI/CD, backend qui démarre | validée |
| 2 | Modèle de données complet + migration Alembic initiale | validée |
| 3 | Import CSV/Excel (analyse + exécution, rapport, doublons, simulation) | validée |
| 4 | Moteur de validation des affectations (pur, TDD, 5 règles) | validée |
| 5 | API CRUD (Position, Shift, Volunteer, Assignment) + matrice + validation branchée | validée |
| 6 | Jeu de données de démonstration (`app/seed.py`) | validée |
| 7 | Authentification : lien magique + JWT + application des rôles | validée |
| 8 | Second système : connexion mot de passe (bcrypt) pour la coordination | validée |
| 9 | Suppression du statut d'affectation (plus d'étape de confirmation) | validée |
| 10 | Envoi d'emails par SMTP : lien magique + récapitulatif de planning | validée |
| 11 | `GET`/`PATCH /editions/{id}` (nom, dates) — ajouter/retirer des jours ; UX config front | validée |
| 12 | Créneaux types (`DayPart` CRUD) + génération auto des créneaux 0/0 à la création d'un poste ; case « à définir » / « sureffectif » sur la grille | à valider |
| 13 | Bénévole : retrait taille de t-shirt / régime ; entité `Artist` (CRUD, par jour) + vœux des bénévoles (artistes, postes) | à valider |
| 14 | Retrait des compétences (`Skill` et règle `MISSING_REQUIRED_SKILL`) ; disponibilités = fenêtres horaires libres par jour (`GET`/`PUT` availability), prises en compte par `OUTSIDE_AVAILABILITY` | à valider |
| 15 | Solveur de suggestions OR-Tools CP-SAT (section 4) : `POST /editions/{id}/solver/suggestions`, candidats remontés en tête de liste dans la grille des besoins — aucune affectation automatique | à valider |
| 16 | Bénévole : l'email n'est plus unique (couple, famille) — identification par (email, nom + prénom) dans l'import et l'API, plus de rejet 409 à la création manuelle | à valider |
| 17 | Vœu de compagnon (`VolunteerCompanionWish`, 0 à N, à sens unique) : fiche bénévole, pris en compte par le solveur (bonus) et le classement par défaut | à valider |
| 18 | Gestion des référents depuis la fiche bénévole : `PATCH /accounts` (rôle LEAD ↔ VOLUNTEER), `lead_account_id` vérifié (doit être un compte référent de l'édition) | à valider |
| 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend, limiteur de tentatives sur `/auth/login` et `/auth/magic-link` | à valider |
| 20 | Planches de badges bénévoles (PDF) : recto personnalisable (fond + champs nom/poste(s)/catégorie/rôle, éditeur glisser-déposer), verso fixe (même image pour tous), écran « Badges » dédié (`/badges`, COORDINATION) | à valider |
| 21 | Formulaire public d'inscription (`/inscription/{access_token}`, sans authentification — voir étape 27) : champs activables par la coordination (email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir, vœu de compagnon en texte libre, disponibilité/cotisation déclaratives, photo, commentaire), captcha hCaptcha, ouverture/fermeture, lien à partager, écran de configuration dédié (`/inscriptions`, COORDINATION) — les bénévoles créés rejoignent directement la liste | à valider |
| 22 | Feuilles de poste (PDF, complément des badges) : une page par poste et par jour avec le référent, les créneaux, les bénévoles affectés et leur téléphone — bouton depuis la grille des besoins (COORDINATION) ; photo du bénévole affichée sur son écran et sur l'écran référent (en face du nom de chaque membre de l'équipe) | à valider |
| 23 | Créneaux : plus de génération automatique à la création d'un poste — création manuelle à horaire libre (`ConfigGrid.tsx`), génération depuis les créneaux types conservée en option (« Générer les créneaux ») ; grille des besoins redessinée en vrai axe des temps (une colonne = 30 min, bornée à la plage utilisée par jour, cases fusionnées pour chaque créneau — `buildTimeGrid`/`placeInTimeGrid`) | à valider |
| 24 | Corrections de stabilité de la grille des besoins (suite à l'étape 23) : positionnement explicite (`gridColumn`/`gridRow`) de chaque cellule (un poste aux créneaux non alignés sur 30 min ne décale plus les postes suivants) ; hiérarchie de `z-index` sans égalité entre les en-têtes figés et isolation de l'infobulle de survol dans son propre contexte d'empilement (ne recouvrait plus la ligne « Affectés / idéal » pendant un défilement) | à valider |
| 25 | Paiement de la cotisation via l'API SumUp (remplace le lien statique Yapla) : plusieurs types de paiement configurables par édition (`FormPaymentOption` — libellé + montant, ex. « Cotisation classique », « Mécénat »), inscription en deux temps pour un paiement en ligne (`PendingFormSubmission` : le `Volunteer` n'est créé qu'à la confirmation du paiement, jamais avant), étape d'adresse postale pour un type « mécénat » (cerfa), page de confirmation en direct au retour de SumUp avec relance possible après échec, tous les champs activés désormais obligatoires, statut (`PENDING`/`PAID`/`FAILED`/`EXPIRED`) tenu à jour par webhook et visible (libellé + montant + statut) sur la fiche du bénévole, email de confirmation d'inscription à la création du bénévole (tenant lieu de reçu quand un paiement est confirmé), mise en forme HTML au thème de l'appli (logo de l'édition en en-tête) pour tous les emails envoyés | à valider |
| 26 | Export Excel de la liste des bénévoles (`GET /editions/{id}/volunteers/export`, COORDINATION, `app/exporting/xlsx.py`) : coordonnées, vœux (résolus en noms lisibles), cotisation et mécénat — bouton dédié sur l'écran de configuration des bénévoles | à valider |
| 27 | Lien d'inscription protégé par jeton (`FormTemplate.access_token`, voir § 2) : l'URL du formulaire public devient `/inscription/{access_token}` (`/inscription` seul ne mène plus nulle part), bouton « Régénérer » sur l'écran de configuration (`/inscriptions`) pour révoquer un lien qui aurait fuité | à valider |
| 28 | Reçus fiscaux mécénat (`GET /editions/{id}/volunteers/mecenat-receipts`, COORDINATION, `app/mecenat/pdf.py`) : un cerfa 11580*05 officiel par bénévole ayant payé en mécénat, donateur/montant (chiffres et toutes lettres en français, `number_words.py`)/date complétés par-dessus le modèle (`pypdf` + ReportLab), organisme bénéficiaire volontairement laissé vierge — bouton dédié sur l'écran de configuration des bénévoles | à valider |
| 29 | Jeu de démonstration : 8 bénévoles avec une cotisation classique payée, 4 avec une cotisation mécénat payée (adresse complète) — `app/seed.py:_add_demo_payments` — pour que l'export Excel et les reçus fiscaux mécénat (étape 28) aient de quoi montrer quelque chose d'emblée | à valider |
| 30 | Infobulles (`HelpTip` et détail de cellule de la grille des besoins) repositionnées en `position: fixed` via JS (`lib/useTooltipPosition.ts`), bornées à l'intérieur de l'écran et basculant de côté si la place manque — corrige un centrage `absolute` qui les faisait déborder de l'écran près d'un bord, et une isolation `z-index` qui rendait celle de la grille invisible (coincée sous les en-têtes figés) | à valider |
| 31 | Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une URL (fuyait le `access_token` du formulaire public à tout destinataire — régression de l'étape 27, aucun test ne couvrait le rendu HTML du logo), sujet retiré du log de remise des emails (pouvait porter le nom d'un bénévole via un modèle personnalisé), image du formulaire public revalidée par décodage réel (`PIL`, pas seulement le `Content-Type` déclaré) avant stockage — évite qu'une fausse image fasse échouer la génération des badges/feuilles de poste, export Excel des bénévoles protégé contre l'injection de formule (CWE-1236, `app/exporting/xlsx.py:_safe_text`), sondage du statut de paiement en attente limité en fréquence par jeton (`pending_status_limiter`, évite un abus de l'API SumUp), `pyjwt` mis à jour (CVE listées non exploitables ici — HS256 à secret statique uniquement, jamais de JWKS — mais autant ne pas rester sur une version vulnérable) | à valider |
| 32 | Formulaire public : astérisque rouge sur les champs obligatoires (`RequiredMark`, `FieldLabel required`) et légende « * champ obligatoire » | à valider |
| 33 | Cerfa mécénat rempli envoyé en pièce jointe de l'email de confirmation une fois le paiement confirmé (`build_message(attachments=…)`, `app/mecenat/receipts.py`) ; modèle cerfa pré-rempli avec l'association et sa signature | à valider |
| 34 | Numéro d'ordre unique sur chaque reçu fiscal mécénat (`Volunteer.mecenat_receipt_number`, `202600001`…, migration `0020`), attribué au paiement ou au premier export puis figé | à valider |
| 35 | Type de paiement à montant libre (`FormPaymentOption.free_amount`, migration `0021`) : case « Montant libre » sur l'écran Inscriptions, montant configuré = minimum, saisie du montant sur le formulaire public (validée côté client et serveur, 10 000 € max) — pensé pour le mécénat | à valider |
| 36 | Écran Inscriptions : état « Actif / Inactif » des types de paiement (`SumUpStatus`, `FormBuilderPage.tsx`) — sans identifiants SumUp ou sans type de paiement, le formulaire public n'affiche qu'une case « cotisation » (ni choix du type, ni étape mécénat, ni paiement en ligne), ce que rien ne signalait | à valider |
| 37 | Cerfa mécénat : nouveau modèle PDF, date du versement repositionnée, date de signature remplie (date du paiement), case « Virement, prélèvement, carte bancaire » cochée (paiement SumUp) ; message de confirmation du paiement annonçant l'email et le reçu fiscal | à valider |
| 38 | Créneaux de nuit : une heure de début avant le début de journée (premier créneau type) saisie sur la carte d'un jour désigne la nuit qui suit (00:00 le samedi ⇒ dimanche 00:00) ; écran « Postes & créneaux », feuilles de poste et chaînage du solveur regroupent par journée de festival, comme la grille et la validation | à valider |
| 39 | Mode de paiement de la cotisation visible partout (`lib/fee.ts:feeMode`, `app/exporting/xlsx.py:_payment_mode`) : en ligne (type, montant, statut, mécénat), sur place, ou rien — section « Cotisation » sur la fiche (case « Paiera sur place » modifiable à la main), colonne « Cotisation » dans la liste, colonne « Mode de paiement » dans l'export Excel | à 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 |
| 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 |
| 44 | Un bénévole peut être affecté à deux postes en même temps : `SHIFT_OVERLAP` passe de `BLOCKING` à `WARNING` (affectation enregistrée, avertissement affiché) ; le solveur ne l'exclut plus mais lui applique un fort malus (`_ALREADY_BUSY_PENALTY`) — proposé seulement pour un créneau sous son minimum, faute de mieux — repris dans le classement par défaut du panneau et dans le mock | à valider |
| 45 | Responsive mobile / tablette / desktop : en-tête (onglets sur une seconde ligne en tablette, menu déroulant en mobile), panneau d'affectation de la grille des besoins et fiche bénévole en tiroir par-dessus sous `lg:` (plein écran en mobile) au lieu d'une colonne fixe qui écrasait la grille, colonne des postes de la grille rétrécie, liste des bénévoles réduite à nom + cotisation en mobile, écran « Postes & créneaux » empilé, aperçu de l'éditeur de badges mis à l'échelle de l'écran (glisser-déposer tactile), ligne des types de paiement repliée, marges réduites — desktop inchangé | à valider |
| 46 | Badges : champ « Photo du bénévole » (`PHOTO`, photo du formulaire recadrée au cadre, 300 dpi) et champ « Texte libre » (`FREE_TEXT`) alimenté par un nouveau « Texte du badge » sur la fiche bénévole (`Volunteer.badge_text`, migration `0024`, repris dans l'export Excel) | à valider |
| 47 | Photo ajoutée par la coordination : section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer), `PUT`/`DELETE /editions/{id}/volunteers/{vid}/photo` (COORDINATION), validation de l'image partagée avec le formulaire public (`read_photo`) | à valider |
| 48 | Badges : texte libre saisi dans l'éditeur (`BadgeField.text`, migration `0025`), commun à tous les badges, le « Texte du badge » de la fiche le remplaçant pour un bénévole ; champs ramenés dans le badge et champ sélectionné au premier plan (la photo restait bloquée hors d'un badge plus étroit que le CR80) | à valider |
| 49 | Badges : le texte des champs (nom, postes et horaires…) passe à la ligne pour tenir dans la largeur de son cadre au lieu de déborder (`app/badges/pdf.py:wrap_text`) | à valider |
| 50 | Badges : les créneaux affectés s'affichent en tableau — le jour de festival en en-tête, puis une colonne horaire et une colonne poste (le référent n'est plus imprimé sur le badge), filets à la couleur du champ, cellules repliées dans leur colonne ; même tableau dans l'aperçu de l'éditeur | à valider |
| 51 | Refonte du créateur de badges d'après la maquette (`design/`) : champs sur le recto **et le verso** (`BadgeField.face`, verso en miroir dans le PDF), fond de zone, réduction automatique du texte, photo ovale, initiales sans photo, nom sur deux lignes (migration `0026`) ; éditeur en 3 colonnes (champs, aperçu recto/verso avec de vrais bénévoles, inspecteur), formats prédéfinis, annuler/flèches/zoom/repères, dépôt d'image sur l'aperçu, contrôle de tous les badges avant génération | à valider |
| 52 | Nombre de badges par bénévole (`Volunteer.badge_count`, 1 par défaut, migration `0027`) : champ « Nombre de badges » sur la fiche, autant d'exemplaires sur les planches, total à imprimer affiché à la génération, colonne dans l'export Excel | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
connexion en place (voir `frontend/`).