Sans identifiants SumUp (code marchand + clé API) ou sans aucun type de paiement, le formulaire public n'affiche qu'une simple case « cotisation » et crée le bénévole directement : ni choix du type de paiement, ni étape d'adresse mécénat, ni paiement en ligne. Rien ne le signalait, si bien qu'une option « Mécénat » configurée semblait ne pas fonctionner. L'écran affiche désormais l'état Actif / Inactif sous « Types de paiement », avec la raison. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
61 KiB
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 |
| Disponibilité (fenêtre horaire libre) | Availability (date, start_time, end_time) |
| 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 |
Distinction critique (ne jamais confondre, ni en base ni dans l'UI)
Availability= une déclaration du bénévole : « le samedi, je peux venir de 14 h à 23 h » — fenêtre horaire libre, plusieurs par jour.Assignment= une décision de l'organisation : « tu tiens le bar samedi 18 h – 22 h ».
Deux tables distinctes, deux écrans distincts, deux vocabulaires distincts.
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) ;codefigé après création. Sert à générer les créneaux 0/0 des postes (pas les disponibilités, qui sont des fenêtres libres). CRUDGET/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 parGET .../volunteers/{id}/photo, COORD/LEAD),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 unVolunteerCompanionWish; 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 lien avecAvailability) etwilling_to_pay_fee(« je participe à la cotisation » — déclenche, lui, un vrai paiement : voirfee_payment_label/fee_amount/fee_checkout_id/fee_payment_status/fee_paid_atet le paiement SumUp plus bas). -
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. CRUDGET/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 deAssignment(décision) et dePosition. 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 compteRole.LEADde la même édition (POST/PATCH/positionsle vérifient, 422position.lead_must_be_referent_accountsinon). 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 enLEAD— à 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 compteLEADqui ne référence plus aucun poste (tous décochés) reste doncLEAD, mais/mobile-referentdé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 NULLsi 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'unPosition: la coordination crée elle-même ceux dont elle a besoin, à l'horaire de son choix (POST /editions/{id}/shifts, sans lien à unDayPart,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-shiftsgénère unShiftà 0/0 par jour de l'édition et parDayPartmanquant (bouton « Générer les créneaux », idempotent — n'écrase jamais un créneau déjà généré, même modifié depuis). -
Availability:volunteer_id,date,start_time,end_time(end_time <= start_time⇒ la fenêtre se termine le lendemain). Fenêtre horaire libre ; plusieurs par (volunteer,date). Unique (volunteer_id,date,start_time,end_time). Éditée en bloc :GET/PUT /editions/{id}/volunteers/{vid}/availability(lecture : COORD / LEAD ou le bénévole lui-même ;PUT: COORDINATION, remplace tout).GET /editions/{id}/availabilityliste celles de toute l'édition (COORD / LEAD) — pour la grille des besoins. -
Assignment:volunteer_id,shift_id,source(AssignmentSource, défautMANUAL),locked(défautfalse). 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/shiftsde 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_mmsont déduits des pixels de l'image du recto à l'import (résolution supposée 300 dpi — voirapp/badges/pdf.py:image_size_mm), donc le badge prend la forme de l'image importée ;NULLtant 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 (field_type∈FULL_NAME/POSITIONS/CATEGORY/ROLE, au plus un par édition —PUT .../badges/template/fieldsremplace tout), coordonnéesx_mm/y_mm/width_mm/height_mmen millimètres depuis le coin haut-gauche du badge,font_size,align,bold,color. Édité visuellement (glisser-déposer) depuis l'écran « Badges » du front-end (frontend/src/pages/BadgesPage.tsx), qui envoie ces mêmes millimètres et redimensionne son aperçu au format réel du badge (GET .../badges/templaterenvoiebadge_width_mm/height_mm).POSITIONSrésout une ligne par créneau affecté, triée chronologiquement : nom du poste + horaire (HH:MM–HH:MM), suivi du référent du poste (Position.lead_account_id→ son bénévole) s'il y en a un et que ce n'est pas le bénévole badgé lui-même (texte multi-lignes,\n) ;CATEGORYliste les catégories distinctes de ces mêmes postes ;ROLEvient deAccount.roledu bénévole (LEAD⇒ « Référent »).POST /editions/{id}/badges/generate(COORDINATION,{volunteer_ids?}— absent = tous) 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 (même image répétée à chaque emplacement) — impression recto-verso classique. -
Feuilles de poste (PDF,
app/sheets/pdf.py+app/api/position_sheets.py) : aucune nouvelle entité — dérivées dePosition/Shift/Assignment/Volunteerexistants. 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). -
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) — 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, voirBadgesPage.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 parVolunteer.fee_payment_status == PAIDetmecenat_addressrenseigné — voirPaiement de la cotisationplus 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 verroupg_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 ; 422mecenat.no_receiptssi aucun bénévole ne qualifie. -
FormTemplate: configuration du formulaire public d'inscription d'une édition (une ligne, créée à la demande).is_opencoupe 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/inscriptionseul — sans le jeton, impossible de deviner ou de tomber sur le lien (GET .../form/public,GET .../form/logoetPOST .../form/public/submitl'exigent en plus deedition_id, comparaison en temps constant viasecrets.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_keysont les identifiants du compte marchand SumUp pour le paiement de la cotisation (app/sumup/) —sumup_api_keyest 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 — etamounten devient le minimum ; plafond 10 000 € contre une faute de frappe, 422form.invalid_free_amountsinon,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 typesDayPart), gérée depuis l'écran « Inscriptions » (bloc « Types de paiement »). CRUDGET/POST/PATCH/DELETE /editions/{id}/form/payment-options(COORDINATION). Unique (edition_id,label) — 409form.duplicate_payment_option_labelsinon ; montant> 0requis — 422form.invalid_sumup_amountsinon. Le bénévole en choisit au plus un à l'inscription (voirPaiement de la cotisationplus bas) ; supprimer une option n'affecte jamais les bénévoles qui l'avaient déjà choisie (copie figée surVolunteer). -
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/fieldsremplace tout). Nom et prénom sont toujours collectés, indispensables à la création duVolunteer: ils ne sont pas dans cette liste, contrairement aux neuf autres. Un champ activé est obligatoire à la soumission (422import.row.missing_required_fieldsinon) —EMAIL/PHONEnon vides,AVAILABLEcoché ; 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/publicetPOST .../form/public/submit(protégés par hCaptcha,app/forms/captcha.py,HCAPTCHA_SECRET_KEY— désactivée si absente, commeEMAIL_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-paymentetPOST .../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_FEEtoute seule, que sisumup_enabledsurPublicFormConfigest 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
Volunteerest 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 duVolunteer, pour ne jamais compter en base quelqu'un qui a rempli le formulaire sans aller au bout du paiement. Si l'option choisie porterequires_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 surVolunteer.mecenat_address/mecenat_city/mecenat_postal_code/mecenat_countryune 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 SumUpPOST /v0.1/checkouts,hosted_checkout.enabled=true, montant et libellé de l'option choisie,redirect_urlpointant vers/inscription?paiement={pending_id}) renvoie son URL dansFormSubmissionResult.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_urlvaut alorsNoneet le front-end propose de réessayer (POST .../pending/{token}/retry-payment, qui ouvre un nouveau checkout — uncheckout_referencen'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 directGET .../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 leVolunteer(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 surVolunteer.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é) etfee_payment_status(PENDING/PAID/FAILED/EXPIRED,NULLtant qu'aucun checkout n'a été créé) suivent l'état ;fee_paid_atest posé à la première confirmationPAID. Une fois promue, la lignePendingFormSubmissioncorrespondante voit ses champs nominatifs vidés (RGPD : l'information ne vit plus que surVolunteer) ; 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 queapp/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). - « Je paierai sur place » (pas de paiement en ligne) : le
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 lesedition_idet sur les liens versvolunteer,shift,position,artist,account. La suppression complète d'un bénévole efface donc sesAvailability,Assignment, vœux (artistes, postes, compagnons — dans les deux sens : ceux qu'il a émis et ceux qui le visaient) et sonAccount. - Dates/heures naïves, heure locale Europe/Paris (
TZdu 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 estlead_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 |
✔ / ✔ | ✔ / — | ✔ / — |
availability lecture / écriture |
✔ / ✔ | ✔ / — | la sienne / — |
matrix, import, accounts |
✔ / ✔ / ✔ | ✔ / — / — | — |
badges (config + génération) |
✔ | — | — |
position-sheets (génération) |
✔ | — | — |
form (config) |
✔ | — | — |
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 :
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_requestsau-delà (app/auth/rate_limit.py, en mémoire, clé par email — pas par IP, voir le commentaire du module).POST /editions/{id}/auth/change-password{current_password, new_password}(compte courant). Hachage bcrypt, longueur miniMIN_LENGTH(10).
Lien magique — comptes LEAD / VOLUNTEER (sans mot de passe) :
POST /editions/{id}/auth/magic-link{email}→202constant. Si un compte sans mot de passe correspond, unMagicLinkTokenest créé (jeton aléatoire, haché SHA-256 en base, TTLmagic_link_ttl_minutes). Limité à 3 demandes/5 min par (édition, email) — même429 error.too_many_requests.- Envoi d'email stubé : le lien est écrit dans les logs (
app.auth, INFO,jeton=…— identifiants seulement, aucune donnée nominative). 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_smtpbascule automatiquement, même siSMTP_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 defrontend/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 parbuild_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 versGET .../form/logo: cette route exige désormais leaccess_tokendu 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-linkenvoie l'email contenantPUBLIC_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/bodysont 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_idsabsent ⇒ 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'unVolunteerest effectivement créé et a un email — que ce soit tout de suite (pas de paiement en ligne) ou à la promotion d'unePendingFormSubmission(paiement confirmé, voir § 2Paiement 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. 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 |
BLOCKING |
Deux créneaux d'un même bénévole se chevauchent. |
OUTSIDE_AVAILABILITY |
WARNING |
Le créneau n'est pas entièrement couvert par les fenêtres de disponibilité déclarées ce jour-là (params : date, uncovered_minutes). |
DAILY_HOURS_EXCEEDED |
WARNING |
> 8 h 00 d'affectations cumulées sur une même journée. |
INSUFFICIENT_REST |
WARNING |
< 8 h 00 entre la fin d'un créneau et le début du suivant. |
Sémantique figée :
- Toutes les affectations du bénévole comptent pour chevauchement / cumul / repos (une affectation = une décision, toujours active).
OUTSIDE_AVAILABILITY: on prend l'union des fenêtresAvailabilitydu bénévole (converties en instants, minuit géré) ; les minutes du créneau hors de cette union déclenchent l'avertissement. Aucune fenêtre déclarée ⇒ créneau entièrement à découvert.- « Journée » (cumul 8 h) : du début du premier
DayPartdu jour au début du premierDayPartdu lendemain (leDayPartne sert plus qu'à ça). - Cumul :
> 8 h 00strict (8 h 00 pile passe). - Repos :
< 8 h 00strict. - Créneaux jointifs (
end_at == start_at) : pas un chevauchement. - Une violation
BLOCKINGempêche l'enregistrement, sans dérogation. - Une violation
WARNINGest 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_idsdu meilleur au moins bon.- Seule contrainte dure : aucun chevauchement de créneaux pour un même
bénévole (règle
SHIFT_OVERLAP) — à la fois contre les affectations déjà décidées et entre deux créneaux nouvellement suggérés. L'effectif idéal est un plafond dur (le solveur ne sur-staffe jamais). - 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 signalé ensuite par un avertissement) ;
- bonus si le poste fait partie des vœux du bénévole
(
VolunteerPositionWish) ; - malus proportionnel aux minutes hors des fenêtres de disponibilité
déclarées (réutilise
validation.engine.uncovered_minutes), et si le repos avec une affectation déjà décidée serait< 8 h 00; - 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).
- Pas de plafond de cumul journalier côté solveur : contrairement à la
validation (
DAILY_HOURS_EXCEEDED, § 3), le solveur ne pénalise pas un bénévole qui dépasserait 8 h sur la journée — seule la validation le signale, à la confirmation. - Simplification assumée : repos, 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, disponibilité y compris conflit d'artiste, compagnons souhaités déjà
présents sur le créneau, charge déjà affectée ; pas de cumul 8 h / repos /
chaînage, déjà couverts par les badges de validation). 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)
- Modèle de données complet + migrations Alembic.
- Import CSV/Excel (identité des bénévoles : nom, email, téléphone,
commentaire — pas les disponibilités, qui se déclarent dans l'app), 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. - API CRUD sur
Position,Shift,Volunteer,Assignment. - Moteur de validation (section 3).
- Endpoint matrice d'une édition :
[{ position, shifts: [{ shift, min_staff, ideal_staff, assigned }] }]. - Jeu de données de démonstration : 120 bénévoles, 15 postes, 4 jours, quelques trous d'effectif, 2–3 avertissements actifs, programmation (artistes les jours de festival) et vœux des bénévoles (postes, artistes) pour que le solveur ait matière à arbitrer.
- 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
│ │ ├── api/ # routers + schemas Pydantic + deps
│ │ │ # health, auth, accounts, editions,
│ │ │ # day_parts, imports, artists, volunteers,
│ │ │ # availability, positions, shifts,
│ │ │ # assignments, solver, matrix, notifications,
│ │ │ # badges, position_sheets, forms
│ │ ├── 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.
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). Les règles « 8 h », « journée », « demi-journée » sont toutes 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) ; surmainseulement, build des imagesbackendetfrontend(registre de conteneurs intégré à Gitea) puis SSH sur le VPS → copie dedeploy/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_IDsont 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 (voirdeploy/README.md). - Secrets/variables Actions requis (voir
deploy/README.md) : secretsREGISTRY_TOKEN,SSH_KEY; variablesREGISTRY_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 commeVITE_EDITION_ID; vide ⇒ formulaire public sans vérification anti-robot). - Le VPS porte un fichier
.env(non versionné) avecPOSTGRES_USER,POSTGRES_PASSWORD,POSTGRES_DB,JWT_SECRET,PUBLIC_BASE_URL,HCAPTCHA_SECRET_KEY(optionnelle, secret hCaptcha — voir § 2,FormField) et les variablesEMAIL_ENABLED/SMTP_*(voirdeploy/README.md). backendetfrontendn'écoutent que sur127.0.0.1(voirdocker-compose.prod.yml) :deploy/Caddyfile.benevolesest 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 |
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/).