Remplace le simple lien Yapla statique par une vraie intégration API SumUp : checkout hébergé créé à la soumission du formulaire, statut de paiement (PENDING/PAID/FAILED/EXPIRED) tenu à jour par un webhook SumUp et visible sur la fiche du bénévole. La cotisation n'est plus un montant unique : la coordination configure autant de types de paiement qu'elle veut (libellé + montant, ex. « Cotisation classique », « Mécénat »), et le bénévole en choisit un à l'inscription — le libellé et le montant choisis sont figés sur le bénévole, indépendamment d'une modification ultérieure de la config. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
708 lines
47 KiB
Markdown
708 lines
47 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).
|
||
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` (case à cocher **purement informative**, sans
|
||
lien avec `Availability`) et `willing_to_pay_fee` (« je participe à la
|
||
cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/
|
||
`fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le
|
||
paiement SumUp plus bas).
|
||
- **`Artist`** : programmation de l'édition (`name`, `performance_date`,
|
||
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
|
||
lendemain). Unique (`edition_id`, `name`, `performance_date`). La
|
||
coordination gère la liste ; elle est présentée par jour. Les bénévoles y
|
||
choisissent les artistes qu'ils veulent voir (`volunteer_artist`) — le
|
||
solveur (section 4) évite de les affecter pendant ce passage. 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`. **Aucun créneau
|
||
n'est généré automatiquement à la création d'un `Position`** : la
|
||
coordination crée elle-même ceux dont elle a besoin, à l'horaire de son
|
||
choix (`POST /editions/{id}/shifts`, sans lien à un `DayPart`,
|
||
`day_part_id: null`) — écran « Postes & créneaux » (`ConfigGrid.tsx`), un
|
||
formulaire heure de début / heure de fin par jour. Repartir des créneaux
|
||
types reste possible en option, à la demande :
|
||
`POST /editions/{id}/positions/{pid}/generate-shifts` génère un `Shift` à
|
||
0/0 par jour de l'édition et par `DayPart` manquant (bouton « Générer les
|
||
créneaux », idempotent — n'écrase jamais un créneau déjà généré, même
|
||
modifié depuis).
|
||
- **`Availability`** : `volunteer_id`, `date`, `start_time`, `end_time`
|
||
(`end_time <= start_time` ⇒ la fenêtre se termine le lendemain). Fenêtre
|
||
horaire **libre** ; plusieurs par (`volunteer`, `date`). Unique
|
||
(`volunteer_id`, `date`, `start_time`, `end_time`). Éditée en bloc :
|
||
`GET`/`PUT /editions/{id}/volunteers/{vid}/availability` (lecture : COORD /
|
||
LEAD ou le bénévole lui-même ; `PUT` : COORDINATION, remplace tout).
|
||
`GET /editions/{id}/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, avec son référent (`Position.lead_account_id`, nom +
|
||
téléphone — « à désigner » si aucun) en en-tête, puis les créneaux du jour
|
||
avec les bénévoles affectés et leur téléphone, pour affichage sur place.
|
||
`POST /editions/{id}/position-sheets/generate` (COORDINATION,
|
||
`{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune
|
||
affectation n'apparaît pas (rien à imprimer).
|
||
- **`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. `sumup_merchant_code`/
|
||
`sumup_api_key` sont les identifiants du compte marchand SumUp pour le
|
||
paiement de la cotisation (`app/sumup/`) — `sumup_api_key` est un secret,
|
||
jamais renvoyé par l'API (`FormTemplateRead.sumup_api_key_set: bool` à la
|
||
place ; chaîne vide en écriture ⇒ l'efface).
|
||
- **`FormPaymentOption`** : un type de paiement de la cotisation (ex.
|
||
« Cotisation classique », « Mécénat ») — `label` + `amount` (montant
|
||
fixe), `order`. Liste libre par édition, autant que la coordination veut
|
||
(comme les créneaux types `DayPart`), gérée depuis l'écran « Inscriptions »
|
||
(bloc « Types de paiement »). CRUD `GET/POST/PATCH/DELETE
|
||
/editions/{id}/form/payment-options` (COORDINATION). Unique
|
||
(`edition_id`, `label`) — 409 `form.duplicate_payment_option_label` sinon ;
|
||
montant `> 0` requis — 422 `form.invalid_sumup_amount` sinon. Le bénévole
|
||
en choisit au plus un à l'inscription (voir `Paiement de la cotisation`
|
||
plus bas) ; supprimer une option n'affecte jamais les bénévoles qui
|
||
l'avaient déjà choisie (copie figée sur `Volunteer`).
|
||
- **`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`, `POST .../form/public/submit` et
|
||
`POST .../form/public/sumup-webhook` sont les **trois seuls endpoints de
|
||
toute l'API sans authentification** — les deux premiers 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 webhook est appelé par SumUp
|
||
lui-même, donc sans hCaptcha possible — voir plus bas. 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.
|
||
- **Paiement de la cotisation (SumUp)** : si le bénévole choisit un type de
|
||
paiement (`FormPaymentOption`, ci-dessus — le formulaire public
|
||
n'affiche ce choix, à la place de la case `WILLING_TO_PAY_FEE` toute
|
||
seule, que si `sumup_enabled` sur `PublicFormConfig` est vrai, c.-à-d.
|
||
identifiants SumUp renseignés **et** au moins un type de paiement
|
||
configuré ; sinon la case reste une simple déclaration, comme avant SumUp)
|
||
et que l'édition a configuré ses identifiants, la soumission crée un
|
||
checkout hébergé (`app/sumup/client.py:create_checkout`, API SumUp
|
||
`POST /v0.1/checkouts`, `hosted_checkout.enabled=true`, montant et
|
||
libellé de l'option choisie) et renvoie son URL de paiement dans
|
||
`FormSubmissionResult.sumup_checkout_url` (le bénévole y est redirigé
|
||
juste après son inscription — pas avant, contrairement à l'ancien lien
|
||
Yapla statique). Le libellé et le montant de l'option choisie sont
|
||
recopiés sur `Volunteer.fee_payment_label`/`fee_amount` **au moment du
|
||
choix** — une copie figée, jamais recalculée si l'option est éditée ou
|
||
supprimée ensuite (`FormPaymentOption` reste une pure configuration,
|
||
jamais une référence vivante depuis l'historique des bénévoles).
|
||
`Volunteer.fee_checkout_id` (identifiant SumUp, jamais exposé) et
|
||
`fee_payment_status` (`PENDING`/`PAID`/`FAILED`/`EXPIRED`, `NULL` tant
|
||
qu'aucun checkout n'a été créé) suivent l'état ; `fee_paid_at` est posé à
|
||
la première confirmation `PAID`. Best effort, comme l'envoi d'emails : un
|
||
échec de l'appel API SumUp (réseau, identifiants invalides…) ne bloque
|
||
jamais l'inscription. Le statut est tenu à jour par
|
||
`POST .../form/public/sumup-webhook` (`event_type:
|
||
CHECKOUT_STATUS_CHANGED`, `id`) : le payload n'étant qu'un identifiant,
|
||
l'app relit toujours l'état réel via l'API (`get_checkout`) avant de
|
||
l'appliquer, jamais fait confiance au seul contenu du webhook. Visible par
|
||
la coordination sur la fiche du bénévole (`ConfigVolunteers.tsx`, libellé
|
||
+ montant + statut), en lecture seule (pas de bascule manuelle).
|
||
|
||
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 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**). 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** (`GrillePage.tsx`) — un vrai axe des
|
||
temps : une colonne = 30 min, bornée par jour à la plage réellement utilisée
|
||
(du créneau le plus matinal au plus tardif, arrondi à la demi-heure
|
||
englobante — pas les 24 h complètes). Les créneaux gardent des horaires
|
||
libres (`ShiftCreate`, § 2) ; ils sont replacés dans cette grille fixe en
|
||
fusionnant les cases qu'ils couvrent, arrondis à la tranche englobante s'ils
|
||
ne tombent pas pile sur un multiple de 30 min (`buildTimeGrid` /
|
||
`placeInTimeGrid` dans `frontend/src/lib/planning.ts`). **Chaque cellule
|
||
reçoit sa position de grille explicitement** (`gridColumn`/`gridRow`
|
||
calculés dans `GrillePage.tsx`, pas seulement `gridColumn: span N`) : un
|
||
poste dont les créneaux ne totalisent pas exactement le nombre de tranches
|
||
d'un jour (deux créneaux adjacents mais non alignés sur 30 min, désormais
|
||
courant depuis la création à la main) ne doit jamais décaler les lignes
|
||
suivantes — bug corrigé une fois (postes manquants au-delà du premier), un
|
||
positionnement implicite (auto-flow) recréerait la même classe de bug.
|
||
Vérifié par `scripts/smoke.tsx:checkGridLayout` (inspecte les `grid-row`
|
||
rendus, pas seulement l'absence de crash — un décalage CSS n'est pas visible
|
||
dans un simple rendu SSR). Hiérarchie de `z-index` volontairement large et
|
||
sans égalité (coins figés 20/21 > en-têtes jour/heure 12/11 > libellés de
|
||
poste 10 > cellules de créneau, chacune isolée dans son propre contexte
|
||
d'empilement — `relative z-0` — pour que son infobulle au survol, `z-20`,
|
||
ne rivalise jamais avec les en-têtes figés) : deux éléments sticky à égalité
|
||
de `z-index` peuvent se chevaucher de façon imprévisible pendant un
|
||
défilement selon le navigateur. Largeur adaptative par jour (`columnWidthPx`,
|
||
en fonction du nombre de tranches) : peu de tranches ⇒ colonnes larges,
|
||
beaucoup ⇒ largeur compacte avec défilement horizontal — jamais de colonnes
|
||
étroites par défaut.
|
||
La « Vue bénévoles » (`VolunteerViewPage.tsx`) reste sur l'ancien modèle,
|
||
distinct : une colonne par **créneau** (signature `starts_at`/`ends_at`)
|
||
plutôt qu'un axe temporel continu (`buildLattice` / `LatticeColumn`), plus
|
||
adapté à son tableau bénévole × créneau.
|
||
|
||
---
|
||
|
||
## 5. Périmètre
|
||
|
||
### Premier incrément (en cours)
|
||
|
||
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
|
||
│ │ ├── sumup/ # client API SumUp (client.py) — paiement de la cotisation
|
||
│ │ ├── bootstrap.py # instance vierge : édition + 1er compte
|
||
│ │ ├── seed.py # jeu de démonstration (python -m app.seed)
|
||
│ │ ├── i18n/
|
||
│ │ │ ├── __init__.py # t(key, **params)
|
||
│ │ │ └── locales/fr.json
|
||
│ │ └── migrations/ # Alembic (env.py, versions/)
|
||
│ └── tests/
|
||
│ ├── conftest.py # vraie base de test PostgreSQL
|
||
│ └── ...
|
||
└── frontend/ # React + Vite + TS + Tailwind (étape ultérieure)
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Commandes
|
||
|
||
Aucune dépendance locale requise hors Docker.
|
||
|
||
| Action | Commande |
|
||
|-------------------------------|-------------------------------------------------------------------------|
|
||
| Lancer en dev | `docker compose up --build` |
|
||
| API + docs interactives | http://localhost:8000/docs |
|
||
| Tests backend | `docker compose run --rm backend uv run pytest` |
|
||
| Couverture | `docker compose run --rm backend uv run pytest --cov=app` |
|
||
| Lint | `docker compose run --rm backend uv run ruff check .` |
|
||
| Format | `docker compose run --rm backend uv run ruff format .` |
|
||
| Créer une migration | `docker compose run --rm backend uv run alembic revision --autogenerate -m "..."` |
|
||
| Appliquer les migrations | `docker compose run --rm backend uv run alembic upgrade head` |
|
||
| Vérifier migration ↔ modèle | `docker compose run --rm backend uv run alembic check` (après `upgrade head`) |
|
||
| Instance vierge (édition + 1er compte) | `docker compose run --rm backend uv run python -m app.bootstrap --name … --start … --end … --email … --password …` |
|
||
| Charger le jeu de démo | `docker compose run --rm backend uv run python -m app.seed` (après `alembic upgrade head`) |
|
||
|
||
---
|
||
|
||
## 8. Conventions
|
||
|
||
- **Code, base, noms de fichiers en anglais.** Le français vit dans les
|
||
fichiers de traduction et les commits.
|
||
- **Aucun texte visible par l'utilisateur en dur.** Backend : `app.i18n.t(key)`
|
||
+ `locales/fr.json`. Frontend : fichier i18n dédié. Vrai même en monolingue.
|
||
- **RGPD :**
|
||
- Pas de log des noms, emails, téléphones. On logue des identifiants.
|
||
- `Edition.retention_days` : durée de rétention paramétrable.
|
||
- Endpoint de suppression complète d'un bénévole : *hard delete* en cascade.
|
||
- **Dates et heures :** stockées naïves, interprétées en heure locale
|
||
Europe/Paris (heure murale). Les règles « 8 h », « journée », « demi-journée »
|
||
sont toutes en heure locale.
|
||
- **Validation TDD :** tests avant implémentation, un cas passant + un cas
|
||
déclenchant par règle.
|
||
- **Git :** un commit atomique par étape validée, message en français.
|
||
- **Étapes courtes :** après chaque étape, montrer le résultat et attendre la
|
||
validation avant de continuer.
|
||
|
||
---
|
||
|
||
## 9. Déploiement
|
||
|
||
- Dépôt Gitea (instance auto-hébergée). VPS avec Docker + un Caddy déjà
|
||
installé en service système (hors de ce dépôt), qui reçoit tout le trafic
|
||
public en HTTPS et fait reverse proxy vers les conteneurs. Nécessite un
|
||
runner Gitea Actions (`act_runner`, exécuteur Docker) attaché au dépôt.
|
||
- `.gitea/workflows/ci-cd.yml` : à chaque push, tests (backend + frontend) ;
|
||
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 le référent, les créneaux, les bénévoles affectés et leur téléphone — bouton depuis la grille des besoins (COORDINATION) ; photo du bénévole affichée sur son écran et sur l'écran référent (en face du nom de chaque membre de l'équipe) | à valider |
|
||
| 23 | Créneaux : plus de génération automatique à la création d'un poste — création manuelle à horaire libre (`ConfigGrid.tsx`), génération depuis les créneaux types conservée en option (« Générer les créneaux ») ; grille des besoins redessinée en vrai axe des temps (une colonne = 30 min, bornée à la plage utilisée par jour, cases fusionnées pour chaque créneau — `buildTimeGrid`/`placeInTimeGrid`) | à valider |
|
||
| 24 | Corrections de stabilité de la grille des besoins (suite à l'étape 23) : positionnement explicite (`gridColumn`/`gridRow`) de chaque cellule (un poste aux créneaux non alignés sur 30 min ne décale plus les postes suivants) ; hiérarchie de `z-index` sans égalité entre les en-têtes figés et isolation de l'infobulle de survol dans son propre contexte d'empilement (ne recouvrait plus la ligne « Affectés / idéal » pendant un défilement) | à valider |
|
||
| 25 | Paiement de la cotisation via l'API SumUp (remplace le lien statique Yapla) : plusieurs types de paiement configurables par édition (`FormPaymentOption` — libellé + montant, ex. « Cotisation classique », « Mécénat »), le bénévole en choisit un à l'inscription, checkout hébergé créé à la soumission si l'édition a configuré ses identifiants SumUp, lien de paiement affiché juste après l'inscription, statut (`PENDING`/`PAID`/`FAILED`/`EXPIRED`) tenu à jour par un webhook SumUp et visible (libellé + montant + statut) sur la fiche du bénévole | à 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/`).
|