Files
benevoles/CLAUDE.md
T
AdamThomasandClaude Sonnet 5 40127a2252 Étape 22 : feuilles de poste (PDF), complément des badges
Une page par poste et par jour, listant les créneaux avec les bénévoles
affectés et leur téléphone — à imprimer et afficher sur place. Aucune
nouvelle entité : dérivé de Position/Shift/Assignment/Volunteer
existants (contrairement aux badges, pas de personnalisation à
configurer). Un créneau à besoin 0/0 sans affectation n'apparaît pas.

Backend : app/sheets/pdf.py (ReportLab, Platypus — SimpleDocTemplate/
Table plutôt que le canvas bas niveau des badges, plus adapté à un
rapport texte multi-pages), POST /editions/{id}/position-sheets/generate
(COORDINATION, {position_ids?} — absent = tous les postes).

Frontend : bouton « Feuilles de poste » dans la grille des besoins,
respecte le filtre de catégorie déjà actif à l'écran.

Vérifié : pytest (284 passés), ruff, alembic check, tsc --noEmit,
eslint, vite build, smoke (flux de bout en bout en mode démonstration)
— tous verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 21:27:01 +02:00

624 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md — Gestion des bénévoles
Application web de gestion des bénévoles pour un festival associatif (50–200
bénévoles, 4 jours montage/démontage inclus). Interface en français, code en
anglais.
Ce fichier est le contrat de travail. Il est tenu à jour à chaque étape.
---
## 1. Vocabulaire du domaine (glossaire FR ↔ EN)
Le métier parle français, le code et la base parlent anglais. Toute entité,
colonne, route et variable utilise la colonne « Code / base ».
| Métier (brief) | Code / base |
|------------------------------------|-----------------------------------------------|
| Édition | `Edition` |
| Bénévole | `Volunteer` |
| Poste | `Position` |
| Référent d'un poste | `Position.lead_account_id` → `Account` |
| Créneau | `Shift` |
| 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`) ; `code` figé 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).
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), `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`/`willing_to_pay_fee` (cases à cocher **purement
informatives**, sans lien avec `Availability` ni aucun workflow de
paiement).
- **`Artist`** : programmation de l'édition (`name`, `performance_date`,
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
lendemain). Unique (`edition_id`, `name`, `performance_date`). La
coordination gère la liste ; elle est présentée par jour. Les bénévoles y
choisissent les artistes qu'ils veulent voir (`volunteer_artist`) — le
solveur (section 4) évite de les affecter pendant ce passage. CRUD
`GET/POST/PATCH/DELETE /editions/{id}/artists` (lecture : tout compte de
l'édition ; écriture : COORDINATION).
- Vœux de postes : `volunteer_position_wish` (« postes que le bénévole
voudrait tenir »), distinct de `Assignment` (décision) et de `Position.
lead_account_id` (référent).
- Vœux de compagnon : `volunteer_companion_wish` (« bénévoles avec qui je
voudrais être affecté », 0 à N). **Saisi à sens unique** (A souhaite B sans
que B fasse le geste en retour) **mais traité comme réciproque par le
solveur** (section 4) : si A souhaite B, B profite aussi de la présence de
A, même s'il ne l'a rien demandé — la réciprocité est artificielle, calculée
à la volée (le loader du solveur, pas la table elle-même, qui reste à sens
unique et éditable indépendamment par chacun). `CHECK (volunteer_id <>
companion_id)` : un bénévole ne peut pas se souhaiter lui-même.
- **`Position`** : `name`, `location`, `category` (`PositionCategory`),
`lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte) —
doit désigner un compte `Role.LEAD` de la même édition (`POST`/`PATCH`
`/positions` le vérifient, 422 `position.lead_must_be_referent_account`
sinon). Se gère depuis la fiche du bénévole (`ConfigVolunteers.tsx` :
section « Référent des postes ») plutôt que depuis le poste : cocher un
poste y crée ou met à niveau (`PATCH /accounts/{id}` : voir plus bas) son
compte en `LEAD` — à sa prochaine connexion il voit l'écran référent
(`/mobile-referent`) plutôt que l'écran bénévole. Décocher ne fait que
retirer le poste, sans jamais rétrograder le compte automatiquement : un
compte `LEAD` qui ne référence plus aucun poste (tous décochés) reste donc
`LEAD`, mais `/mobile-referent` détecte ce cas et affiche alors, si le
compte est rattaché à une fiche bénévole, sa vue de bénévole classique
(`VolunteerHomeView`) plutôt qu'un écran référent vide.
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont
il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at`
(peut finir le lendemain), `min_staff`, `ideal_staff`. À la création d'un
`Position`, un `Shift` à 0/0 est généré par jour de l'édition et par
`DayPart` ; `POST /editions/{id}/positions/{pid}/generate-shifts` recrée les
manquants (après ajout d'un créneau type ou extension des dates).
- **`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}/availability` liste celles de toute l'édition (COORD /
LEAD) — pour la grille des besoins.
- **`Assignment`** : `volunteer_id`, `shift_id`, `source` (`AssignmentSource`,
défaut `MANUAL`), `locked` (défaut `false`). Unique (`volunteer_id`,
`shift_id`). **Pas de statut** : l'affectation est valide dès sa création
(aucune étape de confirmation) ; la retirer, c'est supprimer la ligne.
`GET /editions/{id}/volunteers/{vid}/planning` (lecture : COORD / LEAD ou le
bénévole lui-même) renvoie son planning enrichi (poste, lieu, référent du
poste + son téléphone si son compte est rattaché à une fiche bénévole) sans
jamais donner à un compte VOLUNTEER l'accès en lecture à `positions`/
`shifts` de toute l'édition — c'est l'écran bénévole (`/mobile-benevole`)
qui en a besoin.
- **`Account`** : `email` (unique par édition), `role` (`Role`), `edition_id`,
`volunteer_id` (nullable, unique).
- **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`,
`used_at`.
- **`BadgeTemplate`** : recto/verso d'une édition (une ligne, créée à la
demande). `front_image`/`front_image_content_type` (fond du recto),
`back_image`/`back_image_content_type` (verso — **le même pour tous les
bénévoles**). Images stockées en base (`LargeBinary`), pas de volume de
fichiers à gérer. **Le format du badge n'est pas fixe** :
`front_image_width_mm`/`front_image_height_mm` sont déduits des pixels de
l'image du recto à l'import (résolution supposée 300 dpi — voir
`app/badges/pdf.py:image_size_mm`), donc le badge prend la forme de
l'image importée ; `NULL` tant qu'aucune image n'est importée, l'éditeur
et la génération retombant alors sur le format CR80 par défaut (85,6 ×
54 mm, `DEFAULT_BADGE_WIDTH_MM`/`HEIGHT_MM`). Le verso est ajusté (sans
déformation, `preserveAspectRatio=True`) à ce même format.
- **`BadgeField`** : un champ positionné sur le recto (`field_type` ∈
`FULL_NAME` / `POSITIONS` / `CATEGORY` / `ROLE`, au plus un par édition —
`PUT .../badges/template/fields` remplace tout), 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`. É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/template` renvoie `badge_width_mm`/`height_mm`).
`POSITIONS` ré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`) ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ;
`ROLE` vient de `Account.role` du 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 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, listant 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).
- **`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.
- **`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. 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 ;
`GET /editions/{id}/form/public` et `POST .../form/public/submit` sont les
**deux seuls endpoints de toute l'API sans authentification** — protégés
par hCaptcha (`app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` ; vérification
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`,
hCaptcha reste la défense principale). Le bénévole créé par une soumission
apparaît immédiatement dans la liste de la coordination, comme n'importe
quel autre bénévole.
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 (RGPD) efface
donc ses `Availability`, `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 | ✔ / ✔ | ✔ / — | ✔ / — |
| `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` :
1. `POST /editions/{id}/auth/login` `{email, password}` → JWT + `AccountRead`.
Échec = `401 auth.invalid_credentials` (aucune divulgation). Limité à 5
tentatives/minute par (édition, email) — `429 error.too_many_requests`
au-delà (`app/auth/rate_limit.py`, en mémoire, clé par email — pas par IP,
voir le commentaire du module).
2. `POST /editions/{id}/auth/change-password` `{current_password, new_password}`
(compte courant). Hachage **bcrypt**, longueur mini `MIN_LENGTH` (10).
*Lien magique* — comptes `LEAD` / `VOLUNTEER` (sans mot de passe) :
3. `POST /editions/{id}/auth/magic-link` `{email}` → `202` **constant**. Si un
compte **sans mot de passe** correspond, un `MagicLinkToken` est créé
(jeton aléatoire, **haché SHA-256** en base, TTL `magic_link_ttl_minutes`).
Limité à 3 demandes/5 min par (édition, email) — même
`429 error.too_many_requests`.
4. **Envoi d'email stubé** : le lien est écrit dans les logs (`app.auth`, INFO,
`jeton=…` — identifiants seulement, aucune donnée nominative).
5. `POST /editions/{id}/auth/verify` `{token}` → JWT + `AccountRead`. Jeton à
usage unique.
`GET /auth/me` → compte courant.
La coordination crée les comptes via `POST /editions/{id}/accounts` : un
`COORDINATOR` **exige** un `password` (≥ 10) ; un `LEAD` / `VOLUNTEER` le
**refuse** (lien magique). Un bénévole sans email n'a pas de `Account` — et
inversement, **renseigner un email sur la fiche d'un bénévole ne crée aucun
compte** : c'est un champ de la fiche, pas une inscription. Tant qu'aucun
`Account` ne lui est explicitement donné, `auth/magic-link` ne trouve
personne à qui écrire pour cet email et ne fait rien (202 quoi qu'il arrive,
pour ne jamais révéler si un compte existe). Front : bouton « Donner un accès
de connexion » sur la fiche du bénévole (`ConfigVolunteers.tsx`), qui appelle
cet endpoint avec `role=VOLUNTEER`.
`PATCH /editions/{id}/accounts/{account_id}` change uniquement le `role`, et
seulement entre `LEAD` et `VOLUNTEER` (lien magique dans les deux cas) — 422
`account.role_change_not_allowed` si `COORDINATOR` est impliqué dans un sens
ou l'autre (mot de passe en jeu, jamais touché par cette route). C'est ce qui
met à niveau le compte d'un bénévole en référent quand on le désigne
référent d'un poste (voir `Position.lead_account_id` plus haut).
### Emails (`app/email/`)
Envoi par **SMTP** (auto-hébergé). `EMAIL_ENABLED=false` (défaut dev/tests) ⇒
les emails sont seulement journalisés (`app.email`, INFO, **sans destinataire
ni corps** — RGPD). 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.
- **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 }`.
---
## 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êtres `Availability` du
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 `DayPart` du jour au début du
premier `DayPart` du lendemain (le `DayPart` ne sert plus qu'à ça).
- Cumul : `> 8 h 00` strict (8 h 00 pile passe).
- Repos : `< 8 h 00` strict.
- 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.
- **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** — une colonne représente un **créneau**
distinct (`starts_at` + `ends_at`), pas seulement une heure de début : deux
créneaux qui démarrent en même temps mais durent différemment ont chacun leur
colonne (`buildLattice` / `LatticeColumn` dans `frontend/src/lib/planning.ts`).
Largeur adaptative par jour (`columnWidthPx`) : peu de créneaux dans une
journée ⇒ colonnes larges, beaucoup de créneaux ⇒ largeur compacte avec
défilement horizontal — jamais de colonnes étroites par défaut.
---
## 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 — 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.
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, 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.
7. Solveur de suggestions OR-Tools CP-SAT (section 4) — en cours.
### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte)
SMS · pointage sur site · fonctions d'IA. *(L'envoi d'emails par SMTP est en
place — voir § 2. Le formulaire public d'inscription est en place — voir § 2
et § 9 ; ce n'était pas prévu à l'origine, retiré de cette liste à
l'étape 21.)*
---
## 6. Arborescence
```
.
├── CLAUDE.md
├── README.md
├── docker-compose.yml # dev : db + backend (hot reload)
├── .env.example
├── .gitea/workflows/ci-cd.yml # CI/CD : tests → build (registre Gitea) → SSH sur le VPS
├── deploy/
│ ├── docker-compose.prod.yml # db + backend + frontend, ports en 127.0.0.1
│ ├── Caddyfile.benevoles # bloc à ajouter au Caddy du VPS (reverse proxy)
│ └── README.md # secrets/variables CI/CD requis, mise en service du VPS
├── docker/postgres-init/ # création de la base de test au 1er boot
├── backend/
│ ├── pyproject.toml # géré par uv
│ ├── Dockerfile
│ ├── alembic.ini
│ ├── app/
│ │ ├── main.py # instance FastAPI
│ │ ├── config.py # settings (pydantic-settings)
│ │ ├── db.py # engine + session
│ │ ├── logging_config.py # RGPD : pas de PII dans les logs
│ │ ├── 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 des emails (templates)
│ │ ├── importing/ # import CSV/Excel (parser, text, service)
│ │ ├── 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)
│ │ ├── forms/ # hCaptcha (captcha.py) — voir api/forms.py
│ │ ├── 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) ;
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`, sans authentification) : 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 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 |
**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/`).