Audit de l'application avant sa première publication sur internet. Correctifs immédiats, peu coûteux et sans changement de comportement en dev : - Le backend refuse de démarrer en `ENVIRONMENT=production` si `JWT_SECRET` est vide, trop court ou égal à la valeur par défaut versionnée dans `.env.example` — sinon n'importe qui pourrait forger un jeton de session valide (y compris pour un compte COORDINATION) sans jamais s'authentifier. Testé (`test_config.py`). - Avertissement au démarrage si `EMAIL_ENABLED=false` en production : dans ce cas le jeton de lien magique est journalisé en clair (confort de dev, cf. `api/auth.py`) — un identifiant de connexion en clair dans les logs du conteneur en public serait un vrai problème. - `frontend/nginx.conf` : plus de journal d'accès (la route `/connexion?token=…` y ferait apparaître le jeton de lien magique en clair), en-têtes `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` (empêche aussi la fuite de ce même jeton vers Google Fonts via l'en-tête Referer), `server_tokens off`. - `deploy/Caddyfile.benevoles` : mêmes en-têtes côté reverse proxy public, plus `Strict-Transport-Security`. - `deploy/README.md` : check-list sécurité avant ouverture (secret, SMTP, ne jamais mettre en production la base de démo `app.seed` — le mot de passe du compte coordination y est versionné en clair —, limites connues comme l'absence de rate-limiting sur `/auth/*`). Points vérifiés sans modification nécessaire : autorisation par rôle et cloisonnement par édition cohérents sur tous les endpoints (`owned_or_404` + vérification d'édition partout), mots de passe bcryptés, jetons de lien magique à usage unique haché en base (256 bits d'entropie), pas de PII/jeton dans les logs applicatifs (RGPD déjà en place), pas d'injection de gabarit dans les emails (substitution sur liste blanche, pas de `.format()`), pas d'injection d'en-tête SMTP (`EmailMessage` du socle Python), pas de `dangerouslySetInnerHTML` côté front, jeton JWT en `Authorization: Bearer` (pas de cookie ⇒ pas de CSRF), `.env` jamais versionné, dépendances à jour. Restent des choix à trancher avec l'utilisateur plutôt qu'à décider seul (compromis effort/risque) : rate-limiting sur les endpoints d'authentification, désactivation de `/docs` en production, conteneurs non-root. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
529 lines
32 KiB
Markdown
529 lines
32 KiB
Markdown
# 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).
|
||
- **`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`.
|
||
|
||
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` | ✔ / ✔ / ✔ | ✔ / — / — | — |
|
||
|
||
**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).
|
||
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`).
|
||
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 · constructeur de formulaire · pointage sur site ·
|
||
fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)*
|
||
|
||
---
|
||
|
||
## 6. Arborescence
|
||
|
||
```
|
||
.
|
||
├── CLAUDE.md
|
||
├── README.md
|
||
├── docker-compose.yml # dev : db + backend (hot reload)
|
||
├── .env.example
|
||
├── .gitlab-ci.yml # CI/CD : tests → build (registre GitLab) → 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 # variables CI/CD requises, 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
|
||
│ │ ├── 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)
|
||
│ │ ├── 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 GitLab. 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.
|
||
- `.gitlab-ci.yml` : à chaque push, tests (backend + frontend) ; sur `main`
|
||
seulement, build des images `backend` et `frontend` (registre de conteneurs
|
||
du projet GitLab) 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`).
|
||
- Variables CI/CD requises (voir `deploy/README.md`) : `SSH_HOST`, `SSH_USER`,
|
||
`SSH_KEY`, `DEPLOY_PATH`, `VITE_EDITION_ID`.
|
||
- Le VPS porte un fichier `.env` (non versionné) avec `POSTGRES_USER`,
|
||
`POSTGRES_PASSWORD`, `POSTGRES_DB`, `JWT_SECRET`, `PUBLIC_BASE_URL` 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 | à 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/`).
|