Files
benevoles/CLAUDE.md
T
AdamThomasandClaude Opus 5.5 cc8c2e61ab
ci-cd / build-backend (push) Successful in 29s
ci-cd / build-frontend (push) Successful in 7s
ci-cd / deploy (push) Successful in 11s
Fiches de poste : message de l'organisation en bas de chaque page
Edition.volunteer_message imprimé en pied de chaque page (suite d'une
feuille comprise), la marge du bas agrandie de sa hauteur ; rien sans
message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:11:33 +01:00

84 KiB
Raw Blame History

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), volunteer_message (message de l'organisation, le même pour tous les bénévoles, affiché en bas de l'écran bénévole — saisi par la coordination, onglet « Message aux bénévoles » de la configuration, ConfigMessage.tsx, via PATCH /editions/{id}) et volunteer_message_updated_at (date affichée sous le message). Même règle que Position.message : app/api/_common.py: set_message — bords retirés, vide ⇒ NULL, date posée seulement si le texte change, 2 000 caractères au plus.

  • 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. Un référent peut aussi être affecté comme bénévole (même soirée comprise) : si son compte est rattaché à une fiche bénévole, /mobile-referent a deux onglets, « Mon équipe » et « Mon planning » (sa vue bénévole), ouvert sur « Mon planning » quand il tient en ce moment un créneau de bénévole hors de ses postes sans qu'aucun de ses postes ait un créneau en cours ; dans « Mon équipe », il est marqué « TOI » s'il fait partie de l'équipe pointée.

  • Position.message / message_updated_at : message du poste (consignes : tenue, matériel, rendez-vous…), saisi par le référent depuis son écran (/mobile-referent, « Message à ton équipe ») ou par la coordination (ConfigMessage.tsx), via PATCH .../positions/{id} (même contrôle d'écriture que le reste du poste : COORDINATION ou son référent). Montré à chaque bénévole affecté au poste : repris dans GET .../volunteers/{vid}/planning (position_message, position_message_updated_at).

  • 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. Pointage : attendance (AttendanceStatus PRESENT / ABSENT, NULL = pas pointé) et attendance_marked_at (heure du dernier pointage), posés par PUT .../assignments/{id}/attendance ({attendance}, null annule — même contrôle d'écriture que l'affectation : COORDINATION ou référent du poste). Écran référent : un second appui sur le même bouton annule, mise à jour immédiate à l'écran puis enregistrée (hooks.ts:useSetAttendance, retour arrière et message si l'enregistrement échoue). Ce n'est pas le statut d'une affectation (toujours valide dès sa création), juste la présence constatée sur place. Avant l'étape 58, ce pointage n'était gardé que dans le navigateur et se perdait au rechargement.

  • 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), z_order (ordre d'empilement sur la face : les champs sont dessinés du plus petit au plus grand, à égalité la photo dessous — avant, l'ordre alphabétique des types faisait passer la photo par-dessus le nom et le texte libre ; l'éditeur renvoie toujours des rangs distincts 0…n-1, badges/model.ts:stackOrder/restack, boutons « Superposition » de l'inspecteur, un champ activé arrive au-dessus, la photo dessous ; l'aperçu suit le même ordre, le cadre du champ sélectionné restant par-dessus pour qu'un champ recouvert se saisisse encore). 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, rien n'est imprimé (cadre vide, choix de la coordination — avant l'étape 61, les initiales sur fond beige), sans faire échouer la planche ; l'éditeur n'y montre qu'un repère pointillé « sans photo », non imprimé ; 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). Le message de l'organisation (Edition.volunteer_message, s'il y en a un) est imprimé en pied de chaque page, suite d'une feuille comprise (SheetFooter, marge du bas agrandie de sa hauteur pour que le contenu ne passe jamais dessous). Photo de chaque bénévole en vignette de 14 mm : réduite à 300 dpi (app/sheets/pdf.py:_thumbnail, décodage JPEG à taille réduite via draft) une seule fois par PDF, une photo illisible laissant la case vide. Avant l'étape 62, la photo d'origine (plusieurs Mo, prise au téléphone) était intégrée telle quelle pour chaque bénévole de chaque créneau : génération de plusieurs minutes, serveur à 300 % de CPU, PDF énorme et « erreur inattendue » (bug corrigé une fois). Même principe pour les badges (_fit_photo + PhotoCache : une photo recadrée une fois par génération, malgré recto, verso et exemplaires multiples).

  • 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). Présence (attendance, _attendance / mock buildAttendance) : présents, absents et non pointés (Assignment.attendance) par poste, sur les créneaux terminés et sur ceux en cours à as_of (créneaux à venir ignorés), avec le taux de présence parmi les bénévoles pointés ; la section passe en tête de l'écran tant qu'un créneau est en cours, et l'écran se rafraîchit toutes les 30 s (useStats, refetchInterval).

  • 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) :

  1. 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.
  2. Envoi d'email stubé : le lien est écrit dans les logs (app.auth, INFO, jeton=… — identifiants seulement, aucune donnée nominative).
  3. 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 par bénévole » (VolunteerViewPage.tsx) utilise le même axe des temps (buildTimeGrid / placeInTimeGrid, même largeur de tranche par jour) : chaque affectation est une barre étirée sur toute la durée de son créneau, nom du poste dedans. Deux postes tenus en même temps s'empilent sur deux lignes dans la rangée du bénévole (qui s'agrandit) et passent en jaune. Ligne verticale orange à l'heure actuelle, avec l'heure dans l'en-tête des tranches. Avant l'étape 60, une colonne par créneau distinct (buildLattice, supprimé) : une affectation n'apparaissait que dans la colonne de son propre créneau, jamais sur les autres colonnes qu'elle couvrait, et deux postes simultanés se masquaient.


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 · 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. Le pointage présent / absent par le référent est en place depuis l'étape 58 — Assignment.attendance, § 2.)


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
53 Vue par bénévole : tri des lignes par nom ou par total d'heures affectées, croissant / décroissant (boutons « Trier par » et clic sur l'en-tête « TOTAL H ») — à total égal, l'ordre par nom est conservé à valider
54 Badges : ordre de superposition des champs (BadgeField.z_order, migration 0028 — champs existants renumérotés photo dessous) : boutons « Premier plan / Avancer / Reculer / Arrière-plan » dans l'inspecteur, aperçu et PDF dessinés dans cet ordre — la photo recouvrait le texte à valider
55 Écrans bénévole et référent redessinés d'après la maquette (design/Planning bénévole mobile.dc.html, pensée pour le téléphone — tablette et desktop gardent la même colonne, centrée) : bénévole — créneau en cours ou prochain mis en avant (compte à rebours, lieu, chevauchement, bouton « Appeler » le référent), planning par journée de festival avec frise horaire et statut de chaque créneau (terminé / en cours), message de fin de festival ; référent — onglets de postes, choix du créneau (en cours ou prochain par défaut), compteurs présents / attendus / absents, alerte d'absence, pointage (toujours local à l'écran) avec appel direct. Pas encore repris de la maquette, faute de donnée : téléphone de la coordination, bouton « Demander un renfort » (consigne du poste et message de l'organisation : étape 56) à valider
56 Messages aux bénévoles : message de l'organisation, le même pour tous (Edition.volunteer_message, onglet « Message aux bénévoles » de la configuration), et message de chaque poste (Position.message, saisi par le référent sur son écran ou par la coordination), migration 0029 — affichés sur l'écran bénévole (bloc « Message de l'organisation » daté, consignes du poste sur le créneau mis en avant et dans la liste) à valider
57 Référent aussi bénévole : onglets « Mon équipe » / « Mon planning » sur l'écran référent (compte rattaché à une fiche bénévole), ouverture sur celui qui correspond à ce qu'il fait en ce moment, pastille sur l'onglet dont un créneau est en cours, mention « TOI » dans l'équipe pointée à valider
58 Pointage enregistré : présent / absent du référent sauvegardé sur l'affectation (Assignment.attendance + heure, migration 0030, PUT .../assignments/{id}/attendance, COORDINATION ou référent du poste) — il survivait seulement jusqu'au rechargement de la page ; heure du pointage affichée, message si l'enregistrement échoue à valider
59 Statistiques de présence : section « Présence » de l'écran Statistiques — en ce moment (créneaux en cours : attendus, présents, absents, non pointés par poste) et créneaux terminés (même détail + taux de présence parmi les pointés), rafraîchie toutes les 30 s, en tête de l'écran pendant un créneau à valider
60 Vue par bénévole sur un vrai axe des temps (tranches de 30 min par jour, comme la grille des besoins) : affectations étirées sur toute leur durée avec le nom du poste, deux postes simultanés empilés sur deux lignes et signalés en jaune, ligne verticale orange de l'heure actuelle avec l'heure — une affectation n'apparaissait que dans la colonne de son propre créneau à valider
61 Badges : sans photo, le cadre de la photo reste vide (plus d'initiales sur fond beige) — PDF, aperçu de l'éditeur (repère pointillé non imprimé) et messages de contrôle à valider
62 Feuilles de poste : génération interminable (CPU à 300 %, puis « erreur inattendue ») — photos d'origine intégrées en pleine résolution pour chaque bénévole de chaque créneau ; désormais réduites à la vignette une fois par PDF (40 photos : 15 s / 24 Mo ⇒ 0,2 s / 109 Ko), photo illisible ignorée. Badges : photo recadrée une seule fois par génération et décodée à taille réduite (120 badges : 10 s ⇒ 0,3 s) à valider
63 Feuilles de poste : message de l'organisation en bas de chaque page (titre + texte, retours à la ligne conservés), rien si aucun message à valider

Reste hors périmètre (modèle prêt à les accueillir) : SMS, constructeur de formulaire, IA. Frontend : squelette + 6 écrans + connexion en place (voir frontend/).