Champs `tshirt_size` et `diet` supprimés du modèle `Volunteer`, des schémas d'API, de l'import CSV/Excel (mapping, suggestions, exécution) et du jeu de démonstration. Énum `TshirtSize` et helper `parse_tshirt_size` supprimés. Migration 0005. Clé i18n `import.row.invalid_tshirt_size` retirée. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
361 lines
20 KiB
Markdown
361 lines
20 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` |
|
||
| Compétence | `Skill` |
|
||
| Poste | `Position` |
|
||
| Référent d'un poste | `Position.lead_account_id` → `Account` |
|
||
| Créneau | `Shift` |
|
||
| Disponibilité | `Availability` |
|
||
| Affectation | `Assignment` |
|
||
| 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` |
|
||
| Disponibilité (tri-état) | `AVAILABLE` / `IF_NEEDED` / `UNAVAILABLE` |
|
||
| 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 : « je peux venir tel
|
||
jour, telle demi-journée ».
|
||
- **`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 (clé de l'import des disponibilités).
|
||
CRUD `GET/POST/PATCH/DELETE /editions/{id}/day-parts` (COORDINATION pour
|
||
l'écriture).
|
||
- **`Volunteer`** : `email` (unique **par édition**, nullable pour les
|
||
bénévoles créés à la main — plusieurs `NULL` autorisés), `first_name`,
|
||
`last_name`, `phone`, `comment`. Pas de date de naissance, ni de taille de
|
||
t-shirt / régime alimentaire.
|
||
- **`Skill`** : référentiel par édition (`code`, `label`). N-N `Volunteer`
|
||
(`volunteer_skill`). N-N `Shift` (`shift_required_skill`).
|
||
- **`Position`** : `name`, `location`, `category` (`PositionCategory`),
|
||
`lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte).
|
||
- **`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`, compétences requises
|
||
(N-N `Skill`). À 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`, `day_part_id`, `status`
|
||
(`AvailabilityStatus`). Unique (`volunteer_id`, `date`, `day_part_id`).
|
||
- **`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.
|
||
- **`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`,
|
||
`skill`, `account`. La suppression complète d'un bénévole (RGPD) efface donc
|
||
ses `Availability`, `Assignment`, liens de compétence 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 |
|
||
| `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`.
|
||
|
||
### 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` | Un `DayPart` couvert par le créneau est `UNAVAILABLE` ou sans déclaration. |
|
||
| `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. |
|
||
| `MISSING_REQUIRED_SKILL` | `WARNING` | Le bénévole n'a pas une compétence requise par le créneau. |
|
||
|
||
Sémantique figée :
|
||
|
||
- Toutes les affectations du bénévole comptent pour chevauchement / cumul /
|
||
repos (une affectation = une décision, toujours active).
|
||
- « Journée » (cumul 8 h) : du début du premier `DayPart` du jour au début du
|
||
premier `DayPart` du lendemain.
|
||
- 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. Périmètre
|
||
|
||
### Premier incrément (en cours)
|
||
|
||
1. Modèle de données complet + migrations Alembic.
|
||
2. Import CSV/Excel (un seul fichier bénévoles + disponibilités), 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` ; 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.
|
||
|
||
### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte)
|
||
|
||
Solveur OR-Tools · SMS · constructeur de formulaire · pointage sur site ·
|
||
fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)*
|
||
|
||
---
|
||
|
||
## 5. Arborescence
|
||
|
||
```
|
||
.
|
||
├── CLAUDE.md
|
||
├── README.md
|
||
├── docker-compose.yml # dev : db + backend (hot reload)
|
||
├── .env.example
|
||
├── .github/workflows/deploy.yml # CI/CD : build → GHCR → SSH sur le VPS
|
||
├── deploy/
|
||
│ ├── docker-compose.prod.yml
|
||
│ └── README.md # secrets requis, setup 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, volunteers,
|
||
│ │ │ # positions, shifts, assignments,
|
||
│ │ │ # matrix, notifications
|
||
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
|
||
│ │ ├── email/ # SMTP (sender) + rendu des emails (templates)
|
||
│ │ ├── importing/ # import CSV/Excel (parser, values, service)
|
||
│ │ ├── models/ # tables SQLModel
|
||
│ │ ├── validation/ # moteur pur (models, engine) + loader (base)
|
||
│ │ ├── 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)
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 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`) |
|
||
|
||
---
|
||
|
||
## 7. 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.
|
||
|
||
---
|
||
|
||
## 8. Déploiement
|
||
|
||
- Dépôt GitHub. VPS avec Docker.
|
||
- `.github/workflows/deploy.yml` : sur chaque push `main` → build de l'image
|
||
backend → push sur `ghcr.io/<owner>/<repo>-backend` → SSH sur le VPS →
|
||
`docker compose -f docker-compose.prod.yml pull && up -d`.
|
||
- Secrets requis (voir `deploy/README.md`) : `SSH_HOST`, `SSH_USER`,
|
||
`SSH_KEY`, `DEPLOY_PATH`, `GHCR_TOKEN`.
|
||
- 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`).
|
||
|
||
---
|
||
|
||
## 9. 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 |
|
||
|
||
**Reste hors périmètre** (modèle prêt à les accueillir) : solveur OR-Tools,
|
||
SMS, constructeur de formulaire, pointage sur site, IA. Frontend :
|
||
squelette + 6 écrans + connexion en place (voir `frontend/`).
|