Files
benevoles/CLAUDE.md
T
AdamThomasandClaude Sonnet 5 fea9d37019 Artiste : heure de passage ; vœux d'artistes pris en compte par le solveur
Artist gagne start_time/end_time (end <= start ⇒ finit le lendemain,
même convention que Shift/DayPart/Availability). Migration 0008.

Le solveur (app/solver/) résout maintenant les fenêtres de passage des
artistes que chaque bénévole veut voir (VolunteerArtist) et pénalise
toute suggestion qui chevaucherait un passage souhaité — le bénévole
raterait l'artiste. Comme le reste du scoring, c'est un terme de
l'objectif, pas un blocage : pour un créneau urgent sans autre
candidat, le solveur propose quand même, quitte à ce que ce soit un
pis-aller.

- SolverVolunteer.wished_artist_windows (fenêtres ShiftInfo précalculées
  par le loader depuis Artist + VolunteerArtist).
- Nouveau malus _ARTIST_CONFLICT_PENALTY dans engine.py.
- Tests : moteur (conflit détecté/ignoré hors chevauchement/toujours
  suggéré en dernier recours) + intégration API bout en bout.

176 tests, ruff et alembic check au vert. CLAUDE.md mis à jour (§2 et §4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-11 18:13:54 +02:00

25 KiB
Raw Blame History

CLAUDE.md — Gestion des bénévoles

Application web de gestion des bénévoles pour un festival associatif (50–200 bénévoles, 4 jours montage/démontage inclus). Interface en français, code en anglais.

Ce fichier est le contrat de travail. Il est tenu à jour à chaque étape.


1. Vocabulaire du domaine (glossaire FR ↔ EN)

Le métier parle français, le code et la base parlent anglais. Toute entité, colonne, route et variable utilise la colonne « Code / base ».

Métier (brief) Code / base
Édition Edition
Bénévole Volunteer
Poste Position
Référent d'un poste Position.lead_account_id → Account
Créneau Shift
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)
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 (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, taille de t-shirt, régime, ni compétences. Porte deux listes de vœux, exposées par la fiche (artist_ids, wished_position_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).
  • 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. À 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.
  • 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) 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) :

  1. POST /editions/{id}/auth/magic-link {email} → 202 constant. Si un compte sans mot de passe correspond, un MagicLinkToken est créé (jeton aléatoire, haché SHA-256 en base, TTL magic_link_ttl_minutes).
  2. Envoi d'email stubé : le lien est écrit dans les logs (app.auth, INFO, jeton=… — identifiants seulement, aucune donnée nominative).
  3. POST /editions/{id}/auth/verify {token} → JWT + AccountRead. Jeton à usage unique.

GET /auth/me → compte courant.

La coordination crée les comptes via POST /editions/{id}/accounts : un COORDINATOR exige un password (≥ 10) ; un LEAD / VOLUNTEER le refuse (lien magique). Un bénévole sans email n'a pas de Account.

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), au dépassement du cumul de 8 h ce jour-là, et si le repos avec une affectation déjà décidée serait < 8 h 00 ;
    • 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 ;
    • léger malus pour les bénévoles déjà bien chargés (répartition de la charge).
    • Simplification assumée : cumul de 8 h et repos ne sont vérifiés que contre les affectations déjà décidées, pas entre deux suggestions du même calcul — la validation finale à la confirmation reste seule juge de ce cas limite.

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 ; 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.
  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
├── .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, 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 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).

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

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/).