Compare commits

..
30 Commits
Author SHA1 Message Date
AdamThomasandClaude Opus 5.5 3754cfc69a Planches de badges centrées, verso aligné sur le recto
ci-cd / build-backend (push) Successful in 57s
ci-cd / build-frontend (push) Successful in 9s
ci-cd / deploy (push) Successful in 16s
Espace libre réparti à parts égales (mêmes écarts à gauche, entre les
badges et à droite ; en haut, entre les rangées et en bas) : grille
symétrique, le verso imprimé en miroir tombe exactement derrière le recto
au lieu d'être décalé de l'espace restant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:50:59 +01:00
AdamThomasandClaude Opus 5.5 05cdb0276b Lien de connexion directe permanent dans le récapitulatif de planning
ci-cd / build-backend (push) Successful in 45s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 11s
{lien_connexion} devient un lien permanent (réutilisable, sans expiration,
signé HMAC, rien de stocké) au lieu d'un lien magique de 30 min à usage
unique ou d'un simple lien vers le site. Compte bénévole créé à l'envoi
s'il manque ; révocation par « Renouveler le lien de connexion » sur la
fiche (Account.login_link_version, migration 0031) ; jamais pour la
coordination.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:01:03 +01:00
AdamThomasandClaude Opus 5.5 8d6781e6ce Récapitulatif de planning : créneaux en tableau avec le référent
ci-cd / build-backend (push) Successful in 48s
ci-cd / deploy (push) Successful in 13s
ci-cd / build-frontend (push) Successful in 7s
{creneaux} devient un tableau par jour de festival (horaire, poste,
référent avec son téléphone), en HTML dans l'email et en lignes lisibles
dans la version texte — envoi réel et email de test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:52:06 +01:00
AdamThomasandClaude Opus 5.5 440466ccf9 Email de test : récapitulatif de planning compilé
ci-cd / build-backend (push) Successful in 46s
ci-cd / deploy (push) Successful in 12s
ci-cd / build-frontend (push) Successful in 21s
L'email de test reprend le modèle en cours d'édition, balises remplies
avec le planning d'un bénévole au choix (premier avec un créneau par
défaut), option lien de connexion sans vrai jeton, sujet « [Test] … » —
même fonction de rendu que l'envoi réel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:44:14 +01:00
AdamThomasandClaude Opus 5.5 461742eb78 Email de test et ajout manuel sans champ obligatoire
ci-cd / build-backend (push) Successful in 1m25s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 13s
Étape 67 : adresse + bouton dans l'onglet « Messages » pour envoyer tout
de suite un email de test, résultat réel affiché (envoyé, désactivé,
connexion ou message refusé, réponse du serveur SMTP) —
POST .../notifications/test.

Étape 66 : prénom et nom facultatifs à l'ajout manuel d'un bénévole ;
un bénévole sans nom s'affiche « Sans nom » / « ? » partout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:34:24 +01:00
AdamThomasandClaude Opus 5.5 5c2cc0a6c3 Feuilles de poste compactées : une page par poste et par jour
ci-cd / build-backend (push) Failing after 1m2s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped
Bénévoles deux par ligne (vignette 10 mm, nom et téléphone superposés),
titres, en-têtes de créneau et marges resserrés — 6 feuilles réalistes :
11 pages ⇒ 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:22:46 +01:00
AdamThomasandClaude Opus 5.5 749c7e0917 Vue par bénévole : recherche sur le bénévole ou le poste
ci-cd / build-backend (push) Successful in 6s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 11s
Barre de recherche (sans accents ni casse) sur le nom du bénévole ou d'un
de ses postes, postes trouvés cernés d'orange, cumulable avec les filtres
et le tri.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:15:14 +01:00
AdamThomasandClaude Opus 5.5 cc8c2e61ab Fiches de poste : message de l'organisation en bas de chaque page
ci-cd / build-backend (push) Successful in 29s
ci-cd / build-frontend (push) Successful in 7s
ci-cd / deploy (push) Successful in 11s
Edition.volunteer_message imprimé en pied de chaque page (suite d'une
feuille comprise), la marge du bas agrandie de sa hauteur ; rien sans
message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:11:33 +01:00
AdamThomasandClaude Opus 5.5 c6ea7378c7 Fiches de poste rapides, badges sans initiales, vue par bénévole sur l'axe des temps
ci-cd / build-backend (push) Successful in 34s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 11s
Étape 62 : les fiches de poste intégraient la photo d'origine de chaque
bénévole pour chaque créneau (CPU à 300 %, puis erreur) — photos réduites
à la vignette une fois par PDF ; badges : photo recadrée une fois par
génération et décodée à taille réduite.

Étape 61 : sans photo, le cadre de la photo reste vide sur le badge.

Étape 60 : vue par bénévole en tranches de 30 min, affectations étirées
sur toute leur durée, postes simultanés empilés, ligne de l'heure actuelle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:01:48 +01:00
AdamThomasandClaude Opus 5.5 c57c70569c Pointage enregistré et statistiques de présence
ci-cd / build-backend (push) Successful in 29s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s
Étape 58 : le présent / absent du référent est enregistré sur
l'affectation (Assignment.attendance + heure, migration 0030,
PUT .../assignments/{id}/attendance) au lieu de disparaître au
rechargement.

Étape 59 : section « Présence » de l'écran Statistiques — créneaux en
cours et créneaux terminés, par poste, rafraîchie toutes les 30 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:06:32 +01:00
AdamThomasandClaude Opus 5.5 a37f6c2c41 Référent aussi bénévole : onglets Mon équipe / Mon planning
ci-cd / build-backend (push) Successful in 7s
ci-cd / build-frontend (push) Successful in 20s
ci-cd / deploy (push) Successful in 12s
Un référent rattaché à une fiche bénévole voit aussi ses propres créneaux
(vue bénévole) dans un second onglet de son écran, ouvert d'office quand il
tient un créneau de bénévole hors de ses postes ; « TOI » dans l'équipe
pointée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 07:42:26 +01:00
AdamThomasandClaude Opus 5.5 16d8fb44a7 Écrans bénévole et référent redessinés, messages aux bénévoles
ci-cd / build-backend (push) Successful in 29s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 12s
Étape 55 : écrans /mobile-benevole et /mobile-referent d'après la maquette
(design/Planning bénévole mobile) — créneau en cours ou prochain mis en
avant, planning par journée de festival avec frise horaire, compteurs et
pointage du référent par créneau.

Étape 56 : message de l'organisation (Edition.volunteer_message, onglet
« Message aux bénévoles ») et message de chaque poste (Position.message,
saisi par le référent), migration 0029, affichés sur l'écran bénévole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:17:14 +01:00
AdamThomasandClaude Opus 5.5 4a3f93e137 Relance de la chaîne CI/CD
ci-cd / build-backend (push) Successful in 24s
ci-cd / build-frontend (push) Successful in 27s
ci-cd / deploy (push) Successful in 15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:53:34 +01:00
AdamThomasandClaude Opus 5.5 a5d975baa0 Relance de la chaîne CI/CD
ci-cd / build-backend (push) Failing after 57s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:36:15 +01:00
AdamThomasandClaude Opus 5.5 1026d9b9e1 Badges : ordre de superposition des champs
ci-cd / build-backend (push) Failing after 1m5s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped
La photo recouvrait le nom et le texte libre (champs dessinés dans l'ordre
alphabétique de leur type). BadgeField.z_order (migration 0028, champs
existants renumérotés photo dessous) fixe l'ordre de dessin du PDF et de
l'aperçu ; boutons « Premier plan / Avancer / Reculer / Arrière-plan »
dans l'inspecteur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:33:10 +01:00
AdamThomasandClaude Opus 5.5 4f86398ccb Vue par bénévole : tri par total d'heures
ci-cd / build-backend (push) Successful in 6s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s
Tri des lignes par nom ou par total d'heures affectées, croissant ou
décroissant (boutons « Trier par » et clic sur l'en-tête « TOTAL H ») ;
à total égal, l'ordre par nom est conservé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:23:22 +01:00
AdamThomasandClaude Opus 5.5 ced0855d47 Refonte du créateur de badges et nombre de badges par bénévole
ci-cd / build-backend (push) Successful in 30s
ci-cd / deploy (push) Successful in 13s
ci-cd / build-frontend (push) Successful in 22s
Créateur de badges refait d'après la maquette : champs sur le recto et le
verso (BadgeField.face, verso placé en miroir dans le PDF pour le
recto-verso), fond de zone, réduction automatique du texte, photo ovale,
initiales sans photo, nom sur deux lignes (migration 0026) ; éditeur en
trois colonnes (champs, aperçu recto/verso avec de vrais bénévoles,
inspecteur), formats prédéfinis, annuler, flèches, zoom, repères de
découpe, dépôt d'image sur l'aperçu, contrôle de tous les badges avant
génération.

Nombre de badges par bénévole (Volunteer.badge_count, 1 par défaut,
migration 0027) : champ sur la fiche, autant d'exemplaires sur les
planches, total affiché à la génération, colonne dans l'export Excel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:20:42 +01:00
AdamThomasandClaude Opus 5.5 1a23881e81 Badges : retour à la ligne et créneaux en tableau par jour
ci-cd / build-backend (push) Successful in 37s
ci-cd / build-frontend (push) Successful in 22s
ci-cd / deploy (push) Successful in 13s
Le texte des champs passe à la ligne pour tenir dans la largeur de son
cadre (wrap_text) au lieu de déborder. Les créneaux affectés s'affichent
en tableau : le jour de festival en en-tête, puis une colonne horaire et
une colonne poste (plus de référent sur le badge) ; même tableau dans
l'aperçu de l'éditeur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:44:55 +01:00
AdamThomasandClaude Opus 5.5 cfed76c239 Badges : texte libre saisi dans l'éditeur, champs ramenés dans le badge
Texte libre commun à tous les badges, saisi dans l'éditeur
(BadgeField.text, migration 0025) ; le « Texte du badge » de la fiche le
remplace pour un bénévole. Champs ramenés à l'intérieur du badge et champ
sélectionné au premier plan : la photo restait bloquée hors d'un badge
plus étroit que le CR80 (bornes de glisser-déposer négatives).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:30:34 +01:00
AdamThomasandClaude Opus 5.5 6892e0307d Photo du bénévole ajoutée par la coordination
ci-cd / build-backend (push) Successful in 33s
ci-cd / build-frontend (push) Successful in 20s
ci-cd / deploy (push) Successful in 14s
Section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer),
PUT/DELETE /editions/{id}/volunteers/{vid}/photo (COORDINATION),
validation de l'image partagée avec le formulaire public (read_photo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:17:25 +01:00
AdamThomasandClaude Opus 5.5 54cba467fc Badges : photo du bénévole et texte libre
Champ « Photo du bénévole » (PHOTO) : photo du formulaire recadrée au
cadre, orientation EXIF appliquée, réduite à 300 dpi ; cadre vide sans
photo. Champ « Texte libre » (FREE_TEXT) alimenté par le nouveau « Texte
du badge » de la fiche bénévole (Volunteer.badge_text, migration 0024),
repris dans l'export Excel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:12:09 +01:00
AdamThomasandClaude Opus 5.5 5b493c9737 Ajout de BLUEPRINT.md : socle réutilisable pour une nouvelle appli
Philosophie, stack, architecture, style et conventions de l'appli,
débarrassés du domaine métier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:05:16 +01:00
AdamThomasandClaude Opus 5.5 85e841ae4a Responsive mobile / tablette / desktop
En-tête (onglets sur une seconde ligne en tablette, menu déroulant en
mobile), panneau d'affectation et fiche bénévole en tiroir par-dessus sous
lg: (plein écran en mobile), colonne des postes de la grille rétrécie,
liste des bénévoles réduite en mobile, écran « Postes & créneaux » empilé,
aperçu de l'éditeur de badges mis à l'échelle (glisser-déposer tactile),
ligne des types de paiement repliée, marges réduites — desktop inchangé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:05:16 +01:00
AdamThomasandClaude Opus 5.5 0f904f5e3d Deux postes en même temps possibles, malus dans le solveur
ci-cd / build-backend (push) Successful in 45s
ci-cd / build-frontend (push) Successful in 24s
ci-cd / deploy (push) Successful in 27s
Le chevauchement de créneaux (SHIFT_OVERLAP) n'est plus bloquant : il
remonte en avertissement et l'affectation est enregistrée. Le solveur ne
l'exclut plus mais applique un fort malus au bénévole déjà affecté
ailleurs au même moment (proposé seulement sous l'effectif minimal, faute
de mieux) ; il n'est jamais reproposé sur un créneau où il est déjà.
Même malus dans le classement par défaut du panneau et dans le mock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:38:52 +01:00
AdamThomasandClaude Opus 5.5 d0fa1e109c Logs de diagnostic et rapprochement SumUp ↔ bénévole
ci-cd / build-backend (push) Successful in 38s
ci-cd / build-frontend (push) Successful in 20s
ci-cd / deploy (push) Successful in 13s
- Une ligne de log par requête (route modèle, statut, durée, code d'erreur,
  compte), identifiant X-Request-ID repris dans chaque log et affiché à
  l'utilisateur sous une erreur, exceptions non gérées tracées (500 JSON)
- Journal d'accès d'uvicorn coupé : il exposait le jeton du formulaire public
- Logs métier : paiement SumUp, connexion, import, plannings, solveur
- Nom du bénévole dans la description du checkout SumUp
- Code et identifiant de transaction SumUp enregistrés au paiement
  (migration 0023), affichés sur la fiche et dans l'export Excel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:54:35 +02:00
AdamThomasandClaude Opus 5.5 d722a81a87 Retrait des disponibilités et des limites de temps dans la journée
ci-cd / build-backend (push) Successful in 31s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s
Disponibilités déclarées supprimées (table `availability`, migration 0022,
API, éditeur de la fiche bénévole, badge de couverture du panneau
d'affectation, colonnes des statistiques) ainsi que les règles
OUTSIDE_AVAILABILITY, DAILY_HOURS_EXCEEDED et INSUFFICIENT_REST, les malus
de disponibilité et de repos du solveur et le filtre « Surchargés » de la
vue bénévoles. Seul le chevauchement reste contrôlé. La case « je suis
disponible » du formulaire public est conservée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:29:58 +02:00
AdamThomasandClaude Opus 5.5 f671bb85bf Début de journée indépendant de l'ordre des créneaux types
ci-cd / build-backend (push) Successful in 33s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 13s
La « journée de festival » commence désormais au début du plus long creux
laissé par les créneaux types sur 24 h (l'heure où le dernier se termine),
et non plus au début de celui de plus petit `order` : un créneau type
d'ordre 0 déplacé à 21:00 faisait commencer chaque journée des grilles à
21:00, jusqu'à 03:00 le lendemain. Même règle côté backend
(validation.engine.day_boundary, réutilisée par la génération de créneaux)
et frontend (dayBoundaryFromDayParts).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:48:14 +02:00
AdamThomasandClaude Opus 5.5 ee1f6ed72e Écran Statistiques : inscriptions, réponses, cotisations et besoins
ci-cd / build-backend (push) Successful in 33s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s
Nouvel onglet « Statistiques » (/statistiques, COORDINATION), alimenté
par GET /editions/{id}/stats (app/api/stats.py, recalculé à chaque appel,
aucune nouvelle entité) :
- inscriptions : total, 7 derniers jours, évolution cumulée par jour
- réponses : part des bénévoles ayant renseigné chaque information,
  artistes les plus demandés
- cotisations : en ligne / sur place / aucune, montants par type,
  mécénat, formulaires remplis sans paiement abouti
- besoins face aux bénévoles, par journée de festival : heures × personnes
  demandées vs disponibilités déclarées vs affectées ; par poste, places
  pourvues (plafonnées à l'effectif idéal de chaque créneau) et rapport
  vœux / place

Graphiques SVG maison (components/charts.tsx) avec infobulles au survol
et au clavier, vue tableau pour chacun, palette validée (daltonisme,
contraste), mise en page vérifiée sur ordinateur et mobile. Le mock
calcule les mêmes chiffres et étale les inscriptions du jeu de démo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:40:46 +02:00
AdamThomasandClaude Opus 5.5 ff57bbd863 Mode de paiement de la cotisation visible (en ligne, sur place)
ci-cd / build-backend (push) Successful in 31s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s
Jusqu'ici seul un paiement en ligne apparaissait ; un bénévole qui paiera
sur place ne se distinguait pas d'un bénévole sans cotisation. Mode déduit
des champs existants (lib/fee.ts:feeMode, exporting/xlsx.py:_payment_mode),
sans migration :
- fiche : section « Cotisation » toujours visible — paiement en ligne
  (type, montant, statut, mécénat et adresse du reçu) ou case « Paiera sur
  place » modifiable à la main
- liste : colonne « Cotisation » (Sur place / En ligne ✓ / Mécénat ✓ / —)
- export Excel : colonne « Mode de paiement »

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:47:06 +02:00
AdamThomasandClaude Opus 5.5 a6e7b6e7fb Créneaux de nuit rattachés à leur journée de festival
Un créneau saisi « 00:00–01:00 » sur la carte du samedi était enregistré
le samedi à 00:00 (nuit de vendredi à samedi), donc affiché la veille dans
la grille des besoins. Une heure de début avant le début de journée
(premier créneau type) désigne désormais la nuit qui suit : dimanche
00:00, à la suite de la soirée du samedi.

Même « journée de festival » partout (validation/engine.py:day_boundary,
festival_day ; miroir front lib/planning.ts:festivalDay) : écran Postes &
créneaux, feuilles de poste (une seule page pour une soirée qui passe
minuit) et chaînage du solveur (00h–01h enchaîne 23h–00h).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:47:06 +02:00
118 changed files with 12611 additions and 3351 deletions
+362
View File
@@ -0,0 +1,362 @@
# BLUEPRINT — Socle réutilisable
Ce fichier capture la **philosophie, la stack, l'architecture, le style et
les conventions** de l'application « Gestion des bénévoles », débarrassés de
son domaine métier (festival, créneaux, cotisations…). Il sert de point de
départ pour une nouvelle application construite sur les mêmes bases.
**Mode d'emploi pour une nouvelle appli :**
1. Copier ce fichier dans le nouveau dépôt.
2. Le donner à Claude avec le brief métier : « Crée l'appli X sur les bases
de `BLUEPRINT.md` ».
3. La première étape produit un `CLAUDE.md` propre au nouveau projet (même
plan que celui de `benevoles` : glossaire, modèle, règles, périmètre,
arborescence, commandes, conventions, déploiement, journal des étapes),
qui devient ensuite le contrat de travail.
Les passages marqués **⟨à adapter⟩** dépendent du métier ; tout le reste est
le socle à reprendre tel quel.
---
## 1. Philosophie
- **Un outil pour une petite organisation réelle** (association, 50–200
personnes), pas une plateforme générique. On résout le besoin concret,
sans sur-ingénierie, mais avec un vrai niveau de qualité (tests, sécurité,
RGPD, déploiement automatisé).
- **Le métier parle français, le code parle anglais.** Un glossaire
FR ↔ EN fige les noms une fois pour toutes ; aucun nom métier français
dans le code, aucun texte d'interface en dur nulle part.
- **`CLAUDE.md` est le contrat de travail.** Il décrit le modèle, les règles,
les choix et leurs *raisons*. Il est mis à jour **à chaque étape**, en même
temps que le code. Quand un bug est corrigé, la cause et la règle qui
l'empêche de revenir y sont notées (« bug corrigé une fois — faire Y
recréerait la même classe de bug »).
- **Étapes courtes et validées.** Une étape = une fonctionnalité cohérente,
montrée à l'utilisateur, puis validée avant de continuer. Un commit atomique
par étape, message en français. Le journal des étapes (§ 10 du `CLAUDE.md`)
garde l'historique des décisions.
- **Le cœur métier est pur et testé d'abord.** Les règles critiques vivent
dans un module isolé, sans base de données ni HTTP (fonction pure :
entrée typée → résultat typé), écrit en TDD : un cas passant + un cas
déclenchant par règle. Un `loader.py` est le seul point de contact avec la
base.
- **L'outil assiste, l'humain décide.** Les calculs « intelligents »
(solveur, suggestions) proposent ; ils ne persistent jamais rien
d'eux-mêmes. La validation reste seule juge au moment de l'écriture.
- **Erreurs = codes, pas phrases.** Le backend renvoie
`{"detail": {"code": "domaine.raison", ...params}}` ; la traduction se
fait côté locales. Les violations de règles sont typées (`BLOCKING` /
`WARNING`) avec des paramètres structurés.
- **Simplicité assumée et documentée.** Quand on simplifie (ex. « vérifié
seulement contre l'existant, pas entre deux suggestions »), on l'écrit
explicitement comme simplification assumée.
- **Retirer est une fonctionnalité.** On n'hésite pas à supprimer un concept
devenu inutile (table, règle, écran) — migration, API, front et doc
nettoyés ensemble, plutôt que de laisser du code mort.
- **Aucune dépendance locale hors Docker.** Tout se lance, se teste et se
migre via `docker compose`.
---
## 2. Stack technique
### Backend
| Rôle | Choix |
|------|-------|
| Langage | Python 3.12 |
| Gestion des dépendances | `uv` (`pyproject.toml`, groupe `dev`) |
| API | FastAPI + Uvicorn |
| ORM / schémas | SQLModel (SQLAlchemy + Pydantic) |
| Base | PostgreSQL 16 (driver `psycopg[binary]` v3) |
| Migrations | Alembic (autogenerate + `alembic check` pour vérifier modèle ↔ migrations) |
| Configuration | `pydantic-settings` (`app/config.py`, valeurs réelles dans `.env` non versionné) |
| Auth | `pyjwt` (HS256) + `bcrypt` |
| Tests | `pytest` + `pytest-cov`, **vraie base PostgreSQL de test** |
| Lint / format | `ruff` (line-length 100, règles `E F I UP B`) |
| Outils ponctuels | `openpyxl` (import/export Excel), `reportlab` + `pypdf` (PDF), `pillow` (validation d'images), `httpx` (API tierces), `ortools` (optimisation) — **⟨à adapter⟩** selon le besoin |
### Frontend
| Rôle | Choix |
|------|-------|
| Framework | React 18 + TypeScript (strict) |
| Build | Vite 5 |
| Style | Tailwind CSS 3 (thème étendu, aucune lib de composants) |
| Données serveur | TanStack React Query 5 (hooks centralisés dans `src/api/hooks.ts`) |
| Routing | React Router 6 |
| Graphiques | SVG maison (`components/charts.tsx`), aucune bibliothèque |
| Qualité | ESLint 9 + `tsc --noEmit` + script `smoke` (rendu headless via `tsx`) |
Dépendances runtime volontairement minimales : `react`, `react-dom`,
`react-router-dom`, `@tanstack/react-query`. Rien d'autre sans bonne raison.
### Infra
- Docker Compose en dev (db + backend en hot reload + frontend Vite).
- Production : images `backend` (uvicorn) et `frontend` (build statique
servi par nginx), ports exposés uniquement sur `127.0.0.1`, Caddy du VPS en
reverse proxy HTTPS (`/api/*` → backend avec préfixe retiré, le reste →
frontend).
- Dépôt Gitea auto-hébergé + Gitea Actions (`act_runner`) : tests → build des
images (registre Gitea) → SSH sur le VPS → `pull` → `alembic upgrade head`
→ `up -d`.
- Fuseau horaire : `TZ=Europe/Paris` + `tzdata` dans le conteneur.
---
## 3. Architecture
### Arborescence type
```
.
├── CLAUDE.md # contrat de travail (tenu à jour à chaque étape)
├── README.md
├── Makefile # make demo / make blank / make down / make logs
├── docker-compose.yml # dev
├── .env.example # toutes les variables, sans vraies valeurs
├── .gitea/workflows/ci-cd.yml
├── deploy/ # docker-compose.prod.yml, bloc Caddyfile, README de mise en service
├── docker/postgres-init/ # crée la base de test au premier démarrage
├── backend/
│ ├── pyproject.toml · Dockerfile · alembic.ini
│ ├── app/
│ │ ├── main.py # instance FastAPI, montage des routers
│ │ ├── config.py # Settings (pydantic-settings), get_settings() en cache
│ │ ├── db.py # engine + get_session
│ │ ├── logging_config.py # logs sans PII + identifiant de requête
│ │ ├── request_logging.py # middleware : une ligne par requête, X-Request-ID, 500 tracées
│ │ ├── api/ # un router par ressource + schemas.py + deps.py
│ │ ├── auth/ # passwords, magic_link, tokens (JWT), deps (rôles), rate_limit
│ │ ├── email/ # sender (SMTP), templates (texte), html (rendu thémé), check (diagnostic)
│ │ ├── models/ # enums.py, base.py (TimestampMixin, local_now), domain.py
│ │ ├── validation/ # cœur métier pur : models.py, engine.py, loader.py
│ │ ├── <module_métier>/ # même découpage pour chaque calcul isolé ⟨à adapter⟩
│ │ ├── i18n/ # t(key, **params) + locales/fr.json
│ │ ├── bootstrap.py # instance vierge : 1re entité racine + 1er compte admin
│ │ ├── seed.py # jeu de démonstration réaliste et déterministe
│ │ └── migrations/
│ └── tests/ # conftest.py (vraie base), factories.py, test_<module>.py
└── frontend/
├── Dockerfile · Dockerfile.prod · nginx.conf
├── tailwind.config.js # palette et primitives du design
├── scripts/smoke.tsx # rendu headless + vérifications structurelles
└── src/
├── api/ # types.ts (interface Api), schema.ts (miroir des schémas backend),
│ # http-client.ts, mock/ (même surface, en mémoire), hooks.ts, session.ts
├── app/ # AppShell.tsx, router.tsx
├── auth/ # auth-context, RequireAuth, roles (homePath par rôle)
├── components/ # ui.tsx (primitives), state.tsx (chargement/erreur), charts.tsx
├── i18n/ # t(key, params) + locales/fr.json
├── lib/ # logique pure testable (calculs, dates, env, cx)
└── pages/ # un écran par fichier, sous-dossiers si gros écran
```
### Principes d'architecture backend
- **Multi-tenant par une entité racine** (ici `Edition`) : toute entité
métier porte son `<racine>_id`, toutes les routes sont préfixées
`/<racines>/{id}/...` et la dépendance `get_<racine>` renvoie 404
`error.not_found` si elle n'existe pas. **⟨à adapter⟩** (organisation,
projet, saison…).
- **Clés primaires UUID**, champs d'audit `created_at`/`updated_at` sur
toutes les tables sauf associations (`TimestampMixin`).
- **Énumérations en `VARCHAR` + `CHECK`** (`native_enum=False`), jamais
d'ENUM natif PostgreSQL (migrations plus simples).
- **`ON DELETE CASCADE` au niveau base** sur les liens vers la racine et
entre entités : la suppression RGPD d'une personne est un vrai *hard
delete* qui emporte tout ce qui la concerne.
- **Dates naïves en heure locale** (`local_now()`), interprétées
Europe/Paris. Pas de mélange UTC/local.
- **Modules isolés pour la logique non triviale** : `models.py` (types
purs, dataclasses), `engine.py` (fonction pure), `loader.py` (seul accès
base). Testés sans HTTP.
- **Un router par ressource**, schémas Pydantic `XCreate` / `XRead` /
`XUpdate` dans `api/schemas.py`. En `PATCH` : `None`/absent = inchangé,
`[]` = vider une liste.
- **Secrets jamais renvoyés** par l'API (`xxx_set: bool` à la place ;
chaîne vide en écriture = effacer).
- **Tâches de fond** (`BackgroundTasks`) pour les emails, en *best effort* :
un échec SMTP est journalisé mais ne fait jamais échouer l'appel HTTP.
- **Nettoyage paresseux** plutôt que tâches planifiées (purge des données
périmées au passage d'une requête), tant que le volume le permet.
- **Instance vierge et démo** : `bootstrap.py` (CLI, crée la racine + le
1er compte) et `seed.py` (jeu réaliste avec des cas intéressants :
trous, conflits, données de chaque fonctionnalité).
### Principes d'architecture frontend
- **Une interface `Api` unique, deux implémentations** : client HTTP réel
et **mock en mémoire de même surface** (`VITE_API_MODE=mock|http`). Le
front se développe et se démontre sans backend ; passer de l'un à l'autre
ne change aucun écran. Le mock calcule les mêmes résultats que le backend.
- **`schema.ts` = miroir de `backend/app/api/schemas.py`**, gardé
synchrone à la main.
- **Toutes les données serveur passent par React Query** (`hooks.ts`), jamais
de `fetch` dans les pages.
- **Logique pure dans `src/lib/`**, souvent miroir d'une fonction backend
(ex. `festivalDay` ↔ `day_boundary`) : la règle est écrite à l'identique
des deux côtés et le `CLAUDE.md` le signale.
- **Auth** : JWT en `localStorage`, `Authorization: Bearer`, une 401
déconnecte ; `<RequireAuth>` sur toutes les pages métier ; page d'accueil
et onglets visibles selon le rôle.
- **Variables Vite figées au build** (`VITE_*`) : l'image de prod est
construite pour un environnement donné.
- **Téléchargements** (PDF, Excel) : blob → `<a download>` généré côté
client.
- **Chaque erreur affiche sa référence** (`X-Request-ID`) sous le message,
pour retrouver la ligne de log correspondante.
---
## 4. Sécurité et RGPD (non négociables)
- **Deux systèmes d'authentification, une même session JWT** :
- mot de passe (bcrypt, ≥ 10 caractères) pour les administrateurs ;
- **lien magique** (jeton aléatoire haché SHA-256 en base, usage unique,
TTL court) pour les autres comptes ; réponse `202` constante, sans jamais
révéler si un compte existe.
- **Rôles** appliqués côté backend par endpoint, documentés dans une
**matrice ressource × rôle** dans le `CLAUDE.md`. **⟨à adapter⟩**
(ici `COORDINATOR` / `LEAD` / `VOLUNTEER`).
- **Limiteurs de tentatives** en mémoire (connexion, lien magique,
formulaires publics, sondages vers API tierces) — `429
error.too_many_requests`.
- **Démarrage refusé en production** avec un `JWT_SECRET` par défaut, vide
ou trop court.
- **Pages publiques protégées par jeton** dans l'URL (`secrets.token_urlsafe`,
comparaison `secrets.compare_digest`, 404 générique), régénérable pour
révoquer un lien qui a fuité ; hCaptcha optionnel (désactivé si la clé est
absente).
- **Fichiers importés revalidés** (décodage réel d'image avec PIL, pas
seulement le `Content-Type`) ; exports Excel protégés contre l'injection de
formule (CWE-1236).
- **Aucune donnée nominative dans les logs** (ni nom, ni email, ni
téléphone, ni sujet d'email personnalisable) : on logue des identifiants.
Journal d'accès d'uvicorn **désactivé** (il écrivait les query strings,
donc les jetons).
- **Logs de diagnostic** : une ligne par requête (méthode, route *modèle*
+ identifiants, statut, durée, code d'erreur métier, compte) ; WARNING en
4xx, ERROR + trace complète en 5xx ; `X-Request-ID` propagé jusque dans les
tâches de fond. Erreur 422 : emplacement + type, jamais la valeur saisie.
- **Rétention paramétrable** des données nominatives sur l'entité racine ;
endpoint de suppression complète d'une personne.
- **En-têtes de sécurité** dans nginx et Caddy ; backend et frontend
n'écoutent que sur `127.0.0.1`.
- **Emails** : SMTP auto-hébergé, `EMAIL_ENABLED=false` en dev/tests (simple
journalisation), `multipart/alternative` (texte brut = source de vérité
des tests + HTML au thème de l'appli, tableaux et styles en ligne, logo en
pièce jointe inline par Content-ID plutôt qu'une URL qui fuiterait un
jeton). Commande de diagnostic SMTP dédiée.
---
## 5. Style visuel (design « néo-brutaliste »)
Identité forte, lisible, sans bibliothèque de composants : **bordures
franches noires, ombres portées dures sans flou, libellés en capitales, fond
crème, accents saturés**.
### Palette (`tailwind.config.js`)
| Jeton | Valeur | Usage |
|-------|--------|-------|
| `cream` | `#F5EFE1` | fond général |
| `ink` | `#14110E` | encre, bordures, ombres |
| `paper` | `#FFFDF7` | panneaux, cartes |
| `sand` / `sand-line` / `sand-dark` | `#EFE8D6` / `#E0D8C4` / `#E7E0CE` | barres secondaires, séparateurs |
| `yellow` / `yellow-soft` / `yellow-hover` | `#FFD23F` / `#FFE68A` / `#FFDF77` | action principale, état incomplet |
| `orange` / `orange-dark` | `#FF5C39` / `#E0421F` | urgence, danger |
| `green` | `#0E9F6E` | succès, survol de l'action principale |
| `blue` / `blue-strong` | `#A8C4FF` / `#2A6BFF` | information |
| `mauve` / `mauve-hover` | `#CBA6E3` / `#BE93D9` | dépassement / surplus |
### Typographie
- `font-display` : **Archivo** (400 → 900), titres et boutons en
`font-black`/`font-extrabold`, `uppercase`, `tracking-wide`, petites
tailles (11–12 px).
- `font-mono` : **DM Mono**, pour les heures, chiffres, codes.
- Chargées depuis Google Fonts dans `index.html`.
### Primitives (`components/ui.tsx`)
- **Ombres** : `shadow-brutal-sm` (2 px), `brutal` (3 px), `brutal-md`
(4 px), `brutal-lg` (6 px) — toutes `Npx Npx 0 #14110E`.
- **`Panel`** : `border-[3px] border-ink bg-paper` + ombre portée.
- **`PanelHeader`** : bandeau `bg-ink text-cream` en capitales (variante
hachurée `bg-hatch-ink text-yellow`).
- **`Btn`** : `border-2 border-ink`, capitales, variantes `solid` (jaune →
vert au survol), `ghost` (crème → jaune), `danger` (crème → orange), `dark`
(encre → jaune).
- **Trames d'état** en `backgroundImage` : hachures orange (critique), mauve
(surplus), encre (brouillon), sable (fermé), pointillés.
- Infobulles (`HelpTip`) en `position: fixed` positionnées par JS
(`useTooltipPosition`), bornées à l'écran — jamais en `absolute` dans un
conteneur qui défile.
- Graphiques SVG maison, palette vérifiée daltonisme/contraste sur `paper`,
**chaque graphique doublé d'une vue tableau**.
- Écrans mobiles dédiés aux rôles « terrain » (consultation simple), écrans
denses pour l'administration.
Aucun texte dans les primitives : les libellés viennent toujours de l'i18n.
---
## 6. Conventions de code
- **Code, base, fichiers en anglais** ; français uniquement dans les
locales, les commentaires de contexte métier et les commits.
- **i18n partout, même en monolingue** : backend `app.i18n.t(key,
**params)` (`str.format`), frontend `t(key, params)` (`{nom}`).
- **Commentaires qui expliquent le *pourquoi*** (contraintes, bug évité,
choix non évident), pas le *quoi*.
- **Tests backend** : vraie base PostgreSQL (créée par
`docker/postgres-init`), chaque test dans une transaction annulée
(savepoints, `join_transaction_mode="create_savepoint"`), fixtures
`autouse` qui coupent le SMTP et réinitialisent les limiteurs,
`factories.py` pour les données. Un fichier `test_<module>.py` par module
ou router ; tests d'autorisation dédiés (`test_authz.py`).
- **Tests frontend** : `typecheck` + `lint` + `smoke` (rendu SSR des écrans
avec le mock, et vérifications structurelles quand un bug visuel a déjà eu
lieu — ex. positions de grille rendues).
- **Migrations** numérotées (`0001`, `0002`…), une par étape qui touche le
modèle, vérifiées par `alembic check`.
- **Pas de nouvelle dépendance sans raison écrite** (commentaire dans
`pyproject.toml` si contrainte de version).
---
## 7. Commandes types
| Action | Commande |
|--------|----------|
| Tout lancer avec la démo | `make demo` |
| Instance vierge | `make blank EMAIL=… PASSWORD=… NAME=…` |
| Dev | `docker compose up --build` (API : http://localhost:8000/docs, front : http://localhost:5173) |
| Tests backend | `docker compose run --rm backend uv run pytest` |
| Lint / format | `docker compose run --rm backend uv run ruff check .` / `ruff format .` |
| Migration | `docker compose run --rm backend uv run alembic revision --autogenerate -m "…"` |
| Appliquer / vérifier | `… alembic upgrade head` puis `… alembic check` |
| Front | `npm run typecheck && npm run lint && npm run smoke` |
---
## 8. Méthode de travail avec Claude
1. **Étape 1 — squelette** : monorepo, Docker Compose, backend qui démarre
(`/health`), frontend vide, CI/CD, `CLAUDE.md` initial avec glossaire.
2. **Étape 2 — modèle de données complet** + migration initiale.
3. **Ensuite** : import des données existantes (analyse puis exécution,
mode simulation, rapport ligne par ligne, détection de doublons), CRUD,
cœur métier en TDD, seed de démo, authentification, puis fonctionnalités
une par une.
4. **À chaque étape** : code + tests + migration + mise à jour du
`CLAUDE.md` (modèle, règles, journal) + front (mock compris) → montrer le
résultat → attendre la validation → commit atomique en français.
5. **Revues de sécurité régulières** avant toute ouverture au public, notées
comme étapes à part entière.
+372 -97
View File
@@ -20,7 +20,6 @@ colonne, route et variable utilise la colonne « Code / base ».
| Poste | `Position` | | Poste | `Position` |
| Référent d'un poste | `Position.lead_account_id` → `Account` | | Référent d'un poste | `Position.lead_account_id` → `Account` |
| Créneau | `Shift` | | Créneau | `Shift` |
| Disponibilité (fenêtre horaire libre) | `Availability` (`date`, `start_time`, `end_time`) |
| Affectation | `Assignment` | | Affectation | `Assignment` |
| Artiste programmé | `Artist` (`name`, `performance_date`, `start_time`, `end_time`) | | 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 veux voir cet artiste » | `VolunteerArtist` (N-N `Volunteer` ↔ `Artist`) |
@@ -39,14 +38,14 @@ colonne, route et variable utilise la colonne « Code / base ».
| Rôle | `COORDINATOR` / `LEAD` / `VOLUNTEER` | | Rôle | `COORDINATOR` / `LEAD` / `VOLUNTEER` |
| Catégorie de poste | `BAR` / `TICKETING` / `WELCOME` / `PARKING` / `CLEANING` / `CATERING` / `SETUP` / `STAGE` / `OTHER` | | 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) ### Pas de disponibilités déclarées
- **`Availability`** = une *déclaration* du bénévole : « le samedi, je peux Les fenêtres de disponibilité (`Availability`) ont été retirées à l'étape 42
venir de 14 h à 23 h » — fenêtre horaire libre, plusieurs par jour. (table supprimée, migration `0022`) : l'appli ne connaît que les
- **`Assignment`** = une *décision* de l'organisation : « tu tiens le bar **`Assignment`**, *décisions* de l'organisation (« tu tiens le bar samedi
samedi 18 h – 22 h ». 18 h – 22 h »). Seule subsiste la case informative « je suis disponible » du
formulaire public (`Volunteer.self_declared_available`), sans aucun effet sur
Deux tables distinctes, deux écrans distincts, deux vocabulaires distincts. les affectations.
--- ---
@@ -58,33 +57,53 @@ les tables sauf les tables d'association.
- **`Edition`** : `year`, `name` (opt.), `start_date`, `end_date`, - **`Edition`** : `year`, `name` (opt.), `start_date`, `end_date`,
`retention_days` (durée de rétention des données nominatives en jours, `retention_days` (durée de rétention des données nominatives en jours,
`NULL` = pas de purge). `NULL` = pas de purge), `volunteer_message` (**message de
l'organisation**, le même pour tous les bénévoles, affiché en bas de
l'écran bénévole — saisi par la coordination, onglet « Message aux
bénévoles » de la configuration, `ConfigMessage.tsx`, via `PATCH
/editions/{id}`) et `volunteer_message_updated_at` (date affichée sous le
message). Même règle que `Position.message` : `app/api/_common.py:
set_message` — bords retirés, vide ⇒ `NULL`, date posée seulement si le
texte change, 2 000 caractères au plus.
- **`DayPart`** (« créneau type ») : découpage d'une journée propre à l'édition - **`DayPart`** (« créneau type ») : découpage d'une journée propre à l'édition
(nombre et bornes libres). `code`, `label`, `start_time`, `end_time` (nombre et bornes libres). `code`, `label`, `start_time`, `end_time`
(`end_time < start_time` ⇒ passe minuit), `order`. Unique (`edition_id`, (`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 `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). postes.
CRUD `GET/POST/PATCH/DELETE /editions/{id}/day-parts` (COORDINATION pour CRUD `GET/POST/PATCH/DELETE /editions/{id}/day-parts` (COORDINATION pour
l'écriture). l'écriture).
- **`Volunteer`** : `email` (nullable, **pas unique** — plusieurs bénévoles - **`Volunteer`** : `email` (nullable, **pas unique** — plusieurs bénévoles
peuvent partager une même adresse, ex. un couple ou une famille ; c'est le 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), 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, `first_name`, `last_name`, `phone`, `comment`. **Aucun champ obligatoire
à l'ajout manuel** par la coordination (`POST .../volunteers`, prénom et
nom vides par défaut — fiche complétée plus tard) ; le formulaire public
et l'import exigent toujours prénom et nom. Un bénévole sans nom
s'affiche « Sans nom », initiales « ? » (`lib/volunteer.ts`). Pas de date de naissance,
taille de t-shirt, régime, ni compétences. Porte trois listes de vœux, 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`, exposées par la fiche (`artist_ids`, `wished_position_ids`,
`wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider). `wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
Renseignables aussi via le formulaire public (§ 9) : `photo`/ Renseignables aussi via le formulaire public (§ 9) : `photo`/
`photo_content_type` (image, servie séparément par `photo_content_type` (image, servie séparément par
`GET .../volunteers/{id}/photo`, COORD/LEAD), `companion_wish_note` (« je `GET .../volunteers/{id}/photo`, COORD/LEAD — la coordination peut aussi
l'ajouter, la remplacer ou la retirer depuis la fiche, section « Photo » :
`PUT`/`DELETE .../volunteers/{id}/photo`, COORDINATION, même validation
que le formulaire, `app/api/_common.py:read_photo`), `companion_wish_note` (« je
voudrais être avec » en texte libre — la personne visée n'existe pas 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 forcément encore en base au moment de l'inscription, donc pas de lien
direct vers un `VolunteerCompanionWish` ; la coordination la résout à la direct vers un `VolunteerCompanionWish` ; la coordination la résout à la
main depuis la fiche une fois les deux bénévoles inscrits), main depuis la fiche une fois les deux bénévoles inscrits),
`self_declared_available` (case à cocher **purement informative**, sans `self_declared_available` (case à cocher **purement informative**, sans
lien avec `Availability`) et `willing_to_pay_fee` (« je participe à la effet sur les affectations) et `willing_to_pay_fee` (« je participe à la
cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/ cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/
`fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le `fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le
paiement SumUp plus bas). paiement SumUp plus bas). `badge_text` : texte du badge propre à ce bénévole
(champ `FREE_TEXT`, voir `BadgeField`), saisi par la coordination sur la
fiche (« Texte du badge ») — jamais par le formulaire public — et qui
remplace pour lui le texte commun de l'éditeur. `badge_count` : nombre
d'exemplaires de son badge imprimés sur les planches (1 par défaut, 1 à
100 — `CHECK >= 1` en base, borne haute à la saisie contre une faute de
frappe), « Nombre de badges » sur la fiche.
- **`Artist`** : programmation de l'édition (`name`, `performance_date`, - **`Artist`** : programmation de l'édition (`name`, `performance_date`,
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le `start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
lendemain). Unique (`edition_id`, `name`, `performance_date`). La lendemain). Unique (`edition_id`, `name`, `performance_date`). La
@@ -117,7 +136,21 @@ les tables sauf les tables d'association.
compte `LEAD` qui ne référence plus aucun poste (tous décochés) reste donc 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 `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 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. (`VolunteerHomeView`) plutôt qu'un écran référent vide. Un référent peut
aussi être affecté comme bénévole (même soirée comprise) : si son compte
est rattaché à une fiche bénévole, `/mobile-referent` a deux onglets,
« Mon équipe » et « Mon planning » (sa vue bénévole), ouvert sur « Mon
planning » quand il tient en ce moment un créneau de bénévole hors de ses
postes sans qu'aucun de ses postes ait un créneau en cours ; dans « Mon
équipe », il est marqué « TOI » s'il fait partie de l'équipe pointée.
- **`Position.message`** / `message_updated_at` : **message du poste**
(consignes : tenue, matériel, rendez-vous…), saisi par le référent depuis
son écran (`/mobile-referent`, « Message à ton équipe ») ou par la
coordination (`ConfigMessage.tsx`), via `PATCH .../positions/{id}` (même
contrôle d'écriture que le reste du poste : COORDINATION ou son
référent). Montré à chaque bénévole affecté au poste : repris dans
`GET .../volunteers/{vid}/planning` (`position_message`,
`position_message_updated_at`).
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont - **`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` 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 (peut finir le lendemain), `min_staff`, `ideal_staff`. **Aucun créneau
@@ -131,14 +164,6 @@ les tables sauf les tables d'association.
0/0 par jour de l'édition et par `DayPart` manquant (bouton « Générer les 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 créneaux », idempotent — n'écrase jamais un créneau déjà généré, même
modifié depuis). 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`, - **`Assignment`** : `volunteer_id`, `shift_id`, `source` (`AssignmentSource`,
défaut `MANUAL`), `locked` (défaut `false`). Unique (`volunteer_id`, défaut `MANUAL`), `locked` (défaut `false`). Unique (`volunteer_id`,
`shift_id`). **Pas de statut** : l'affectation est valide dès sa création `shift_id`). **Pas de statut** : l'affectation est valide dès sa création
@@ -149,6 +174,17 @@ les tables sauf les tables d'association.
jamais donner à un compte VOLUNTEER l'accès en lecture à `positions`/ 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`) `shifts` de toute l'édition — c'est l'écran bénévole (`/mobile-benevole`)
qui en a besoin. qui en a besoin.
**Pointage** : `attendance` (`AttendanceStatus` `PRESENT` / `ABSENT`,
`NULL` = pas pointé) et `attendance_marked_at` (heure du dernier
pointage), posés par `PUT .../assignments/{id}/attendance`
(`{attendance}`, `null` annule — même contrôle d'écriture que
l'affectation : COORDINATION ou référent du poste). Écran référent : un
second appui sur le même bouton annule, mise à jour immédiate à l'écran
puis enregistrée (`hooks.ts:useSetAttendance`, retour arrière et message
si l'enregistrement échoue). Ce n'est pas le statut d'une affectation
(toujours valide dès sa création), juste la présence constatée sur
place. Avant l'étape 58, ce pointage n'était gardé que dans le
navigateur et se perdait au rechargement.
- **`Account`** : `email` (unique par édition), `role` (`Role`), `edition_id`, - **`Account`** : `email` (unique par édition), `role` (`Role`), `edition_id`,
`volunteer_id` (nullable, unique). `volunteer_id` (nullable, unique).
- **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`, - **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`,
@@ -165,25 +201,88 @@ les tables sauf les tables d'association.
et la génération retombant alors sur le format CR80 par défaut (85,6 × 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 54 mm, `DEFAULT_BADGE_WIDTH_MM`/`HEIGHT_MM`). Le verso est ajusté (sans
déformation, `preserveAspectRatio=True`) à ce même format. déformation, `preserveAspectRatio=True`) à ce même format.
- **`BadgeField`** : un champ positionné sur le recto (`field_type` ∈ - **`BadgeField`** : un champ positionné sur le **recto ou le verso**
`FULL_NAME` / `POSITIONS` / `CATEGORY` / `ROLE`, au plus un par édition — (`face` ∈ `FRONT` / `BACK` ; `field_type` ∈ `FULL_NAME` / `POSITIONS` /
`PUT .../badges/template/fields` remplace tout), coordonnées `x_mm`/`y_mm`/ `CATEGORY` / `ROLE` / `PHOTO` / `FREE_TEXT`, au plus un par face et par
`width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du badge, édition — unique (`edition_id`, `face`, `field_type`), 422
`font_size`, `align`, `bold`, `color`. Édité visuellement (glisser-déposer) `badge.duplicate_field_type` sinon ; `PUT .../badges/template/fields`
depuis l'écran « Badges » du front-end (`frontend/src/pages/BadgesPage.tsx`), remplace tout, les deux faces dans la même liste), coordonnées `x_mm`/
qui envoie ces mêmes millimètres et redimensionne son aperçu au format réel `y_mm`/`width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du
du badge (`GET .../badges/template` renvoie `badge_width_mm`/`height_mm`). badge, `font_size`, `align`, `bold`, `color`, `bg_color` (fond plein de la
`POSITIONS` résout **une ligne par créneau affecté**, triée zone, `NULL` = transparent, `#rrggbb` sinon 422 `badge.invalid_color`),
chronologiquement : nom du poste + horaire (`HH:MM–HH:MM`), suivi du `auto_fit` (taille réduite par pas de 0,5 pt, plancher 4 pt, jusqu'à ce que
référent du poste (`Position.lead_account_id` → son bénévole) s'il y en a tout tienne — `app/badges/pdf.py:fit_text` ; sans, les lignes en trop ne
un et que ce n'est pas le bénévole badgé lui-même (texte multi-lignes, sont pas imprimées), `photo_shape` (`RECT` / `OVAL`, champ `PHOTO`),
`\n`) ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ; `z_order` (ordre d'empilement sur la face : les champs sont dessinés du
`ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent »). plus petit au plus grand, à égalité la photo dessous — avant, l'ordre
alphabétique des types faisait passer la photo par-dessus le nom et le
texte libre ; l'éditeur renvoie toujours des rangs distincts 0…n-1,
`badges/model.ts:stackOrder`/`restack`, boutons « Superposition » de
l'inspecteur, un champ activé arrive au-dessus, la photo dessous ; l'aperçu
suit le même ordre, le cadre du champ sélectionné restant par-dessus pour
qu'un champ recouvert se saisisse encore).
`FREE_TEXT` et `ROLE` sont centrés verticalement dans leur cadre (règle
dérivée du type), les autres en haut. `FULL_NAME` = prénom, puis nom à la
ligne. Édité visuellement depuis l'écran « Badges »
(`frontend/src/pages/badges/` — refonte d'après `design/Créateur de
badges.dc.html` : liste des champs recto/verso, aperçu des deux faces avec
les données d'un vrai bénévole, inspecteur, formats prédéfinis via
`PUT .../template/size`, annuler, flèches, zoom, repères de découpe
1,5 mm, contrôle de tous les badges avant génération — `issues.ts`).
L'aperçu reprend la mise en page du PDF à l'identique
(`badges/layout.ts`, avec les métriques Helvetica de ReportLab recopiées
dans `helveticaMetrics.ts`, pas une mesure du navigateur) : toute
règle de mise en page changée dans `app/badges/pdf.py` doit l'être aussi
dans `layout.ts`, sinon le contrôle avant génération ment.
`POSITIONS` est un **tableau** (`app/api/badges.py:_positions_table`,
dessiné par `app/badges/pdf.py:_draw_table`) des créneaux affectés,
**regroupés par jour de festival** (§ 3 — une soirée qui passe minuit
reste sur le jour où elle a commencé) : le jour en en-tête sur toute la
largeur (« Samedi 11 »), puis une ligne par créneau, colonne horaire
(`HH:MM–HH:MM`, à sa largeur naturelle) et colonne poste (le reste,
repliée si besoin), dans l'ordre chronologique. Pas de référent sur le
badge (choix de la coordination). Une ligne qui ne tient plus dans la
hauteur du cadre n'est pas imprimée ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ;
`ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent ») ;
`FREE_TEXT` imprime `Volunteer.badge_text` s'il est renseigné, sinon le
texte commun saisi dans l'éditeur (`BadgeField.text`, ignoré pour les
autres types), sinon rien ; `PHOTO` imprime
`Volunteer.photo` dans le cadre du champ (style de texte ignoré), recadrée
au ratio du cadre sans déformation, orientation EXIF appliquée et réduite
à 300 dpi (`app/badges/pdf.py:_fit_photo` — une photo de téléphone de
plusieurs Mo répétée sur 200 badges alourdirait le PDF) ; sans photo ou
photo illisible, **rien n'est imprimé** (cadre vide, choix de la
coordination — avant l'étape 61, les initiales sur fond beige), sans
faire échouer la planche ; l'éditeur n'y montre qu'un repère pointillé
« sans photo », non imprimé ; `OVAL` découpe la photo en ellipse. Le texte d'un champ passe à la ligne pour tenir dans la largeur
de son cadre (`app/badges/pdf.py:wrap_text` — entre deux mots, ou au
milieu d'un mot trop long seul ; les `\n` restent des retours à la
ligne), et ce qui dépasse la hauteur du cadre n'est pas imprimé — comme
l'aperçu de l'éditeur, qui fait de même côté navigateur. Avant, chaque
ligne s'imprimait d'un bloc et débordait du cadre. L'éditeur ramène tout champ à l'intérieur du badge
(`badges/model.ts:fitToBadge`) et met le champ sélectionné au premier
plan : les positions par défaut visent un CR80 paysage, et sur un badge
plus étroit un champ pouvait tomber hors du badge, avec des bornes de
glisser-déposer négatives qui le bloquaient — bug corrigé une fois (photo
impossible à déplacer ni redimensionner).
`POST /editions/{id}/badges/generate` (COORDINATION, `{volunteer_ids?}` — `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 absent = tous ; chaque bénévole y figure `badge_count` fois, à la suite)
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 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 à par planche, de la page de verso correspondante (image de verso puis
chaque emplacement) — impression recto-verso classique. champs `BACK` de chaque bénévole) — impression recto-verso classique
(retournement bord long) : sur la page de verso, chaque badge est placé
**en miroir horizontal** (colonne `cols-1-col`, `_cell_origin(mirrored=
True)`) pour tomber derrière son recto. La grille est **centrée et symétrique** sur la
page (`app/badges/pdf.py:_spacing`) : l'espace libre est réparti à parts
égales — même écart à gauche, entre les badges et à droite, et de même en
haut, entre les rangées et en bas (CR80 sur A4 : 12,9 mm et 16,2 mm),
marge d'impression de 10 mm au moins ; une fois la feuille retournée,
chaque verso tombe exactement derrière son recto. Avant l'étape 71, la
grille partait du coin haut-gauche et l'espace restant tombait à droite
et en bas, d'où un verso décalé (bug corrigé une fois). Indispensable depuis que le verso
est personnalisé ; avant, un verso identique pour tous masquait le
décalage.
- **Feuilles de poste** (PDF, `app/sheets/pdf.py` + `app/api/position_sheets.py`) : - **Feuilles de poste** (PDF, `app/sheets/pdf.py` + `app/api/position_sheets.py`) :
aucune nouvelle entité — dérivées de `Position`/`Shift`/`Assignment`/ aucune nouvelle entité — dérivées de `Position`/`Shift`/`Assignment`/
`Volunteer` existants. Complément des badges (un badge identifie un `Volunteer` existants. Complément des badges (un badge identifie un
@@ -192,13 +291,52 @@ les tables sauf les tables d'association.
téléphone — « à désigner » si aucun) en en-tête, puis les créneaux du jour 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. avec les bénévoles affectés et leur téléphone, pour affichage sur place.
`POST /editions/{id}/position-sheets/generate` (COORDINATION, `POST /editions/{id}/position-sheets/generate` (COORDINATION,
`{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune `{position_ids?}` — absent = tous). Mise en page compacte, pour tenir
affectation n'apparaît pas (rien à imprimer). sur une page par poste et par jour autant que possible : bénévoles **deux
par ligne** (vignette de 10 mm, nom en gras et téléphone dessous),
en-têtes et marges resserrés ; un créneau à besoin 0/0 sans aucune
affectation n'apparaît pas (rien à imprimer). Le **message de
l'organisation** (`Edition.volunteer_message`, s'il y en a un) est
imprimé en pied de **chaque** page, suite d'une feuille comprise
(`SheetFooter`, marge du bas agrandie de sa hauteur pour que le contenu
ne passe jamais dessous). Photo de chaque bénévole en
vignette (10 mm) : réduite à 300 dpi (`app/sheets/pdf.py:_thumbnail`,
décodage JPEG à taille réduite via `draft`) une seule fois par PDF, une
photo illisible laissant la case vide. Avant l'étape 62, la photo
d'origine (plusieurs Mo, prise au téléphone) était intégrée telle quelle
pour chaque bénévole de chaque créneau : génération de plusieurs minutes,
serveur à 300 % de CPU, PDF énorme et « erreur inattendue » (bug corrigé
une fois). Même principe pour les badges (`_fit_photo` + `PhotoCache` :
une photo recadrée une fois par génération, malgré recto, verso et
exemplaires multiples).
- **Statistiques** (`GET /editions/{id}/stats`, COORDINATION,
`app/api/stats.py` ; écran `/statistiques`, `StatsPage.tsx`) : aucune
nouvelle entité, tout est recalculé à chaque appel. Inscriptions (total,
7 derniers jours, évolution cumulée par jour d'ajout `created_at`,
jours sans inscription inclus), réponses (part des bénévoles ayant
renseigné chaque information, artistes demandés), cotisations (mode de
paiement — même règle que `lib/fee.ts:feeMode` —, montants par type,
mécénat, `PendingFormSubmission` jamais payées), besoins face aux
bénévoles : par **journée de festival** (§ 3), heures × personnes
demandées (min/idéal) vs heures affectées ; par poste, places min/idéales, places **pourvues plafonnées
créneau par créneau à l'effectif idéal** (`filled` — un sureffectif ne
compense jamais un trou ailleurs ; `assigned` garde le total brut) et
nombre de vœux. Graphiques en SVG maison (`components/charts.tsx`,
aucune bibliothèque), palette validée (daltonisme/contraste) sur le fond
`paper`, chaque graphique doublé d'une vue tableau. Le mock calcule les
mêmes chiffres (`buildStats`) et étale les dates d'inscription du jeu
de démo (`registeredAt`). **Présence** (`attendance`,
`_attendance` / mock `buildAttendance`) : présents, absents et non
pointés (`Assignment.attendance`) par poste, sur les créneaux terminés
et sur ceux en cours à `as_of` (créneaux à venir ignorés), avec le taux
de présence parmi les bénévoles pointés ; la section passe en tête de
l'écran tant qu'un créneau est en cours, et l'écran se rafraîchit
toutes les 30 s (`useStats`, `refetchInterval`).
- **Export bénévoles** (Excel, `app/exporting/xlsx.py` + `GET - **Export bénévoles** (Excel, `app/exporting/xlsx.py` + `GET
/editions/{id}/volunteers/export`, COORDINATION) : une ligne par /editions/{id}/volunteers/export`, COORDINATION) : une ligne par
bénévole, mêmes champs que la fiche (coordonnées, vœux, cotisation, bénévole, mêmes champs que la fiche (coordonnées, vœux, cotisation,
mécénat) — les vœux (postes/artistes/compagnons) sont résolus en noms mécénat, transaction SumUp — code et identifiant) — les vœux (postes/
lisibles, jamais en UUID. `openpyxl`, déjà utilisé en lecture par l'import artistes/compagnons) sont résolus en noms lisibles, jamais en UUID. `openpyxl`, déjà utilisé en lecture par l'import
(`app/importing/`), cette fois en écriture. Bouton « Exporter en Excel » (`app/importing/`), cette fois en écriture. Bouton « Exporter en Excel »
sur l'écran de configuration des bénévoles (`ConfigVolunteers.tsx`), même sur l'écran de configuration des bénévoles (`ConfigVolunteers.tsx`), même
mécanique de téléchargement que les badges/feuilles de poste (blob → mécanique de téléchargement que les badges/feuilles de poste (blob →
@@ -350,7 +488,17 @@ les tables sauf les tables d'association.
ou supprimée ensuite. `Volunteer.fee_checkout_id` (identifiant SumUp, ou supprimée ensuite. `Volunteer.fee_checkout_id` (identifiant SumUp,
jamais exposé) et `fee_payment_status` (`PENDING`/`PAID`/`FAILED`/ jamais exposé) et `fee_payment_status` (`PENDING`/`PAID`/`FAILED`/
`EXPIRED`, `NULL` tant qu'aucun checkout n'a été créé) suivent l'état ; `EXPIRED`, `NULL` tant qu'aucun checkout n'a été créé) suivent l'état ;
`fee_paid_at` est posé à la première confirmation `PAID`. Une fois promue, `fee_paid_at` est posé à la première confirmation `PAID`, avec la
transaction SumUp qui a réglé le checkout : `fee_transaction_code` (code
court du tableau de bord SumUp et de ses reçus, ex. `TEENSK4W2K` — lu sur
le checkout, ou à défaut sur son essai `SUCCESSFUL` dans `transactions`)
et `fee_transaction_id` (identifiant technique) — exposés sur la fiche
(section « Cotisation ») et dans l'export Excel, pour le rapprochement
comptable. Dans l'autre sens, l'API de création de checkout n'ayant
**aucun champ de métadonnées libre**, c'est la `description` du checkout
(« affichée dans les outils SumUp ») qui porte le nom du bénévole :
`Prénom Nom — libellé — édition AAAA` (`_checkout_description` ; donnée
transmise au prestataire de paiement, pas un log). Une fois promue,
la ligne `PendingFormSubmission` correspondante voit ses champs nominatifs la ligne `PendingFormSubmission` correspondante voit ses champs nominatifs
vidés (RGPD : l'information ne vit plus que sur `Volunteer`) ; une ligne vidés (RGPD : l'information ne vit plus que sur `Volunteer`) ; une ligne
jamais payée est purgée après 48 h sans être relancée jamais payée est purgée après 48 h sans être relancée
@@ -368,7 +516,7 @@ Conventions techniques :
- Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les - Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les
`edition_id` et sur les liens vers `volunteer`, `shift`, `position`, `edition_id` et sur les liens vers `volunteer`, `shift`, `position`,
`artist`, `account`. La suppression complète d'un bénévole efface `artist`, `account`. La suppression complète d'un bénévole efface
donc ses `Availability`, `Assignment`, vœux (artistes, postes, compagnons — donc ses `Assignment`, vœux (artistes, postes, compagnons —
dans les deux sens : ceux qu'il a émis et ceux qui le visaient) et son dans les deux sens : ceux qu'il a émis et ceux qui le visaient) et son
`Account`. `Account`.
- Dates/heures **naïves**, heure locale Europe/Paris (`TZ` du conteneur + - Dates/heures **naïves**, heure locale Europe/Paris (`TZ` du conteneur +
@@ -393,11 +541,11 @@ Matrice appliquée par les endpoints :
| `assignments` écriture | ✔ | ses postes | — | | `assignments` écriture | ✔ | ses postes | — |
| `assignments` lecture | ✔ | ✔ | les siennes | | `assignments` lecture | ✔ | ✔ | les siennes |
| `artists` lecture / écriture | ✔ / ✔ | ✔ / — | ✔ / — | | `artists` lecture / écriture | ✔ / ✔ | ✔ / — | ✔ / — |
| `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — |
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — | | `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
| `badges` (config + génération) | ✔ | — | — | | `badges` (config + génération) | ✔ | — | — |
| `position-sheets` (génération) | ✔ | — | — | | `position-sheets` (génération) | ✔ | — | — |
| `form` (config) | ✔ | — | — | | `form` (config) | ✔ | — | — |
| `stats` | ✔ | — | — |
| `form/public`, `form/public/submit` | *(aucune authentification — voir § 2, `FormField`)* ||| | `form/public`, `form/public/submit` | *(aucune authentification — voir § 2, `FormField`)* |||
**Authentification — deux systèmes selon le compte**, une même session JWT **Authentification — deux systèmes selon le compte**, une même session JWT
@@ -438,7 +586,26 @@ compte** : c'est un champ de la fiche, pas une inscription. Tant qu'aucun
personne à qui écrire pour cet email et ne fait rien (202 quoi qu'il arrive, 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 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 de connexion » sur la fiche du bénévole (`ConfigVolunteers.tsx`), qui appelle
cet endpoint avec `role=VOLUNTEER`. cet endpoint avec `role=VOLUNTEER`. Seule exception : envoyer un
récapitulatif de planning contenant `{lien_connexion}` vaut aussi « donner
un accès » (voir plus bas).
*Lien de connexion permanent* — celui du récapitulatif de planning
(`{lien_connexion}`, `app/auth/permanent_link.py`) : connexion directe en
un clic, **réutilisable et sans expiration** (contrairement au lien magique
de la page de connexion, 30 min et usage unique), pour que le bénévole
retrouve son planning depuis n'importe quel email reçu. Rien n'est stocké :
jeton `pl.<id du compte>.<HMAC(JWT_SECRET, id + login_link_version)>`,
vérifié par le même `POST .../auth/verify` (même page `/connexion?token=`
côté front). Bénévoles et référents seulement — jamais un compte de la
coordination ni à mot de passe. Révocation : bouton « Renouveler le lien
de connexion » sur la fiche (`POST .../accounts/{id}/reset-login-link`,
COORDINATION, incrémente `Account.login_link_version`, migration `0031`) —
tous les liens déjà envoyés à ce compte cessent de fonctionner ; changer
`JWT_SECRET` les révoque tous. Un bénévole sans compte en reçoit un
(`VOLUNTEER`, à son email) à l'envoi d'un récapitulatif avec
`{lien_connexion}` ; si son email est déjà celui d'un autre compte (couple,
famille), pas de compte possible : lien vers la page de connexion seule.
`PATCH /editions/{id}/accounts/{account_id}` change uniquement le `role`, et `PATCH /editions/{id}/accounts/{account_id}` change uniquement le `role`, et
seulement entre `LEAD` et `VOLUNTEER` (lien magique dans les deux cas) — 422 seulement entre `LEAD` et `VOLUNTEER` (lien magique dans les deux cas) — 422
@@ -464,6 +631,22 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
alias validé. alias validé.
- Diagnostic : `docker compose run --rm backend uv run python -m app.email.check - 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. <destinataire>` — affiche la config effective et la cause exacte d'un échec.
- **Email de test depuis l'appli** : bloc « Tester l'envoi d'emails » de
l'onglet « Messages » de la configuration (`ConfigMail.tsx`) — une
adresse et un bouton. `POST /editions/{id}/notifications/test` (`{email}`,
COORDINATION) envoie **tout de suite** (pas en tâche de fond) le
**récapitulatif de planning tel que les bénévoles le recevront** : le
modèle en cours d'édition (`subject`/`body`, case « avec le lien de
connexion » = même corps que le bouton du même nom), variables remplies
avec le planning d'un vrai bénévole (`volunteer_id`, à défaut le premier
par nom qui a un créneau, à défaut un exemple fictif), sujet préfixé
« [Test] », même mise en forme et même logo — code partagé avec l'envoi
réel (`notifications.py:_planning_email`). `{lien_connexion}` y mène à la
page de connexion **sans jeton** (jamais de vrai lien magique vers la
boîte de test). Renvoie le résultat réel (`app/email/sender.py:deliver_test`) : `sent`, `disabled`
(`EMAIL_ENABLED=false`), `connection_failed`, `rejected`, avec la réponse
du serveur SMTP (jamais l'adresse du destinataire), le serveur et
l'expéditeur configurés — sans accès au serveur ni à ses logs.
- **Mise en forme** : chaque email part en `multipart/alternative` — texte - **Mise en forme** : chaque email part en `multipart/alternative` — texte
brut (source de vérité, utilisé par les tests) **et** une version HTML au brut (source de vérité, utilisé par les tests) **et** une version HTML au
thème de l'appli (`app/email/html.py:render_html` — couleurs/typographie thème de l'appli (`app/email/html.py:render_html` — couleurs/typographie
@@ -485,11 +668,20 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
- **Récapitulatif de planning** : `POST /editions/{id}/notifications/planning` - **Récapitulatif de planning** : `POST /editions/{id}/notifications/planning`
(COORDINATION), corps `{ subject?, body?, volunteer_ids? }`. `subject`/`body` (COORDINATION), corps `{ subject?, body?, volunteer_ids? }`. `subject`/`body`
sont des **modèles** avec `{prenom}`, `{nom}`, `{nb_creneaux}`, `{nb_heures}`, sont des **modèles** avec `{prenom}`, `{nom}`, `{nb_creneaux}`, `{nb_heures}`,
`{creneaux}`, `{lien_connexion}` (si présent ⇒ un lien magique personnel est `{creneaux}`, `{lien_connexion}` (lien de connexion **permanent** du
émis pour le compte du bénévole). `volunteer_ids` absent ⇒ tous les bénévoles bénévole, voir « Lien de connexion permanent » plus haut — compte créé
s'il n'en a pas). `volunteer_ids` absent ⇒ tous les bénévoles
de l'édition qui ont un email. Envoi en tâche de fond (`BackgroundTasks`, 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, une connexion SMTP réutilisée). Réponse : `{ queued, skipped_no_email,
recipients }`. recipients }`. `{creneaux}` est un **tableau**, comme sur les badges : le jour de
festival en en-tête (une nuit reste sur le jour où elle a commencé),
puis une ligne par créneau — horaire, poste, référent du poste avec son
téléphone (« à désigner » sinon). Vrai tableau HTML dans la version HTML
(`app/email/html.py:render_planning_table`, inséré à la place d'un repère
via `build_message(html_body=…, html_blocks=…)`), et version texte une
ligne par créneau sous chaque jour (`email.planning.table.text_line`).
Composition partagée par l'envoi réel et l'email de test
(`notifications.py:_planning_email`, `_load_context`).
- **Confirmation d'inscription** (`app/api/forms.py`) : dès qu'un `Volunteer` - **Confirmation d'inscription** (`app/api/forms.py`) : dès qu'un `Volunteer`
est effectivement créé et a un email — que ce soit tout de suite (pas de est effectivement créé et a un email — que ce soit tout de suite (pas de
paiement en ligne) ou à la promotion d'une `PendingFormSubmission` paiement en ligne) ou à la promotion d'une `PendingFormSubmission`
@@ -517,29 +709,52 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
Module isolé `backend/app/validation/`. **Tests écrits avant l'implémentation**, Module isolé `backend/app/validation/`. **Tests écrits avant l'implémentation**,
un cas passant + un cas déclenchant par règle. un cas passant + un cas déclenchant par règle.
Retourne une liste de violations typées `BLOCKING` ou `WARNING`. Chaque Retourne une liste de violations typées `BLOCKING` ou `WARNING` (aucune règle
`BLOCKING` aujourd'hui depuis l'étape 44 : le type reste prévu par le contrat
de l'API). Chaque
violation est un **code** + des paramètres structurés, jamais une phrase (la violation est un **code** + des paramètres structurés, jamais une phrase (la
traduction se fait via `locales/fr.json`). traduction se fait via `locales/fr.json`).
| Code | Sévérité | Règle | | Code | Sévérité | Règle |
|---------------------------|------------|-------------------------------------------------------------| |---------------------------|------------|-------------------------------------------------------------|
| `SHIFT_OVERLAP` | `BLOCKING` | Deux créneaux d'un même bénévole se chevauchent. | | `SHIFT_OVERLAP` | `WARNING` | 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. | **Deux postes en même temps sont permis** (étape 44) : le chevauchement ne
| `INSUFFICIENT_REST` | `WARNING` | < 8 h 00 entre la fin d'un créneau et le début du suivant. | bloque plus l'affectation, il remonte en avertissement dans le panneau
d'affectation (« Déjà affecté sur … au même moment »). C'est le solveur qui
évite de le proposer (malus, § 4).
**Aucune limite de temps dans la journée** (étape 42) : plus de cumul
journalier maximal (`DAILY_HOURS_EXCEEDED`, ex-8 h), plus de repos minimal
entre deux créneaux (`INSUFFICIENT_REST`, ex-8 h), plus de contrôle de
disponibilité (`OUTSIDE_AVAILABILITY`). Ni la validation ni le solveur n'en
tiennent compte.
Sémantique figée : Sémantique figée :
- Toutes les affectations du bénévole comptent pour chevauchement / cumul / - Toutes les affectations du bénévole comptent pour le chevauchement (une
repos (une affectation = une décision, toujours active). affectation = une décision, toujours active).
- `OUTSIDE_AVAILABILITY` : on prend l'**union** des fenêtres `Availability` du - « Journée de festival » (plus utilisée par la validation elle-même, mais par
bénévole (converties en instants, minuit géré) ; les minutes du créneau hors les grilles, les feuilles de poste, les statistiques et le chaînage du
de cette union déclenchent l'avertissement. Aucune fenêtre déclarée ⇒ solveur) : commence au **début du plus long creux** que
créneau entièrement à découvert. laissent les `DayPart` sur le cadran de 24 h, c.-à-d. à l'heure où le
- « Journée » (cumul 8 h) : du début du premier `DayPart` du jour au début du dernier créneau type de la journée se termine (Matin 08–13, Après-midi
premier `DayPart` du lendemain (le `DayPart` ne sert plus qu'à ça). 13–19, Soirée 19–02 ⇒ journée de 02:00 à 02:00). **Indépendant de
- Cumul : `> 8 h 00` strict (8 h 00 pile passe). `DayPart.order`**, qui n'est qu'un ordre d'affichage figé à la création
- Repos : `< 8 h 00` strict. et ne suit pas les heures modifiées ensuite. Avant, c'était le début du
`DayPart` de plus petit `order`, ce qui pouvait donner une journée de
21:00 à 03:00 le lendemain dans les grilles (bug corrigé une fois). Sans
creux (24 h couvertes) : début du premier par `order` ; sans `DayPart` :
minuit.
**Même « journée de festival » partout** (`app/validation/engine.py:
day_boundary`/`festival_day`, miroir front `lib/planning.ts:festivalDay`) :
une soirée qui passe minuit (22h–23h, 23h–00h, 00h–01h…) reste d'un seul
tenant sur le jour où elle a commencé — grille des besoins, écran « Postes
& créneaux », feuilles de poste, chaînage du solveur. À la saisie d'un
créneau sur la carte d'un jour (`ConfigGrid.tsx`,
`shiftDatesForFestivalDay`), une heure de début avant cette borne désigne
la nuit qui suit (00:00 sur le samedi ⇒ dimanche 00:00), pas le petit
matin du même jour calendaire.
- Créneaux jointifs (`end_at == start_at`) : **pas** un chevauchement. - Créneaux jointifs (`end_at == start_at`) : **pas** un chevauchement.
- Une violation `BLOCKING` empêche l'enregistrement, sans dérogation. - Une violation `BLOCKING` empêche l'enregistrement, sans dérogation.
- Une violation `WARNING` est retournée à l'appelant mais n'empêche rien - Une violation `WARNING` est retournée à l'appelant mais n'empêche rien
@@ -562,22 +777,23 @@ affectation n'est jamais persistée par cet endpoint.
recalcule à la demande (rien n'est mis en cache côté serveur) et renvoie recalcule à la demande (rien n'est mis en cache côté serveur) et renvoie
`{ suggestions: [{ shift_id, volunteer_ids }], shifts_considered, `{ suggestions: [{ shift_id, volunteer_ids }], shifts_considered,
volunteers_considered }`, `volunteer_ids` du meilleur au moins bon. volunteers_considered }`, `volunteer_ids` du meilleur au moins bon.
- **Seule contrainte dure** : aucun chevauchement de créneaux pour un même - **Contraintes dures** : jamais deux créneaux qui se chevauchent suggérés au
bénévole (règle `SHIFT_OVERLAP`) — à la fois contre les affectations déjà même bénévole dans un même calcul ; jamais un bénévole suggéré sur un
décidées et entre deux créneaux nouvellement suggérés. L'effectif idéal est créneau où il est déjà affecté. L'effectif idéal est un plafond dur (le
un plafond dur (le solveur ne sur-staffe jamais). solveur ne sur-staffe jamais). Un bénévole **déjà affecté à un autre poste
au même moment** reste proposable (la validation ne fait qu'avertir, § 3)
mais avec un fort malus (`_ALREADY_BUSY_PENALTY`, 300 — plus que tous les
bonus cumulés et que la base d'un créneau au-dessus de son minimum) : en
pratique, il n'est proposé que pour un créneau sous son effectif minimal,
et seulement faute de mieux.
- Tout le reste est un terme de l'objectif (maximisation), pas une - 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 contrainte — cohérent avec le moteur de validation, qui reste seul juge au
moment où la coordination confirme une affectation : moment où la coordination confirme une affectation :
- un créneau encore sous son effectif **minimal** prime largement sur tout - un créneau encore sous son effectif **minimal** prime largement sur tout
le reste (le solveur doit presque toujours proposer quelqu'un pour un 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 trou urgent, quitte à ce que ce soit un pis-aller) ;
avertissement) ;
- bonus si le poste fait partie des vœux du bénévole - bonus si le poste fait partie des vœux du bénévole
(`VolunteerPositionWish`) ; (`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 - 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** immédiatement précédent, le même jour — **n'importe quel poste**
(`SolverShift.predecessor_shift_ids`) : limite les enchaînements de (`SolverShift.predecessor_shift_ids`) : limite les enchaînements de
@@ -590,11 +806,9 @@ affectation n'est jamais persistée par cet endpoint.
au créneau — plusieurs compagnons présents cumulent le bonus ; 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 - léger malus pour les bénévoles déjà bien chargés (répartition de la
charge). charge).
- **Pas de plafond de cumul journalier côté solveur** : contrairement à la - **Aucune limite de temps** : ni cumul journalier, ni repos minimal, ni
validation (`DAILY_HOURS_EXCEEDED`, § 3), le solveur ne pénalise pas un disponibilité (§ 3).
bénévole qui dépasserait 8 h sur la journée — seule la validation le - Simplification assumée : chaînage et compagnons ne sont vérifiés
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 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 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 se renforcent pas mutuellement) — la validation finale à la confirmation
@@ -602,9 +816,9 @@ affectation n'est jamais persistée par cet endpoint.
**Classement par défaut, sans lancer le solveur** — `frontend/src/lib/planning.ts` **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 (`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à poste, conflit d'artiste, compagnons souhaités déjà présents sur le créneau,
présents sur le créneau, charge déjà affectée ; pas de cumul 8 h / repos / déjà affecté ailleurs au même moment, charge déjà affectée ; pas de
chaînage, déjà couverts par les badges de validation). C'est ce chaînage). C'est ce
qui classe la colonne des candidats dans le panneau d'affectation dès 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 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 effectivement tourné (`POST .../solver/suggestions`), ses bénévoles retenus
@@ -644,10 +858,18 @@ bug. Largeur adaptative par jour (`columnWidthPx`,
en fonction du nombre de tranches) : peu de tranches ⇒ colonnes larges, en fonction du nombre de tranches) : peu de tranches ⇒ colonnes larges,
beaucoup ⇒ largeur compacte avec défilement horizontal — jamais de colonnes beaucoup ⇒ largeur compacte avec défilement horizontal — jamais de colonnes
étroites par défaut. étroites par défaut.
La « Vue bénévoles » (`VolunteerViewPage.tsx`) reste sur l'ancien modèle, La « Vue par bénévole » (`VolunteerViewPage.tsx`) utilise le même axe des
distinct : une colonne par **créneau** (signature `starts_at`/`ends_at`) temps (`buildTimeGrid` / `placeInTimeGrid`, même largeur de tranche par
plutôt qu'un axe temporel continu (`buildLattice` / `LatticeColumn`), plus jour) : chaque affectation est une barre étirée sur toute la durée de son
adapté à son tableau bénévole × créneau. créneau, nom du poste dedans. Deux postes tenus en même temps s'empilent
sur deux lignes dans la rangée du bénévole (qui s'agrandit) et passent en
jaune. Ligne verticale orange à l'heure actuelle, avec l'heure dans
l'en-tête des tranches. Barre de recherche (nom du bénévole ou d'un de
ses postes, sans accents ni casse) : ne garde que les bénévoles qui
correspondent, et cerne d'orange les postes trouvés. Avant l'étape 60, une colonne par créneau distinct
(`buildLattice`, supprimé) : une affectation n'apparaissait que dans la
colonne de son propre créneau, jamais sur les autres colonnes qu'elle
couvrait, et deux postes simultanés se masquaient.
--- ---
@@ -657,7 +879,7 @@ adapté à son tableau bénévole × créneau.
1. Modèle de données complet + migrations Alembic. 1. Modèle de données complet + migrations Alembic.
2. Import CSV/Excel (identité des bénévoles : nom, email, téléphone, 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 commentaire), en deux
temps : analyse (colonnes détectées, aperçu) puis exécution avec mapping temps : analyse (colonnes détectées, aperçu) puis exécution avec mapping
validé. Rapport ligne par ligne. Détection des doublons : upsert sur validé. Rapport ligne par ligne. Détection des doublons : upsert sur
(`email`, `last_name` + `first_name`) — l'email seul ne suffit pas, (`email`, `last_name` + `first_name`) — l'email seul ne suffit pas,
@@ -670,17 +892,18 @@ adapté à son tableau bénévole × créneau.
5. Endpoint matrice d'une édition : 5. Endpoint matrice d'une édition :
`[{ position, shifts: [{ shift, min_staff, ideal_staff, assigned }] }]`. `[{ position, shifts: [{ shift, min_staff, ideal_staff, assigned }] }]`.
6. Jeu de données de démonstration : 120 bénévoles, 15 postes, 4 jours, 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 quelques trous d'effectif, programmation
(artistes les jours de festival) et vœux des bénévoles (postes, artistes) (artistes les jours de festival) et vœux des bénévoles (postes, artistes)
pour que le solveur ait matière à arbitrer. pour que le solveur ait matière à arbitrer.
7. Solveur de suggestions OR-Tools CP-SAT (section 4) — en cours. 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) ### 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 SMS · fonctions d'IA. *(L'envoi d'emails par SMTP est en
place — voir § 2. Le formulaire public d'inscription est en place — voir § 2 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 à et § 9 ; ce n'était pas prévu à l'origine, retiré de cette liste à
l'étape 21.)* l'étape 21. Le pointage présent / absent par le référent est en place depuis
l'étape 58 — `Assignment.attendance`, § 2.)*
--- ---
@@ -706,13 +929,14 @@ l'étape 21.)*
│ │ ├── main.py # instance FastAPI │ │ ├── main.py # instance FastAPI
│ │ ├── config.py # settings (pydantic-settings) │ │ ├── config.py # settings (pydantic-settings)
│ │ ├── db.py # engine + session │ │ ├── db.py # engine + session
│ │ ├── logging_config.py # RGPD : pas de PII dans les logs │ │ ├── logging_config.py # RGPD : pas de PII dans les logs ; identifiant de requête
│ ├── request_logging.py # une ligne de log par requête, X-Request-ID, erreurs 500
│ │ ├── api/ # routers + schemas Pydantic + deps │ │ ├── api/ # routers + schemas Pydantic + deps
│ │ │ # health, auth, accounts, editions, │ │ │ # health, auth, accounts, editions,
│ │ │ # day_parts, imports, artists, volunteers, │ │ │ # day_parts, imports, artists, volunteers,
│ │ │ # availability, positions, shifts, │ │ │ # positions, shifts,
│ │ │ # assignments, solver, matrix, notifications, │ │ │ # assignments, solver, matrix, notifications,
│ │ │ # badges, position_sheets, forms │ │ │ # badges, position_sheets, forms, stats
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles │ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
│ │ ├── email/ # SMTP (sender) + rendu texte (templates) et HTML (html.py) + logo (branding.py) │ │ ├── email/ # SMTP (sender) + rendu texte (templates) et HTML (html.py) + logo (branding.py)
│ │ ├── importing/ # import CSV/Excel (parser, text, service) │ │ ├── importing/ # import CSV/Excel (parser, text, service)
@@ -767,11 +991,28 @@ Aucune dépendance locale requise hors Docker.
+ `locales/fr.json`. Frontend : fichier i18n dédié. Vrai même en monolingue. + `locales/fr.json`. Frontend : fichier i18n dédié. Vrai même en monolingue.
- **RGPD :** - **RGPD :**
- Pas de log des noms, emails, téléphones. On logue des identifiants. - Pas de log des noms, emails, téléphones. On logue des identifiants.
- **Logs** (`app/logging_config.py`, `app/request_logging.py`) : une ligne
par requête HTTP (`app.request` — méthode, route **modèle** + ses
identifiants, jamais l'URL brute ni un paramètre `*token*`, statut, durée,
code d'erreur métier `detail.code`, compte connecté) ; INFO / WARNING
(4xx) / ERROR (5xx), `/health` en DEBUG. Chaque requête a un identifiant
(`X-Request-ID`, repris s'il est fourni et bien formé) préfixé à toutes
ses lignes de log, tâches de fond comprises, et affiché à l'utilisateur
sous une erreur (`ErrorNote` : « Référence à communiquer ») — `grep` de
cette référence dans les logs du conteneur pour diagnostiquer. Erreur 422
de validation : emplacement + type, jamais la valeur saisie. Exception non
gérée : trace complète + `500 {detail: {code: "error.internal",
request_id}}`. Journal d'accès d'uvicorn **désactivé** (il écrivait la
chaîne de requête, donc le `access_token` du formulaire public). Logs
métier sur les parcours opaques : paiement SumUp (inscription en attente,
checkout créé/refusé avec le code d'erreur SumUp, changements de statut,
promotion, webhook), connexion (cause réelle d'un refus, côté logs
seulement), import, envoi de plannings, solveur (durées).
- `Edition.retention_days` : durée de rétention paramétrable. - `Edition.retention_days` : durée de rétention paramétrable.
- Endpoint de suppression complète d'un bénévole : *hard delete* en cascade. - 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 - **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 » Europe/Paris (heure murale). La « journée de festival » est en heure
sont toutes en heure locale. locale.
- **Validation TDD :** tests avant implémentation, un cas passant + un cas - **Validation TDD :** tests avant implémentation, un cas passant + un cas
déclenchant par règle. déclenchant par règle.
- **Git :** un commit atomique par étape validée, message en français. - **Git :** un commit atomique par étape validée, message en français.
@@ -852,7 +1093,41 @@ Aucune dépendance locale requise hors Docker.
| 35 | Type de paiement à montant libre (`FormPaymentOption.free_amount`, migration `0021`) : case « Montant libre » sur l'écran Inscriptions, montant configuré = minimum, saisie du montant sur le formulaire public (validée côté client et serveur, 10 000 € max) — pensé pour le mécénat | à valider | | 35 | Type de paiement à montant libre (`FormPaymentOption.free_amount`, migration `0021`) : case « Montant libre » sur l'écran Inscriptions, montant configuré = minimum, saisie du montant sur le formulaire public (validée côté client et serveur, 10 000 € max) — pensé pour le mécénat | à valider |
| 36 | Écran Inscriptions : état « Actif / Inactif » des types de paiement (`SumUpStatus`, `FormBuilderPage.tsx`) — sans identifiants SumUp ou sans type de paiement, le formulaire public n'affiche qu'une case « cotisation » (ni choix du type, ni étape mécénat, ni paiement en ligne), ce que rien ne signalait | à valider | | 36 | Écran Inscriptions : état « Actif / Inactif » des types de paiement (`SumUpStatus`, `FormBuilderPage.tsx`) — sans identifiants SumUp ou sans type de paiement, le formulaire public n'affiche qu'une case « cotisation » (ni choix du type, ni étape mécénat, ni paiement en ligne), ce que rien ne signalait | à valider |
| 37 | Cerfa mécénat : nouveau modèle PDF, date du versement repositionnée, date de signature remplie (date du paiement), case « Virement, prélèvement, carte bancaire » cochée (paiement SumUp) ; message de confirmation du paiement annonçant l'email et le reçu fiscal | à valider | | 37 | Cerfa mécénat : nouveau modèle PDF, date du versement repositionnée, date de signature remplie (date du paiement), case « Virement, prélèvement, carte bancaire » cochée (paiement SumUp) ; message de confirmation du paiement annonçant l'email et le reçu fiscal | à valider |
| 38 | Créneaux de nuit : une heure de début avant le début de journée (premier créneau type) saisie sur la carte d'un jour désigne la nuit qui suit (00:00 le samedi ⇒ dimanche 00:00) ; écran « Postes & créneaux », feuilles de poste et chaînage du solveur regroupent par journée de festival, comme la grille et la validation | à valider |
| 39 | Mode de paiement de la cotisation visible partout (`lib/fee.ts:feeMode`, `app/exporting/xlsx.py:_payment_mode`) : en ligne (type, montant, statut, mécénat), sur place, ou rien — section « Cotisation » sur la fiche (case « Paiera sur place » modifiable à la main), colonne « Cotisation » dans la liste, colonne « Mode de paiement » dans l'export Excel | à valider |
| 40 | Écran Statistiques (`/statistiques`, COORDINATION, `GET /editions/{id}/stats`) : inscriptions (nombre, évolution cumulée), réponses au formulaire, artistes demandés, cotisations (en ligne / sur place / aucune, par type, mécénat, paiements jamais aboutis), besoins par journée de festival face aux disponibilités déclarées et aux affectations, places pourvues et vœux par poste — graphiques SVG maison avec infobulles et vues tableau | à valider |
| 41 | Début de « journée de festival » indépendant de l'ordre des créneaux types : début du plus long creux qu'ils laissent sur 24 h (`validation.engine.day_boundary`, miroir `lib/planning.ts:dayBoundaryFromDayParts`). Avant, un créneau type d'ordre 0 déplacé à 21:00 faisait commencer chaque journée des grilles (besoins, vue bénévoles) à 21:00, jusqu'à 03:00 le lendemain | à valider |
| 42 | Retrait des disponibilités déclarées (table `availability` supprimée, migration `0022` ; API, éditeur de la fiche bénévole, badge de couverture du panneau d'affectation, colonnes des statistiques) et des limites de temps dans la journée (règles `OUTSIDE_AVAILABILITY`, `DAILY_HOURS_EXCEEDED`, `INSUFFICIENT_REST`, malus de disponibilité et de repos du solveur, filtre « Surchargés » de la vue bénévoles) — seul le chevauchement reste contrôlé. La case « je suis disponible » du formulaire public est conservée | à valider |
| 43 | Logs de diagnostic (ligne par requête avec route modèle, statut, durée, code d'erreur et compte ; identifiant `X-Request-ID` repris dans chaque log et affiché à l'utilisateur sous une erreur ; exceptions non gérées tracées ; journal d'accès d'uvicorn coupé car il exposait le jeton du formulaire public ; logs métier SumUp, connexion, import, plannings, solveur) et rapprochement SumUp ↔ bénévole : nom du bénévole dans la description du checkout (pas de métadonnées libres dans l'API), code et identifiant de transaction enregistrés au paiement (`Volunteer.fee_transaction_code`/`fee_transaction_id`, migration `0023`), affichés sur la fiche et dans l'export Excel | à valider |
| 44 | Un bénévole peut être affecté à deux postes en même temps : `SHIFT_OVERLAP` passe de `BLOCKING` à `WARNING` (affectation enregistrée, avertissement affiché) ; le solveur ne l'exclut plus mais lui applique un fort malus (`_ALREADY_BUSY_PENALTY`) — proposé seulement pour un créneau sous son minimum, faute de mieux — repris dans le classement par défaut du panneau et dans le mock | à valider |
| 45 | Responsive mobile / tablette / desktop : en-tête (onglets sur une seconde ligne en tablette, menu déroulant en mobile), panneau d'affectation de la grille des besoins et fiche bénévole en tiroir par-dessus sous `lg:` (plein écran en mobile) au lieu d'une colonne fixe qui écrasait la grille, colonne des postes de la grille rétrécie, liste des bénévoles réduite à nom + cotisation en mobile, écran « Postes & créneaux » empilé, aperçu de l'éditeur de badges mis à l'échelle de l'écran (glisser-déposer tactile), ligne des types de paiement repliée, marges réduites — desktop inchangé | à valider |
| 46 | Badges : champ « Photo du bénévole » (`PHOTO`, photo du formulaire recadrée au cadre, 300 dpi) et champ « Texte libre » (`FREE_TEXT`) alimenté par un nouveau « Texte du badge » sur la fiche bénévole (`Volunteer.badge_text`, migration `0024`, repris dans l'export Excel) | à valider |
| 47 | Photo ajoutée par la coordination : section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer), `PUT`/`DELETE /editions/{id}/volunteers/{vid}/photo` (COORDINATION), validation de l'image partagée avec le formulaire public (`read_photo`) | à valider |
| 48 | Badges : texte libre saisi dans l'éditeur (`BadgeField.text`, migration `0025`), commun à tous les badges, le « Texte du badge » de la fiche le remplaçant pour un bénévole ; champs ramenés dans le badge et champ sélectionné au premier plan (la photo restait bloquée hors d'un badge plus étroit que le CR80) | à valider |
| 49 | Badges : le texte des champs (nom, postes et horaires…) passe à la ligne pour tenir dans la largeur de son cadre au lieu de déborder (`app/badges/pdf.py:wrap_text`) | à valider |
| 50 | Badges : les créneaux affectés s'affichent en tableau — le jour de festival en en-tête, puis une colonne horaire et une colonne poste (le référent n'est plus imprimé sur le badge), filets à la couleur du champ, cellules repliées dans leur colonne ; même tableau dans l'aperçu de l'éditeur | à valider |
| 51 | Refonte du créateur de badges d'après la maquette (`design/`) : champs sur le recto **et le verso** (`BadgeField.face`, verso en miroir dans le PDF), fond de zone, réduction automatique du texte, photo ovale, initiales sans photo, nom sur deux lignes (migration `0026`) ; éditeur en 3 colonnes (champs, aperçu recto/verso avec de vrais bénévoles, inspecteur), formats prédéfinis, annuler/flèches/zoom/repères, dépôt d'image sur l'aperçu, contrôle de tous les badges avant génération | à valider |
| 52 | Nombre de badges par bénévole (`Volunteer.badge_count`, 1 par défaut, migration `0027`) : champ « Nombre de badges » sur la fiche, autant d'exemplaires sur les planches, total à imprimer affiché à la génération, colonne dans l'export Excel | à valider |
| 53 | Vue par bénévole : tri des lignes par nom ou par total d'heures affectées, croissant / décroissant (boutons « Trier par » et clic sur l'en-tête « TOTAL H ») — à total égal, l'ordre par nom est conservé | à valider |
| 54 | Badges : ordre de superposition des champs (`BadgeField.z_order`, migration `0028` — champs existants renumérotés photo dessous) : boutons « Premier plan / Avancer / Reculer / Arrière-plan » dans l'inspecteur, aperçu et PDF dessinés dans cet ordre — la photo recouvrait le texte | à valider |
| 55 | Écrans bénévole et référent redessinés d'après la maquette (`design/Planning bénévole mobile.dc.html`, pensée pour le téléphone — tablette et desktop gardent la même colonne, centrée) : bénévole — créneau en cours ou prochain mis en avant (compte à rebours, lieu, chevauchement, bouton « Appeler » le référent), planning par journée de festival avec frise horaire et statut de chaque créneau (terminé / en cours), message de fin de festival ; référent — onglets de postes, choix du créneau (en cours ou prochain par défaut), compteurs présents / attendus / absents, alerte d'absence, pointage (toujours local à l'écran) avec appel direct. Pas encore repris de la maquette, faute de donnée : téléphone de la coordination, bouton « Demander un renfort » (consigne du poste et message de l'organisation : étape 56) | à valider |
| 56 | Messages aux bénévoles : message de l'organisation, le même pour tous (`Edition.volunteer_message`, onglet « Message aux bénévoles » de la configuration), et message de chaque poste (`Position.message`, saisi par le référent sur son écran ou par la coordination), migration `0029` — affichés sur l'écran bénévole (bloc « Message de l'organisation » daté, consignes du poste sur le créneau mis en avant et dans la liste) | à valider |
| 57 | Référent aussi bénévole : onglets « Mon équipe » / « Mon planning » sur l'écran référent (compte rattaché à une fiche bénévole), ouverture sur celui qui correspond à ce qu'il fait en ce moment, pastille sur l'onglet dont un créneau est en cours, mention « TOI » dans l'équipe pointée | à valider |
| 58 | Pointage enregistré : présent / absent du référent sauvegardé sur l'affectation (`Assignment.attendance` + heure, migration `0030`, `PUT .../assignments/{id}/attendance`, COORDINATION ou référent du poste) — il survivait seulement jusqu'au rechargement de la page ; heure du pointage affichée, message si l'enregistrement échoue | à valider |
| 59 | Statistiques de présence : section « Présence » de l'écran Statistiques — en ce moment (créneaux en cours : attendus, présents, absents, non pointés par poste) et créneaux terminés (même détail + taux de présence parmi les pointés), rafraîchie toutes les 30 s, en tête de l'écran pendant un créneau | à valider |
| 60 | Vue par bénévole sur un vrai axe des temps (tranches de 30 min par jour, comme la grille des besoins) : affectations étirées sur toute leur durée avec le nom du poste, deux postes simultanés empilés sur deux lignes et signalés en jaune, ligne verticale orange de l'heure actuelle avec l'heure — une affectation n'apparaissait que dans la colonne de son propre créneau | à valider |
| 61 | Badges : sans photo, le cadre de la photo reste vide (plus d'initiales sur fond beige) — PDF, aperçu de l'éditeur (repère pointillé non imprimé) et messages de contrôle | à valider |
| 62 | Feuilles de poste : génération interminable (CPU à 300 %, puis « erreur inattendue ») — photos d'origine intégrées en pleine résolution pour chaque bénévole de chaque créneau ; désormais réduites à la vignette une fois par PDF (40 photos : 15 s / 24 Mo ⇒ 0,2 s / 109 Ko), photo illisible ignorée. Badges : photo recadrée une seule fois par génération et décodée à taille réduite (120 badges : 10 s ⇒ 0,3 s) | à valider |
| 63 | Feuilles de poste : message de l'organisation en bas de chaque page (titre + texte, retours à la ligne conservés), rien si aucun message | à valider |
| 64 | Vue par bénévole : barre de recherche sur le nom du bénévole ou d'un de ses postes (sans accents ni casse), postes trouvés cernés d'orange, cumulable avec les filtres et le tri | à valider |
| 65 | Feuilles de poste compactées : bénévoles deux par ligne (vignette 10 mm, nom et téléphone superposés), titres, en-têtes de créneau et marges resserrés — sur un jeu réaliste de 6 feuilles, 11 pages ⇒ 6 (une par poste et par jour) | à valider |
| 66 | Ajout manuel d'un bénévole sans champ obligatoire (prénom et nom facultatifs, côté formulaire et API) ; un bénévole sans nom s'affiche « Sans nom » / « ? » dans les listes, la grille, la vue par bénévole, les badges et l'écran référent | à valider |
| 67 | Email de test : adresse + bouton dans l'onglet « Messages » de la configuration, envoi immédiat et résultat réel affiché (envoyé, envoi désactivé, connexion refusée, message refusé, avec la réponse du serveur SMTP) — `POST .../notifications/test` | à valider |
| 68 | L'email de test est le récapitulatif de planning compilé tel qu'envoyé aux bénévoles (modèle en cours d'édition, variables remplies avec le planning d'un bénévole au choix, option lien de connexion — sans vrai jeton —, sujet « [Test] … ») | à valider |
| 69 | Récapitulatif de planning : `{creneaux}` devient un tableau par jour de festival (horaire, poste, référent avec son téléphone), en HTML dans l'email et en lignes lisibles dans la version texte — envoi réel et email de test | à valider |
| 70 | `{lien_connexion}` devient un lien de connexion directe permanent (réutilisable, sans expiration, signé HMAC, rien de stocké — avant : lien magique de 30 min à usage unique, ou simple lien vers le site sans compte) ; compte bénévole créé à l'envoi s'il manque ; révocation par « Renouveler le lien de connexion » sur la fiche (`Account.login_link_version`, migration `0031`) ; jamais pour la coordination | à valider |
| 71 | Planches de badges centrées : espace libre réparti à parts égales (mêmes écarts à gauche, entre les badges, à droite ; en haut, entre les rangées, en bas), grille symétrique — le verso, imprimé en miroir, tombe exactement derrière le recto au lieu d'être décalé de l'espace restant | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, IA. Frontend : squelette + 6 écrans +
connexion en place (voir `frontend/`). connexion en place (voir `frontend/`).
+57 -3
View File
@@ -3,15 +3,18 @@
`resolve_owned_ids`/`set_artists`/`set_wished_positions` sont utilisés à la `resolve_owned_ids`/`set_artists`/`set_wished_positions` sont utilisés à la
fois par `volunteers.py` (fiche éditée par la coordination) et `forms.py` fois par `volunteers.py` (fiche éditée par la coordination) et `forms.py`
(vœux saisis depuis le formulaire public) — un seul endroit pour la (vœux saisis depuis le formulaire public) — un seul endroit pour la
validation « ces ids appartiennent bien à cette édition ».""" validation « ces ids appartiennent bien à cette édition ». Idem pour
`read_photo` (photo d'un bénévole)."""
import io
import uuid import uuid
from fastapi import HTTPException from fastapi import HTTPException, UploadFile
from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete from sqlalchemy import delete
from sqlmodel import Session, select from sqlmodel import Session, select
from app.models import Artist, Position, VolunteerArtist, VolunteerPositionWish from app.models import Artist, Position, VolunteerArtist, VolunteerPositionWish, local_now
def owned_or_404(instance, edition_id: uuid.UUID): def owned_or_404(instance, edition_id: uuid.UUID):
@@ -77,3 +80,54 @@ def set_wished_positions(
filter_value=volunteer.id, filter_value=volunteer.id,
pairs=[{"volunteer_id": volunteer.id, "position_id": pid} for pid in ids], pairs=[{"volunteer_id": volunteer.id, "position_id": pid} for pid in ids],
) )
MAX_PHOTO_BYTES = 8 * 1024 * 1024
ALLOWED_PHOTO_TYPES = {"image/png", "image/jpeg"}
async def read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None:
"""Validation partagée par le formulaire public (inscription immédiate
ou en attente de paiement) et l'ajout d'une photo par la coordination
depuis la fiche (`volunteers.py:set_volunteer_photo`) — `None` si aucun
fichier fourni. `Content-Type` est
un en-tête fourni par l'appelant, jamais vérifié par lui-même (ce
endpoint est public, sans authentification) : un fichier qui ne se
décode pas comme une vraie image malgré un `Content-Type` correct
(`image/png`/`image/jpeg` revendiqué) est rejeté ici plutôt que de
planter plus tard la génération des badges/feuilles de poste
(`app/badges/pdf.py`/`app/sheets/pdf.py`, qui lisent ces octets sans
filet) — même validation que l'import d'image des badges
(`app/api/badges.py:_pixel_size`, réservé à la coordination)."""
if photo is None or not photo.filename:
return None
content_type = photo.content_type or ""
if content_type not in ALLOWED_PHOTO_TYPES:
raise HTTPException(
status_code=422, detail={"code": "form.photo_type_not_supported"}
)
content = await photo.read()
if len(content) > MAX_PHOTO_BYTES:
raise HTTPException(status_code=413, detail={"code": "form.photo_too_large"})
if not content:
return None
try:
with Image.open(io.BytesIO(content)) as img:
img.verify()
except (UnidentifiedImageError, OSError) as exc:
raise HTTPException(
status_code=422, detail={"code": "form.photo_type_not_supported"}
) from exc
return content, content_type
def set_message(target: object, field: str, value: str | None) -> None:
"""Message affiché aux bénévoles (`Edition.volunteer_message`,
`Position.message`) : espaces de bord retirés, vide = plus de message.
`{field}_updated_at` n'est daté que si le texte change vraiment — il dit
aux bénévoles de quand date le message."""
text = (value or "").strip() or None
if text == getattr(target, field):
return
setattr(target, field, text)
setattr(target, f"{field}_updated_at", local_now() if text else None)
+2 -12
View File
@@ -5,6 +5,7 @@ import datetime as dt
from sqlmodel import Session, select from sqlmodel import Session, select
from app.models import DayPart, Edition, Position, Shift from app.models import DayPart, Edition, Position, Shift
from app.validation.engine import day_boundary
def _dates(edition: Edition) -> list[dt.date]: def _dates(edition: Edition) -> list[dt.date]:
@@ -12,17 +13,6 @@ def _dates(edition: Edition) -> list[dt.date]:
return [edition.start_date + dt.timedelta(days=i) for i in range(span + 1)] return [edition.start_date + dt.timedelta(days=i) for i in range(span + 1)]
def _day_boundary(day_parts: list[DayPart]) -> dt.time:
"""Début du premier créneau type du jour (le plus petit `order`) — même
frontière que `validation.engine._day_boundary`. Un créneau type dont
l'heure de début tombe avant cette frontière est la suite de la nuit du
jour précédent (ex. `Nuit3` 00 h–01 h après `Soirée` 19 h–00 h) : il doit
être généré sur le jour *suivant*, pas sur `day` lui-même."""
if not day_parts:
return dt.time(0, 0)
return min(day_parts, key=lambda part: part.order).start_time
def _window(day: dt.date, part: DayPart, boundary: dt.time) -> tuple[dt.datetime, dt.datetime]: def _window(day: dt.date, part: DayPart, boundary: dt.time) -> tuple[dt.datetime, dt.datetime]:
actual_day = day + dt.timedelta(days=1) if part.start_time < boundary else day actual_day = day + dt.timedelta(days=1) if part.start_time < boundary else day
start = dt.datetime.combine(actual_day, part.start_time) start = dt.datetime.combine(actual_day, part.start_time)
@@ -40,7 +30,7 @@ def generate_missing_shifts(
day_parts = list( day_parts = list(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id)) session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
) )
boundary = _day_boundary(day_parts) boundary = day_boundary(day_parts)
existing = { existing = {
(s.day_part_id, s.starts_at.date()) (s.day_part_id, s.starts_at.date())
for s in session.exec( for s in session.exec(
+18
View File
@@ -109,6 +109,24 @@ def update_account(
return account return account
@router.post("/{account_id}/reset-login-link", response_model=AccountRead)
def reset_login_link(
account_id: uuid.UUID,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Account:
"""Révoque le lien de connexion permanent du compte (`app/auth/
permanent_link.py`) : tous les liens déjà envoyés cessent de
fonctionner ; le prochain récapitulatif portera un nouveau lien. Ex.
email transféré, téléphone perdu."""
account = owned_or_404(session.get(Account, account_id), edition.id)
account.login_link_version += 1
session.commit()
session.refresh(account)
return account
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_account( def delete_account(
account_id: uuid.UUID, account_id: uuid.UUID,
+26 -1
View File
@@ -17,6 +17,7 @@ from app.api.schemas import (
AssignmentValidateRequest, AssignmentValidateRequest,
AssignmentValidateResult, AssignmentValidateResult,
AssignmentWriteResult, AssignmentWriteResult,
AttendanceUpdate,
) )
from app.auth.deps import ( from app.auth.deps import (
current_account_in_edition, current_account_in_edition,
@@ -24,7 +25,7 @@ from app.auth.deps import (
require_staff, require_staff,
) )
from app.db import get_session from app.db import get_session
from app.models import Account, Assignment, Edition, Role, Shift, Volunteer from app.models import Account, Assignment, Edition, Role, Shift, Volunteer, local_now
from app.validation import has_blocking, validate from app.validation import has_blocking, validate
from app.validation.loader import build_validation_input from app.validation.loader import build_validation_input
@@ -206,6 +207,30 @@ def update_assignment(
return AssignmentWriteResult(assignment=assignment, warnings=warnings) return AssignmentWriteResult(assignment=assignment, warnings=warnings)
@router.put("/{assignment_id}/attendance", response_model=AssignmentRead)
def set_attendance(
assignment_id: uuid.UUID,
payload: AttendanceUpdate,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> Assignment:
"""Pointe un bénévole présent ou absent sur son créneau (`None` annule) —
le référent du poste ou la coordination. Enregistré en base : le
pointage survit à un rechargement et se retrouve sur tout appareil."""
assignment = owned_or_404(
session.get(Assignment, assignment_id), edition.id
)
ensure_can_write_shift(
account, session, session.get(Shift, assignment.shift_id)
)
assignment.attendance = payload.attendance
assignment.attendance_marked_at = local_now() if payload.attendance else None
session.commit()
session.refresh(assignment)
return assignment
@router.delete("/{assignment_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{assignment_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_assignment( def delete_assignment(
assignment_id: uuid.UUID, assignment_id: uuid.UUID,
+47
View File
@@ -25,6 +25,8 @@ from app.api.schemas import (
from app.auth.deps import get_current_account from app.auth.deps import get_current_account
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
from app.auth.passwords import MIN_LENGTH, hash_password, verify_password from app.auth.passwords import MIN_LENGTH, hash_password, verify_password
from app.auth.permanent_link import PREFIX as PERMANENT_PREFIX
from app.auth.permanent_link import account_from_permanent_token
from app.auth.rate_limit import login_limiter, magic_link_limiter from app.auth.rate_limit import login_limiter, magic_link_limiter
from app.auth.tokens import create_access_token, hash_token from app.auth.tokens import create_access_token, hash_token
from app.config import get_settings from app.config import get_settings
@@ -84,7 +86,17 @@ def login(
or account.password_hash is None or account.password_hash is None
or not verify_password(payload.password, account.password_hash) or not verify_password(payload.password, account.password_hash)
): ):
# Cause réelle dans les logs seulement — la réponse reste la même
# (pas de divulgation). Jamais l'email saisi.
if account is None:
reason = "aucun compte pour cet email"
elif account.password_hash is None:
reason = f"compte {account.id} sans mot de passe (lien magique)"
else:
reason = f"mot de passe incorrect pour le compte {account.id}"
logger.info("Connexion refusée (édition %s) : %s", edition_id, reason)
raise _invalid_credentials() raise _invalid_credentials()
logger.info("Connexion par mot de passe : compte %s (édition %s)", account.id, edition_id)
return _issue(account) return _issue(account)
@@ -135,7 +147,19 @@ def request_magic_link(
tâche de fond (une erreur SMTP est journalisée, pas remontée).""" tâche de fond (une erreur SMTP est journalisée, pas remontée)."""
magic_link_limiter.hit(f"{edition_id}:{payload.email.strip().lower()}") magic_link_limiter.hit(f"{edition_id}:{payload.email.strip().lower()}")
account = _account_by_email(session, edition_id, payload.email) account = _account_by_email(session, edition_id, payload.email)
if account is None:
logger.info(
"Lien magique demandé sans compte correspondant (édition %s) — rien envoyé "
"(un email sur une fiche bénévole ne crée pas d'accès)",
edition_id,
)
elif account.password_hash is not None:
logger.info(
"Lien magique demandé pour le compte %s, qui a un mot de passe — rien envoyé",
account.id,
)
if account is not None and account.password_hash is None: if account is not None and account.password_hash is None:
logger.info("Lien magique émis pour le compte %s (édition %s)", account.id, edition_id)
settings = get_settings() settings = get_settings()
raw = issue_magic_link_token(session, account) raw = issue_magic_link_token(session, account)
session.commit() session.commit()
@@ -162,6 +186,16 @@ def verify_magic_link(
payload: VerifyRequest, payload: VerifyRequest,
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> TokenResponse: ) -> TokenResponse:
raw = payload.token.strip()
if raw.startswith(PERMANENT_PREFIX):
# Lien permanent du récapitulatif de planning : réutilisable, sans
# expiration, révocable (voir `app/auth/permanent_link.py`).
account = account_from_permanent_token(session, raw)
if account is None or account.edition_id != edition_id:
logger.info("Lien permanent refusé (édition %s)", edition_id)
raise _invalid_token()
logger.info("Connexion par lien permanent : compte %s (édition %s)", account.id, edition_id)
return _issue(account)
token = session.exec( token = session.exec(
select(MagicLinkToken).where( select(MagicLinkToken).where(
MagicLinkToken.token_hash == hash_token(payload.token.strip()) MagicLinkToken.token_hash == hash_token(payload.token.strip())
@@ -172,13 +206,26 @@ def verify_magic_link(
or token.used_at is not None or token.used_at is not None
or token.expires_at < local_now() or token.expires_at < local_now()
): ):
if token is None:
reason = "jeton inconnu"
elif token.used_at is not None:
reason = f"jeton déjà utilisé (compte {token.account_id})"
else:
reason = f"jeton expiré (compte {token.account_id})"
logger.info("Lien magique refusé (édition %s) : %s", edition_id, reason)
raise _invalid_token() raise _invalid_token()
account = session.get(Account, token.account_id) account = session.get(Account, token.account_id)
if account is None or account.edition_id != edition_id: if account is None or account.edition_id != edition_id:
logger.info(
"Lien magique refusé (édition %s) : compte %s absent ou d'une autre édition",
edition_id,
token.account_id,
)
raise _invalid_token() raise _invalid_token()
token.used_at = local_now() token.used_at = local_now()
session.commit() session.commit()
session.refresh(account) session.refresh(account)
logger.info("Connexion par lien magique : compte %s (édition %s)", account.id, edition_id)
return _issue(account) return _issue(account)
-132
View File
@@ -1,132 +0,0 @@
"""Disponibilités déclarées par les bénévoles.
Une disponibilité = « le <date>, je peux de <start_time> à <end_time> ».
Fenêtre horaire libre (pas liée aux créneaux types) ; plusieurs par jour
possibles. `end_time <= start_time` ⇒ la fenêtre se termine le lendemain.
Ces fenêtres sont prises en compte par le moteur de validation (règle
`OUTSIDE_AVAILABILITY`) au moment d'affecter un bénévole à un créneau.
"""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import delete
from sqlmodel import Session, select
from app.api._common import owned_or_404
from app.api.deps import get_edition
from app.api.schemas import AvailabilityRead, AvailabilitySet
from app.auth.deps import (
current_account_in_edition,
require_coordinator,
require_staff,
)
from app.db import get_session
from app.models import Account, Availability, Edition, Role, Volunteer
router = APIRouter(prefix="/editions/{edition_id}", tags=["availability"])
def _ensure_can_read(account: Account, volunteer_id: uuid.UUID) -> None:
if account.role in (Role.COORDINATOR, Role.LEAD):
return
if account.volunteer_id == volunteer_id:
return
raise HTTPException(status_code=403, detail={"code": "error.forbidden"})
def _windows(session: Session, volunteer_id: uuid.UUID) -> list[Availability]:
return list(
session.exec(
select(Availability)
.where(Availability.volunteer_id == volunteer_id)
.order_by(Availability.date, Availability.start_time)
)
)
@router.get(
"/volunteers/{volunteer_id}/availability",
response_model=list[AvailabilityRead],
)
def list_availability(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> list[Availability]:
_ensure_can_read(account, volunteer_id)
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
return _windows(session, volunteer_id)
@router.put(
"/volunteers/{volunteer_id}/availability",
response_model=list[AvailabilityRead],
)
def replace_availability(
volunteer_id: uuid.UUID,
payload: AvailabilitySet,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> list[Availability]:
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
for w in payload.windows:
if not edition.start_date <= w.date <= edition.end_date:
raise HTTPException(
status_code=422,
detail={
"code": "availability.date_outside_edition",
"params": {"date": w.date.isoformat()},
},
)
if w.start_time == w.end_time:
raise HTTPException(
status_code=422,
detail={"code": "availability.empty_window"},
)
session.execute(
delete(Availability).where(Availability.volunteer_id == volunteer_id)
)
# Dé-doublonne (date, start, end) : la contrainte d'unicité l'imposerait.
seen: set[tuple] = set()
for w in payload.windows:
key = (w.date, w.start_time, w.end_time)
if key in seen:
continue
seen.add(key)
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer_id,
date=w.date,
start_time=w.start_time,
end_time=w.end_time,
)
)
session.commit()
return _windows(session, volunteer_id)
@router.get("/availability", response_model=list[AvailabilityRead])
def list_edition_availability(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> list[Availability]:
"""Toutes les disponibilités de l'édition (pour la grille des besoins)."""
return list(
session.exec(
select(Availability)
.where(Availability.edition_id == edition.id)
.order_by(
Availability.volunteer_id,
Availability.date,
Availability.start_time,
)
)
)
+101 -43
View File
@@ -1,20 +1,22 @@
"""Planches de badges bénévoles : configuration du recto/verso (COORDINATION) """Planches de badges bénévoles : configuration du recto/verso (COORDINATION)
et génération du PDF. et génération du PDF.
Le verso est une image fixe, la même pour tous les bénévoles. Le recto Chaque face (recto, verso) combine une image de fond (la même pour tous les
combine une image de fond et des champs positionnés (nom, poste(s) affectés, bénévoles) et des champs positionnés (nom, poste(s) affectés, catégorie,
catégorie, rôle) — voir `app/badges/pdf.py` pour la composition du PDF et rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py` pour la
`app/models/domain.py` (`BadgeTemplate`, `BadgeField`) pour le modèle. composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
`BadgeField`) pour le modèle.
""" """
import datetime as dt
import io import io
import re
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete from sqlalchemy import delete
from sqlalchemy.orm import aliased
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api.deps import get_edition from app.api.deps import get_edition
@@ -31,6 +33,8 @@ from app.badges.pdf import (
DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_WIDTH_MM,
BadgeFieldSpec, BadgeFieldSpec,
BadgeSpec, BadgeSpec,
BadgeTable,
BadgeTableSection,
generate_badge_sheets_pdf, generate_badge_sheets_pdf,
image_size_mm, image_size_mm,
) )
@@ -39,20 +43,26 @@ from app.i18n import t
from app.models import ( from app.models import (
Account, Account,
Assignment, Assignment,
BadgeFace,
BadgeField, BadgeField,
BadgeFieldType, BadgeFieldType,
BadgeTemplate, BadgeTemplate,
DayPart,
Edition, Edition,
Position, Position,
Role, Role,
Shift, Shift,
Volunteer, Volunteer,
) )
from app.validation.engine import day_boundary, festival_day
router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"]) router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"])
_MAX_IMAGE_BYTES = 8 * 1024 * 1024 _MAX_IMAGE_BYTES = 8 * 1024 * 1024
_ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg"} _ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg"}
_HEX_COLOR = re.compile(r"#[0-9a-fA-F]{6}")
# Champs centrés verticalement dans leur cadre (les autres partent du haut).
_MIDDLE_ALIGNED = {BadgeFieldType.FREE_TEXT, BadgeFieldType.ROLE}
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -75,7 +85,14 @@ def _fields(session: Session, edition_id: uuid.UUID) -> list[BadgeField]:
session.exec( session.exec(
select(BadgeField) select(BadgeField)
.where(BadgeField.edition_id == edition_id) .where(BadgeField.edition_id == edition_id)
.order_by(BadgeField.field_type) # Ordre de dessin (voir `BadgeField.z_order`) ; à égalité, la
# photo dessous, pour ne jamais masquer un texte.
.order_by(
BadgeField.face,
BadgeField.z_order,
(BadgeField.field_type == BadgeFieldType.PHOTO).desc(),
BadgeField.field_type,
)
) )
) )
@@ -201,9 +218,11 @@ def set_fields(
_guard: Account = Depends(require_coordinator), _guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> list[BadgeField]: ) -> list[BadgeField]:
seen: set[BadgeFieldType] = set() # Au plus un champ de chaque type par face ; le même sur les deux faces
# est permis.
seen: set[tuple[BadgeFace, BadgeFieldType]] = set()
for f in payload.fields: for f in payload.fields:
if f.field_type in seen: if (f.face, f.field_type) in seen:
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail={ detail={
@@ -211,7 +230,12 @@ def set_fields(
"params": {"field_type": f.field_type}, "params": {"field_type": f.field_type},
}, },
) )
seen.add(f.field_type) seen.add((f.face, f.field_type))
if f.bg_color is not None and not _HEX_COLOR.fullmatch(f.bg_color):
raise HTTPException(
status_code=422,
detail={"code": "badge.invalid_color", "params": {"color": f.bg_color}},
)
session.execute(delete(BadgeField).where(BadgeField.edition_id == edition.id)) session.execute(delete(BadgeField).where(BadgeField.edition_id == edition.id))
for f in payload.fields: for f in payload.fields:
@@ -245,7 +269,7 @@ def set_size(
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Génération du PDF # # Génération du PDF #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
AssignmentRow = tuple[Position, Shift, Volunteer | None] # (poste, créneau, référent) AssignmentRow = tuple[Position, Shift]
def _role_label(volunteer_id: uuid.UUID, lead_volunteer_ids: set[uuid.UUID]) -> str: def _role_label(volunteer_id: uuid.UUID, lead_volunteer_ids: set[uuid.UUID]) -> str:
@@ -258,16 +282,29 @@ def _category_label(category) -> str:
return t(f"position.category.{category.value.lower()}") return t(f"position.category.{category.value.lower()}")
def _position_line( def _day_label(day: dt.date) -> str:
position: Position, shift: Shift, referent: Volunteer | None, volunteer_id: uuid.UUID """En-tête d'un jour dans le tableau des créneaux : « Samedi 11 »."""
) -> str: return t("badge.table.day_label", weekday=t(f"badge.weekday.{day.weekday()}"), day=day.day)
"""Une ligne par créneau affecté : poste + horaire, et le référent du
poste s'il y en a un — sauf si le bénévole badgé est lui-même ce
référent (pas besoin de se l'indiquer à soi-même).""" def _positions_table(assignments: list[AssignmentRow], boundary: dt.time) -> BadgeTable | None:
line = f"{position.name} {shift.starts_at:%H:%M}–{shift.ends_at:%H:%M}" """Créneaux affectés regroupés par jour de festival (une soirée qui
if referent is not None and referent.id != volunteer_id: passe minuit reste sur le jour où elle a commencé — `festival_day`) :
line += f" ({t('badge.role.referent')} : {referent.first_name} {referent.last_name})" le jour en en-tête, puis une ligne horaire / poste par créneau, dans
return line l'ordre chronologique. `None` sans aucun créneau (rien à dessiner)."""
by_day: dict[dt.date, list[tuple[str, str]]] = defaultdict(list)
for position, shift in assignments:
by_day[festival_day(shift.starts_at, boundary)].append(
(f"{shift.starts_at:%H:%M}–{shift.ends_at:%H:%M}", position.name)
)
if not by_day:
return None
return BadgeTable(
sections=tuple(
BadgeTableSection(title=_day_label(day), rows=tuple(rows))
for day, rows in sorted(by_day.items())
)
)
def _field_text( def _field_text(
@@ -276,17 +313,22 @@ def _field_text(
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]], assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]],
lead_volunteer_ids: set[uuid.UUID], lead_volunteer_ids: set[uuid.UUID],
) -> str: ) -> str:
"""Texte d'un champ — `POSITIONS` n'en a pas : c'est un tableau
(`_positions_table`)."""
if field.field_type == BadgeFieldType.FULL_NAME: if field.field_type == BadgeFieldType.FULL_NAME:
return f"{volunteer.first_name} {volunteer.last_name}" # Prénom puis nom, chacun sur sa ligne.
return f"{volunteer.first_name}\n{volunteer.last_name}"
if field.field_type == BadgeFieldType.PHOTO:
# Une image, pas un texte (rien d'imprimé sans photo).
return ""
if field.field_type == BadgeFieldType.FREE_TEXT:
# Texte commun saisi dans l'éditeur, remplacé pour un bénévole par
# le « Texte du badge » de sa fiche s'il en a un.
return volunteer.badge_text or field.text or ""
assignments = assignments_by_volunteer.get(volunteer.id, []) assignments = assignments_by_volunteer.get(volunteer.id, [])
if field.field_type == BadgeFieldType.POSITIONS:
return "\n".join(
_position_line(position, shift, referent, volunteer.id)
for position, shift, referent in assignments
)
if field.field_type == BadgeFieldType.CATEGORY: if field.field_type == BadgeFieldType.CATEGORY:
categories = sorted( categories = sorted(
{_category_label(position.category) for position, _shift, _r in assignments} {_category_label(position.category) for position, _shift in assignments}
) )
return ", ".join(categories) return ", ".join(categories)
if field.field_type == BadgeFieldType.ROLE: if field.field_type == BadgeFieldType.ROLE:
@@ -314,20 +356,20 @@ def generate_badges(
raise HTTPException(status_code=422, detail={"code": "badge.no_volunteers"}) raise HTTPException(status_code=422, detail={"code": "badge.no_volunteers"})
volunteer_ids = [v.id for v in volunteers] volunteer_ids = [v.id for v in volunteers]
Referent = aliased(Volunteer)
rows = session.exec( rows = session.exec(
select(Assignment.volunteer_id, Position, Shift, Referent) select(Assignment.volunteer_id, Position, Shift)
.join(Shift, Shift.id == Assignment.shift_id) .join(Shift, Shift.id == Assignment.shift_id)
.join(Position, Position.id == Shift.position_id) .join(Position, Position.id == Shift.position_id)
.join(Account, Account.id == Position.lead_account_id, isouter=True)
.join(Referent, Referent.id == Account.volunteer_id, isouter=True)
.where(Assignment.volunteer_id.in_(volunteer_ids)) .where(Assignment.volunteer_id.in_(volunteer_ids))
.order_by(Shift.starts_at) .order_by(Shift.starts_at)
).all() ).all()
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]] = defaultdict(list) assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]] = defaultdict(list)
for volunteer_id, position, shift, referent in rows: for volunteer_id, position, shift in rows:
assignments_by_volunteer[volunteer_id].append((position, shift, referent)) assignments_by_volunteer[volunteer_id].append((position, shift))
boundary = day_boundary(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
lead_volunteer_ids = { lead_volunteer_ids = {
account.volunteer_id account.volunteer_id
for account in session.exec( for account in session.exec(
@@ -340,13 +382,9 @@ def generate_badges(
if account.volunteer_id is not None if account.volunteer_id is not None
} }
badges = [ def spec(field: BadgeField, volunteer: Volunteer) -> BadgeFieldSpec:
BadgeSpec( return BadgeFieldSpec(
fields=tuple( text=_field_text(field, volunteer, assignments_by_volunteer, lead_volunteer_ids),
BadgeFieldSpec(
text=_field_text(
field, volunteer, assignments_by_volunteer, lead_volunteer_ids
),
x_mm=field.x_mm, x_mm=field.x_mm,
y_mm=field.y_mm, y_mm=field.y_mm,
width_mm=field.width_mm, width_mm=field.width_mm,
@@ -355,11 +393,31 @@ def generate_badges(
align=field.align.value, align=field.align.value,
bold=field.bold, bold=field.bold,
color=field.color, color=field.color,
is_photo=field.field_type == BadgeFieldType.PHOTO,
image=volunteer.photo if field.field_type == BadgeFieldType.PHOTO else None,
table=_positions_table(assignments_by_volunteer.get(volunteer.id, []), boundary)
if field.field_type == BadgeFieldType.POSITIONS
else None,
bg_color=field.bg_color,
auto_fit=field.auto_fit,
valign="MIDDLE" if field.field_type in _MIDDLE_ALIGNED else "TOP",
photo_shape=field.photo_shape.value,
) )
for field in fields
) front_fields = [f for f in fields if f.face == BadgeFace.FRONT]
) back_fields = [f for f in fields if f.face == BadgeFace.BACK]
# Un badge par exemplaire demandé sur la fiche (`Volunteer.badge_count`),
# à la suite les uns des autres.
badges = [
badge
for volunteer in volunteers for volunteer in volunteers
for badge in [
BadgeSpec(
fields=tuple(spec(field, volunteer) for field in front_fields),
back_fields=tuple(spec(field, volunteer) for field in back_fields),
)
]
* volunteer.badge_count
] ]
pdf_bytes = generate_badge_sheets_pdf( pdf_bytes = generate_badge_sheets_pdf(
+5 -1
View File
@@ -3,6 +3,7 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session from sqlmodel import Session
from app.api._common import set_message
from app.api.deps import get_edition from app.api.deps import get_edition
from app.api.schemas import EditionRead, EditionUpdate from app.api.schemas import EditionRead, EditionUpdate
from app.auth.deps import current_account_in_edition, require_coordinator from app.auth.deps import current_account_in_edition, require_coordinator
@@ -27,7 +28,10 @@ def update_edition(
_guard: Account = Depends(require_coordinator), _guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> Edition: ) -> Edition:
for field, value in payload.model_dump(exclude_unset=True).items(): changes = payload.model_dump(exclude_unset=True)
if "volunteer_message" in changes:
set_message(edition, "volunteer_message", changes.pop("volunteer_message"))
for field, value in changes.items():
setattr(edition, field, value) setattr(edition, field, value)
if edition.end_date < edition.start_date: if edition.end_date < edition.start_date:
raise HTTPException( raise HTTPException(
+119 -43
View File
@@ -25,7 +25,6 @@ coordination, faute de savoir si la personne visée est déjà inscrite (voir
CLAUDE.md § 2, `Volunteer`).""" CLAUDE.md § 2, `Volunteer`)."""
import datetime as dt import datetime as dt
import io
import json import json
import logging import logging
import secrets import secrets
@@ -43,13 +42,15 @@ from fastapi import (
UploadFile, UploadFile,
status, status,
) )
from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete from sqlalchemy import delete
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api._common import ( from app.api._common import (
ALLOWED_PHOTO_TYPES,
MAX_PHOTO_BYTES,
owned_or_404, owned_or_404,
read_photo,
resolve_owned_ids, resolve_owned_ids,
set_artists, set_artists,
set_wished_positions, set_wished_positions,
@@ -109,8 +110,6 @@ logger = logging.getLogger("app.forms")
router = APIRouter(prefix="/editions/{edition_id}/form", tags=["form"]) router = APIRouter(prefix="/editions/{edition_id}/form", tags=["form"])
_MAX_PHOTO_BYTES = 8 * 1024 * 1024
_ALLOWED_PHOTO_TYPES = {"image/png", "image/jpeg"}
# Vœux de poste : au plus deux, pour éviter que tout le monde coche tous les # Vœux de poste : au plus deux, pour éviter que tout le monde coche tous les
# postes « au cas où » — force une vraie priorisation dès le formulaire. # postes « au cas où » — force une vraie priorisation dès le formulaire.
_MAX_POSITION_WISHES = 2 _MAX_POSITION_WISHES = 2
@@ -340,14 +339,14 @@ def delete_payment_option(
async def _read_logo(file: UploadFile) -> tuple[bytes, str]: async def _read_logo(file: UploadFile) -> tuple[bytes, str]:
content_type = file.content_type or "" content_type = file.content_type or ""
if content_type not in _ALLOWED_PHOTO_TYPES: if content_type not in ALLOWED_PHOTO_TYPES:
raise HTTPException( raise HTTPException(
status_code=422, detail={"code": "form.logo_type_not_supported"} status_code=422, detail={"code": "form.logo_type_not_supported"}
) )
content = await file.read() content = await file.read()
if not content: if not content:
raise HTTPException(status_code=400, detail={"code": "form.logo_empty"}) raise HTTPException(status_code=400, detail={"code": "form.logo_empty"})
if len(content) > _MAX_PHOTO_BYTES: if len(content) > MAX_PHOTO_BYTES:
raise HTTPException(status_code=413, detail={"code": "form.logo_too_large"}) raise HTTPException(status_code=413, detail={"code": "form.logo_too_large"})
return content, content_type return content, content_type
@@ -462,39 +461,6 @@ def _prune_stale_pending(session: Session, edition_id: uuid.UUID) -> None:
session.commit() session.commit()
async def _read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None:
"""Validation partagée par l'inscription immédiate et l'inscription en
attente de paiement — `None` si aucun fichier fourni. `Content-Type` est
un en-tête fourni par l'appelant, jamais vérifié par lui-même (ce
endpoint est public, sans authentification) : un fichier qui ne se
décode pas comme une vraie image malgré un `Content-Type` correct
(`image/png`/`image/jpeg` revendiqué) est rejeté ici plutôt que de
planter plus tard la génération des badges/feuilles de poste
(`app/badges/pdf.py`/`app/sheets/pdf.py`, qui lisent ces octets sans
filet) — même validation que l'import d'image des badges
(`app/api/badges.py:_pixel_size`, réservé à la coordination)."""
if photo is None or not photo.filename:
return None
content_type = photo.content_type or ""
if content_type not in _ALLOWED_PHOTO_TYPES:
raise HTTPException(
status_code=422, detail={"code": "form.photo_type_not_supported"}
)
content = await photo.read()
if len(content) > _MAX_PHOTO_BYTES:
raise HTTPException(status_code=413, detail={"code": "form.photo_too_large"})
if not content:
return None
try:
with Image.open(io.BytesIO(content)) as img:
img.verify()
except (UnidentifiedImageError, OSError) as exc:
raise HTTPException(
status_code=422, detail={"code": "form.photo_type_not_supported"}
) from exc
return content, content_type
def _filter_owned_ids( def _filter_owned_ids(
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID] session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID]
) -> list[uuid.UUID]: ) -> list[uuid.UUID]:
@@ -688,7 +654,7 @@ async def submit_public_form(
else payment_option.amount if payment_option is not None else None else payment_option.amount if payment_option is not None else None
) )
photo_data = await _read_photo(photo) if FormFieldType.PHOTO in fields else None photo_data = await read_photo(photo) if FormFieldType.PHOTO in fields else None
if payment_option is None: if payment_option is None:
# Pas de paiement en ligne (case désactivée, ou « je paierai sur # Pas de paiement en ligne (case désactivée, ou « je paierai sur
@@ -718,6 +684,11 @@ async def submit_public_form(
set_artists(session, volunteer, artist_ids) set_artists(session, volunteer, artist_ids)
session.commit() session.commit()
session.refresh(volunteer) session.refresh(volunteer)
logger.info(
"Formulaire public : bénévole %s créé (édition %s, sans paiement en ligne)",
volunteer.id,
edition.id,
)
if volunteer.email: if volunteer.email:
subject, body = render_registration_confirmation(first_name=volunteer.first_name) subject, body = render_registration_confirmation(first_name=volunteer.first_name)
background.add_task( background.add_task(
@@ -758,6 +729,14 @@ async def submit_public_form(
session.add(pending) session.add(pending)
session.commit() session.commit()
session.refresh(pending) session.refresh(pending)
logger.info(
"Formulaire public : inscription en attente de paiement %s (édition %s, "
"montant %.2f, mécénat=%s)",
pending.id,
edition.id,
pending.payment_amount,
pending.requires_mecenat_info,
)
if pending.requires_mecenat_info: if pending.requires_mecenat_info:
return FormSubmissionResult(pending_token=pending.id, requires_mecenat_info=True) return FormSubmissionResult(pending_token=pending.id, requires_mecenat_info=True)
@@ -783,19 +762,53 @@ def _create_pending_checkout(
amount=pending.payment_amount, amount=pending.payment_amount,
currency="EUR", currency="EUR",
checkout_reference=f"{pending.id}-{uuid.uuid4().hex[:8]}", checkout_reference=f"{pending.id}-{uuid.uuid4().hex[:8]}",
description=f"{pending.payment_label} — édition {edition.year}", description=_checkout_description(edition, pending),
return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook", return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook",
redirect_url=f"{base}/inscription/{template.access_token}?paiement={pending.id}", redirect_url=f"{base}/inscription/{template.access_token}?paiement={pending.id}",
) )
if checkout is None: if checkout is None:
logger.warning(
"Checkout SumUp non créé pour l'inscription en attente %s (édition %s) — "
"le bénévole devra réessayer",
pending.id,
edition.id,
)
return None return None
pending.checkout_id = checkout.id pending.checkout_id = checkout.id
pending.checkout_status = FeePaymentStatus.PENDING pending.checkout_status = FeePaymentStatus.PENDING
session.add(pending) session.add(pending)
session.commit() session.commit()
logger.info(
"Checkout SumUp %s créé pour l'inscription en attente %s (édition %s)",
checkout.id,
pending.id,
edition.id,
)
if not checkout.hosted_checkout_url:
logger.warning(
"Checkout SumUp %s sans hosted_checkout_url — le paiement hébergé "
"est-il activé sur le compte marchand ?",
checkout.id,
)
return checkout.hosted_checkout_url return checkout.hosted_checkout_url
# Longueur prudente : la doc SumUp ne fixe pas de maximum pour `description`.
_CHECKOUT_DESCRIPTION_MAX = 255
def _checkout_description(edition: Edition, pending: PendingFormSubmission) -> str:
"""Libellé de la transaction côté SumUp (tableau de bord, exports) : le
nom du bénévole en tête, pour qu'un paiement s'y rapproche d'un coup
d'œil de la bonne personne — l'API de création de checkout n'a pas de
champ de métadonnées libre (voir `app/sumup/client.py:create_checkout`).
Donnée transmise au prestataire de paiement, pas un log : la règle RGPD
de `app/logging_config.py` ne s'applique pas ici."""
name = " ".join(p for p in (pending.first_name, pending.last_name) if p)
parts = [name, pending.payment_label, f"édition {edition.year}"]
return " — ".join(p for p in parts if p)[:_CHECKOUT_DESCRIPTION_MAX]
def _promote_pending( def _promote_pending(
session: Session, session: Session,
edition: Edition, edition: Edition,
@@ -824,6 +837,8 @@ def _promote_pending(
fee_checkout_id=checkout.id, fee_checkout_id=checkout.id,
fee_payment_status=FeePaymentStatus.PAID, fee_payment_status=FeePaymentStatus.PAID,
fee_paid_at=local_now(), fee_paid_at=local_now(),
fee_transaction_code=checkout.transaction_code,
fee_transaction_id=checkout.transaction_id,
mecenat_address=pending.mecenat_address, mecenat_address=pending.mecenat_address,
mecenat_city=pending.mecenat_city, mecenat_city=pending.mecenat_city,
mecenat_postal_code=pending.mecenat_postal_code, mecenat_postal_code=pending.mecenat_postal_code,
@@ -866,6 +881,22 @@ def _promote_pending(
pending.mecenat_country = None pending.mecenat_country = None
session.add(pending) session.add(pending)
session.commit() session.commit()
logger.info(
"Paiement confirmé : inscription en attente %s → bénévole %s (édition %s, "
"checkout %s, transaction %s, reçu mécénat %s)",
pending.id,
volunteer.id,
edition.id,
checkout.id,
checkout.transaction_code or "inconnue",
volunteer.mecenat_receipt_number or "-",
)
if not checkout.transaction_code:
logger.warning(
"Checkout SumUp %s payé sans code de transaction renvoyé — rapprochement "
"à faire depuis le tableau de bord SumUp",
checkout.id,
)
if volunteer.email: if volunteer.email:
# Paiement « mécénat » (adresse postale saisie) : le cerfa rempli part # Paiement « mécénat » (adresse postale saisie) : le cerfa rempli part
@@ -906,7 +937,10 @@ def _send_paid_confirmation(
pdf = generate_mecenat_receipts_pdf([receipt]) pdf = generate_mecenat_receipts_pdf([receipt])
attachments.append((pdf, "application/pdf", RECEIPT_FILENAME)) attachments.append((pdf, "application/pdf", RECEIPT_FILENAME))
except Exception: except Exception:
logger.exception("Reçu fiscal mécénat non généré — email envoyé sans pièce jointe") logger.exception(
"Reçu fiscal mécénat n° %s non généré — email envoyé sans pièce jointe",
receipt.number or "?",
)
send(to, subject, body, logo=logo, attachments=attachments) send(to, subject, body, logo=logo, attachments=attachments)
@@ -925,10 +959,21 @@ def _apply_pending_checkout_result(
try: try:
new_status = FeePaymentStatus(checkout.status) new_status = FeePaymentStatus(checkout.status)
except ValueError: except ValueError:
logger.warning(
"Checkout SumUp %s : statut inconnu %r ignoré", checkout.id, checkout.status
)
return return
if new_status == FeePaymentStatus.PAID: if new_status == FeePaymentStatus.PAID:
_promote_pending(session, edition, pending, checkout, background) _promote_pending(session, edition, pending, checkout, background)
else: else:
if new_status != pending.checkout_status:
logger.info(
"Checkout SumUp %s (inscription en attente %s) : %s → %s",
checkout.id,
pending.id,
pending.checkout_status,
new_status,
)
pending.checkout_status = new_status pending.checkout_status = new_status
session.add(pending) session.add(pending)
session.commit() session.commit()
@@ -1043,6 +1088,12 @@ def retry_pending_payment(
) )
template = _get_or_create_template(session, edition.id) template = _get_or_create_template(session, edition.id)
logger.info(
"Relance du paiement de l'inscription en attente %s (édition %s, statut %s)",
pending.id,
edition.id,
pending.checkout_status,
)
checkout_url = _create_pending_checkout(session, edition, template, pending) checkout_url = _create_pending_checkout(session, edition, template, pending)
return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url) return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url)
@@ -1059,13 +1110,23 @@ def sumup_webhook(
identifiant : jamais fait confiance sans relire l'état réel via l'API identifiant : jamais fait confiance sans relire l'état réel via l'API
(voir `app/sumup/client.py:get_checkout`). Répond toujours `200`, y (voir `app/sumup/client.py:get_checkout`). Répond toujours `200`, y
compris sur un événement inconnu — SumUp réessaie sinon inutilement.""" compris sur un événement inconnu — SumUp réessaie sinon inutilement."""
logger.info(
"Webhook SumUp reçu (édition %s, événement %s, checkout %s)",
edition.id,
payload.event_type,
payload.id,
)
template = _get_or_create_template(session, edition.id) template = _get_or_create_template(session, edition.id)
if not template.sumup_api_key: if not template.sumup_api_key:
logger.warning(
"Webhook SumUp ignoré : aucune clé API SumUp configurée (édition %s)",
edition.id,
)
return Response(status_code=status.HTTP_200_OK) return Response(status_code=status.HTTP_200_OK)
checkout = get_checkout(api_key=template.sumup_api_key, checkout_id=payload.id) checkout = get_checkout(api_key=template.sumup_api_key, checkout_id=payload.id)
if checkout is None: if checkout is None:
logger.warning("Webhook SumUp : relecture du checkout impossible.") logger.warning("Webhook SumUp : relecture du checkout %s impossible.", payload.id)
return Response(status_code=status.HTTP_200_OK) return Response(status_code=status.HTTP_200_OK)
pending = session.exec( pending = session.exec(
@@ -1094,6 +1155,21 @@ def sumup_webhook(
volunteer.fee_payment_status = new_status volunteer.fee_payment_status = new_status
if new_status == FeePaymentStatus.PAID and volunteer.fee_paid_at is None: if new_status == FeePaymentStatus.PAID and volunteer.fee_paid_at is None:
volunteer.fee_paid_at = local_now() volunteer.fee_paid_at = local_now()
if checkout.transaction_code and not volunteer.fee_transaction_code:
volunteer.fee_transaction_code = checkout.transaction_code
volunteer.fee_transaction_id = checkout.transaction_id
session.add(volunteer) session.add(volunteer)
session.commit() session.commit()
logger.info(
"Webhook SumUp : bénévole %s (checkout %s) → %s",
volunteer.id,
payload.id,
new_status,
)
return Response(status_code=status.HTTP_200_OK)
logger.warning(
"Webhook SumUp : checkout %s inconnu dans l'édition %s (inscription purgée ?)",
payload.id,
edition.id,
)
return Response(status_code=status.HTTP_200_OK) return Response(status_code=status.HTTP_200_OK)
+15 -1
View File
@@ -1,5 +1,7 @@
"""Endpoints d'import : analyse puis exécution.""" """Endpoints d'import : analyse puis exécution."""
import logging
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from pydantic import ValidationError from pydantic import ValidationError
from sqlmodel import Session from sqlmodel import Session
@@ -17,6 +19,8 @@ from app.importing.schemas import (
from app.importing.service import run_import, suggest_mapping, validate_mapping from app.importing.service import run_import, suggest_mapping, validate_mapping
from app.models import Account, Edition from app.models import Account, Edition
logger = logging.getLogger("app.importing")
router = APIRouter(prefix="/editions/{edition_id}/import", tags=["import"]) router = APIRouter(prefix="/editions/{edition_id}/import", tags=["import"])
_PREVIEW_ROWS = 5 _PREVIEW_ROWS = 5
@@ -107,9 +111,19 @@ async def execute(
}, },
) )
return run_import( report = run_import(
session=session, session=session,
edition=edition, edition=edition,
table=table, table=table,
mapping=parsed_mapping, mapping=parsed_mapping,
) )
logger.info(
"Import %s (édition %s) : %d lignes — %d créés, %d mis à jour, %d ignorés",
"simulé" if report.dry_run else "exécuté",
edition.id,
report.total_rows,
report.created,
report.updated,
report.skipped,
)
return report
+275 -46
View File
@@ -1,22 +1,36 @@
"""Envoi d'emails d'information aux bénévoles (récapitulatif de planning).""" """Envoi d'emails d'information aux bénévoles (récapitulatif de planning)."""
import datetime as dt
import logging
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass
from email.message import EmailMessage
from fastapi import APIRouter, BackgroundTasks, Depends from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import func
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api.deps import get_edition from app.api.deps import get_edition
from app.api.schemas import ( from app.api.schemas import (
PlanningNotificationRequest, PlanningNotificationRequest,
PlanningNotificationResult, PlanningNotificationResult,
TestEmailRequest,
TestEmailResult,
) )
from app.auth.deps import require_coordinator from app.auth.deps import require_coordinator
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token from app.auth.magic_link import build_magic_link_url
from app.auth.permanent_link import permanent_login_token
from app.config import get_settings
from app.db import get_session from app.db import get_session
from app.email import edition_logo, render_planning, send_many from app.email import edition_logo, render_planning
from app.email.html import render_planning_table
from app.email.sender import build_message, deliver_test, send_messages
from app.i18n import t from app.i18n import t
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer from app.models import Account, Assignment, DayPart, Edition, Position, Role, Shift, Volunteer
from app.validation.engine import day_boundary, festival_day
logger = logging.getLogger("app.notifications")
router = APIRouter( router = APIRouter(
prefix="/editions/{edition_id}/notifications", tags=["notifications"] prefix="/editions/{edition_id}/notifications", tags=["notifications"]
@@ -26,16 +40,244 @@ _LOGIN_PLACEHOLDER = "{lien_connexion}"
def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str: def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str:
"""Lien magique personnel si le bénévole a un compte, sinon l'accueil.""" """Lien de connexion **permanent** du bénévole (`app/auth/
permanent_link.py` : connexion directe en un clic, réutilisable, sans
expiration, révocable). Envoyer ce lien vaut « donner un accès de
connexion » : un bénévole qui n'a pas encore de compte en reçoit un
(rôle `VOLUNTEER`, à son email). Si son email est déjà celui d'un autre
compte de l'édition (couple, famille partageant une adresse), pas de
compte possible : simple lien vers la page de connexion."""
account = session.exec( account = session.exec(
select(Account) select(Account)
.where(Account.edition_id == edition.id) .where(Account.edition_id == edition.id)
.where(Account.volunteer_id == volunteer.id) .where(Account.volunteer_id == volunteer.id)
.where(Account.password_hash.is_(None))
).first() ).first()
if account is None: if account is None and volunteer.email:
email = volunteer.email.strip()
taken = session.exec(
select(Account.id)
.where(Account.edition_id == edition.id)
.where(func.lower(Account.email) == email.lower())
).first()
if taken is None:
account = Account(
edition_id=edition.id, email=email, role=Role.VOLUNTEER, volunteer_id=volunteer.id
)
session.add(account)
session.flush()
logger.info(
"Compte créé pour le lien de connexion : bénévole %s (compte %s)",
volunteer.id,
account.id,
)
if (
account is None
or account.password_hash is not None
or account.role not in (Role.VOLUNTEER, Role.LEAD)
):
return _login_page_url()
return build_magic_link_url(permanent_login_token(account))
def _login_page_url() -> str:
"""Page de connexion, sans jeton."""
return build_magic_link_url("").split("?")[0] return build_magic_link_url("").split("?")[0]
return build_magic_link_url(issue_magic_link_token(session, account))
# Repère du tableau des créneaux dans la version HTML (caractère d'usage
# privé : jamais saisi dans un modèle), remplacé par le vrai tableau.
_SHIFTS_MARKER = "\ue000creneaux\ue000"
@dataclass(frozen=True)
class _PlanningContext:
"""Ce qui sert à composer le récapitulatif de chaque bénévole, chargé
une fois par envoi."""
positions: dict[uuid.UUID, Position]
# Nom + téléphone du référent de chaque poste qui en a un.
referents: dict[uuid.UUID, str]
shifts_by_volunteer: dict[uuid.UUID, list[Shift]]
boundary: dt.time
def _load_context(session: Session, edition: Edition) -> _PlanningContext:
positions = {
p.id: p for p in session.exec(select(Position).where(Position.edition_id == edition.id))
}
referents: dict[uuid.UUID, str] = {}
for position_id, referent in session.exec(
select(Position.id, Volunteer)
.join(Account, Account.id == Position.lead_account_id)
.join(Volunteer, Volunteer.id == Account.volunteer_id)
.where(Position.edition_id == edition.id)
):
name = f"{referent.first_name} {referent.last_name}".strip()
referents[position_id] = f"{name} · {referent.phone}" if referent.phone else name
shifts_by_volunteer: dict[uuid.UUID, list[Shift]] = defaultdict(list)
for volunteer_id, shift in session.exec(
select(Assignment.volunteer_id, Shift)
.join(Shift, Shift.id == Assignment.shift_id)
.where(Assignment.edition_id == edition.id)
):
shifts_by_volunteer[volunteer_id].append(shift)
boundary = day_boundary(session.exec(select(DayPart).where(DayPart.edition_id == edition.id)))
return _PlanningContext(positions, referents, shifts_by_volunteer, boundary)
def _day_label(day: dt.date) -> str:
"""« Samedi 11 » — même en-tête de jour que le tableau des badges."""
return t("badge.table.day_label", weekday=t(f"badge.weekday.{day.weekday()}"), day=day.day)
def _shift_sections(
shifts: list[Shift], ctx: _PlanningContext
) -> list[tuple[str, list[tuple[str, str, str]]]]:
"""Créneaux regroupés par journée de festival (une nuit reste sur le
jour où elle a commencé), comme le tableau des badges :
`[(jour, [(horaire, poste, référent)])]`."""
by_day: dict[dt.date, list[tuple[str, str, str]]] = defaultdict(list)
for s in sorted(shifts, key=lambda s: s.starts_at):
by_day[festival_day(s.starts_at, ctx.boundary)].append(
(
f"{s.starts_at:%H:%M}–{s.ends_at:%H:%M}",
ctx.positions[s.position_id].name,
ctx.referents.get(s.position_id) or t("email.planning.table.no_referent"),
)
)
return [(_day_label(day), rows) for day, rows in sorted(by_day.items())]
def _shifts_text(sections: list[tuple[str, list[tuple[str, str, str]]]]) -> str:
"""Version texte du tableau (clients mail sans HTML) : le jour, puis
une ligne par créneau."""
if not sections:
return t("email.planning.no_shifts")
return "\n\n".join(
day
+ "\n"
+ "\n".join(
t("email.planning.table.text_line", time=time, position=position, referent=referent)
for time, position, referent in rows
)
for day, rows in sections
)
@dataclass(frozen=True)
class _PlanningEmail:
subject: str
body: str
# Corps de la version HTML : `{creneaux}` y devient un vrai tableau.
html_body: str
html_blocks: dict[str, str]
def _planning_email(
*,
ctx: _PlanningContext,
subject_template: str,
body_template: str,
first_name: str,
last_name: str,
shifts: list[Shift],
login_link: str,
) -> _PlanningEmail:
"""Récapitulatif de planning d'un bénévole — partagé par l'envoi réel
et l'email de test. `{creneaux}` : tableau par jour (horaire, poste,
référent avec son téléphone), en HTML et en texte."""
minutes = sum((s.ends_at - s.starts_at).total_seconds() / 60 for s in shifts)
sections = _shift_sections(shifts, ctx)
def render(shifts_text: str) -> tuple[str, str]:
return render_planning(
subject_template=subject_template,
body_template=body_template,
first_name=first_name,
last_name=last_name,
shift_count=len(shifts),
hours=round(minutes / 60, 1),
shifts_text=shifts_text,
login_link=login_link,
)
subject, body = render(_shifts_text(sections))
if not sections:
return _PlanningEmail(subject, body, body, {})
_, html_body = render(_SHIFTS_MARKER)
table = render_planning_table(
sections,
(
t("email.planning.table.time"),
t("email.planning.table.position"),
t("email.planning.table.referent"),
),
)
return _PlanningEmail(subject, body, html_body, {_SHIFTS_MARKER: table})
@router.post("/test", response_model=TestEmailResult)
def send_test_email(
payload: TestEmailRequest,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> TestEmailResult:
"""Envoie tout de suite à l'adresse saisie le récapitulatif de planning
**tel que les bénévoles le recevront** : le modèle en cours d'édition
(`subject`/`body`), variables remplies avec les données d'un vrai
bénévole, même mise en forme et même logo — sujet préfixé « [Test] ».
Renvoie le résultat réel du serveur SMTP, pour vérifier à la fois le
contenu et la configuration d'envoi depuis l'écran « Messages ».
`{lien_connexion}` y est remplacé par l'adresse de la page de connexion,
**sans jeton** : un vrai lien magique ouvrirait le compte du bénévole à
quiconque lit la boîte de test."""
email = payload.email.strip()
if "@" not in email or email.startswith("@") or email.endswith("@") or " " in email:
raise HTTPException(status_code=422, detail={"code": "notifications.invalid_email"})
ctx = _load_context(session, edition)
shifts_by_volunteer = ctx.shifts_by_volunteer
volunteers = list(
session.exec(
select(Volunteer)
.where(Volunteer.edition_id == edition.id)
.order_by(Volunteer.last_name, Volunteer.first_name)
)
)
example = next((v for v in volunteers if v.id == payload.volunteer_id), None) or next(
(v for v in volunteers if shifts_by_volunteer.get(v.id)),
volunteers[0] if volunteers else None,
)
composed = _planning_email(
ctx=ctx,
subject_template=payload.subject or t("email.planning.default_subject"),
body_template=payload.body or t("email.planning.default_body"),
first_name=example.first_name if example else t("email.test.example_first_name"),
last_name=example.last_name if example else t("email.test.example_last_name"),
shifts=shifts_by_volunteer.get(example.id, []) if example else [],
login_link=_login_page_url(),
)
message = build_message(
email,
t("email.test.subject_prefix", subject=composed.subject),
composed.body,
logo=edition_logo(session, edition.id),
html_body=composed.html_body,
html_blocks=composed.html_blocks,
)
status, detail = deliver_test(message)
settings = get_settings()
return TestEmailResult(
status=status,
detail=detail,
smtp_host=f"{settings.smtp_host}:{settings.smtp_port}",
smtp_from=settings.smtp_from,
example_volunteer_id=example.id if example else None,
example_volunteer_name=(
f"{example.first_name} {example.last_name}".strip() if example else ""
),
)
@router.post("/planning", response_model=PlanningNotificationResult) @router.post("/planning", response_model=PlanningNotificationResult)
@@ -57,64 +299,51 @@ def send_planning_notification(
stmt = stmt.where(Volunteer.id.in_(payload.volunteer_ids)) stmt = stmt.where(Volunteer.id.in_(payload.volunteer_ids))
volunteers = list(session.exec(stmt.order_by(Volunteer.last_name))) volunteers = list(session.exec(stmt.order_by(Volunteer.last_name)))
positions = { ctx = _load_context(session, edition)
p.id: p logo = edition_logo(session, edition.id)
for p in session.exec(
select(Position).where(Position.edition_id == edition.id)
)
}
shifts_by_volunteer: dict[uuid.UUID, list[Shift]] = defaultdict(list)
for volunteer_id, shift in session.exec(
select(Assignment.volunteer_id, Shift)
.join(Shift, Shift.id == Assignment.shift_id)
.where(Assignment.edition_id == edition.id)
):
shifts_by_volunteer[volunteer_id].append(shift)
items: list[tuple[str, str, str]] = [] messages: list[EmailMessage] = []
recipients: list[uuid.UUID] = [] recipients: list[uuid.UUID] = []
skipped_no_email = 0 skipped_no_email = 0
for volunteer in volunteers: for volunteer in volunteers:
if not volunteer.email: if not volunteer.email:
skipped_no_email += 1 skipped_no_email += 1
continue continue
shifts = sorted( composed = _planning_email(
shifts_by_volunteer.get(volunteer.id, []), ctx=ctx,
key=lambda s: s.starts_at,
)
minutes = sum(
(s.ends_at - s.starts_at).total_seconds() / 60 for s in shifts
)
shifts_text = "\n".join(
t(
"email.planning.shift_line",
position=positions[s.position_id].name,
start=s.starts_at.strftime("%d/%m %H:%M"),
end=s.ends_at.strftime("%d/%m %H:%M"),
)
for s in shifts
) or t("email.planning.no_shifts")
subject, body = render_planning(
subject_template=subject_template, subject_template=subject_template,
body_template=body_template, body_template=body_template,
first_name=volunteer.first_name, first_name=volunteer.first_name,
last_name=volunteer.last_name, last_name=volunteer.last_name,
shift_count=len(shifts), shifts=ctx.shifts_by_volunteer.get(volunteer.id, []),
hours=round(minutes / 60, 1),
shifts_text=shifts_text,
login_link=( login_link=(
_login_link(session, edition, volunteer) _login_link(session, edition, volunteer)
if wants_login_link if wants_login_link
else "" else ""
), ),
) )
items.append((volunteer.email, subject, body)) messages.append(
build_message(
volunteer.email,
composed.subject,
composed.body,
logo=logo,
html_body=composed.html_body,
html_blocks=composed.html_blocks,
)
)
recipients.append(volunteer.id) recipients.append(volunteer.id)
session.commit() # persiste les éventuels MagicLinkToken émis session.commit() # persiste les comptes créés pour les liens de connexion
background.add_task(send_many, items, logo=edition_logo(session, edition.id)) logger.info(
"Récapitulatif de planning (édition %s) : %d emails en file, %d bénévoles sans email",
edition.id,
len(messages),
skipped_no_email,
)
background.add_task(send_messages, messages)
return PlanningNotificationResult( return PlanningNotificationResult(
queued=len(items), queued=len(messages),
skipped_no_email=skipped_no_email, skipped_no_email=skipped_no_email,
recipients=recipients, recipients=recipients,
) )
+21 -3
View File
@@ -17,14 +17,17 @@ from app.api.deps import get_edition
from app.api.schemas import PositionSheetsRequest from app.api.schemas import PositionSheetsRequest
from app.auth.deps import require_coordinator from app.auth.deps import require_coordinator
from app.db import get_session from app.db import get_session
from app.models import Account, Artist, Assignment, Edition, Position, Shift, Volunteer from app.i18n import t
from app.models import Account, Artist, Assignment, DayPart, Edition, Position, Shift, Volunteer
from app.sheets.pdf import ( from app.sheets.pdf import (
PositionDaySheet, PositionDaySheet,
SheetArtist, SheetArtist,
SheetFooter,
SheetShift, SheetShift,
SheetVolunteer, SheetVolunteer,
generate_position_sheets_pdf, generate_position_sheets_pdf,
) )
from app.validation.engine import day_boundary, festival_day
router = APIRouter( router = APIRouter(
prefix="/editions/{edition_id}/position-sheets", tags=["position-sheets"] prefix="/editions/{edition_id}/position-sheets", tags=["position-sheets"]
@@ -69,6 +72,13 @@ def generate_position_sheets(
for vs in volunteers_by_shift.values(): for vs in volunteers_by_shift.values():
vs.sort(key=lambda v: (v.last_name, v.first_name)) vs.sort(key=lambda v: (v.last_name, v.first_name))
# « Jour » = journée de festival (CLAUDE.md § 3), pas date du calendrier :
# une soirée qui passe minuit (23h–00h, 00h–01h…) reste sur une seule
# feuille, celle du jour où elle a commencé.
boundary = day_boundary(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
# Un jour sans aucun besoin réel (créneau 0/0 généré automatiquement, # Un jour sans aucun besoin réel (créneau 0/0 généré automatiquement,
# jamais rempli) n'apparaît pas — évite des pages « personne affecté » # jamais rempli) n'apparaît pas — évite des pages « personne affecté »
# pour des créneaux qui ne représentent aucun besoin. # pour des créneaux qui ne représentent aucun besoin.
@@ -77,7 +87,8 @@ def generate_position_sheets(
undefined_need = shift.min_staff == 0 and shift.ideal_staff == 0 undefined_need = shift.min_staff == 0 and shift.ideal_staff == 0
if undefined_need and not volunteers_by_shift.get(shift.id): if undefined_need and not volunteers_by_shift.get(shift.id):
continue continue
shifts_by_position_day[(shift.position_id, shift.starts_at.date())].append(shift) day = festival_day(shift.starts_at, boundary)
shifts_by_position_day[(shift.position_id, day)].append(shift)
Referent = aliased(Volunteer) Referent = aliased(Volunteer)
referent_rows = session.exec( referent_rows = session.exec(
@@ -142,7 +153,14 @@ def generate_position_sheets(
status_code=422, detail={"code": "position_sheets.no_shifts"} status_code=422, detail={"code": "position_sheets.no_shifts"}
) )
pdf_bytes = generate_position_sheets_pdf(sheets) # Message de l'organisation (le même que sur l'écran bénévole) en bas de
# chaque page.
footer = (
SheetFooter(title=t("position_sheets.footer_title"), message=edition.volunteer_message)
if edition.volunteer_message
else None
)
pdf_bytes = generate_position_sheets_pdf(sheets, footer)
return Response( return Response(
content=pdf_bytes, content=pdf_bytes,
media_type="application/pdf", media_type="application/pdf",
+3 -1
View File
@@ -10,7 +10,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api._common import owned_or_404 from app.api._common import owned_or_404, set_message
from app.api._shiftgen import generate_missing_shifts from app.api._shiftgen import generate_missing_shifts
from app.api.deps import get_edition from app.api.deps import get_edition
from app.api.schemas import ( from app.api.schemas import (
@@ -103,6 +103,8 @@ def update_position(
ensure_can_write_position(account, position) ensure_can_write_position(account, position)
changes = payload.model_dump(exclude_unset=True) changes = payload.model_dump(exclude_unset=True)
_check_lead_account(session, edition, changes.get("lead_account_id")) _check_lead_account(session, edition, changes.get("lead_account_id"))
if "message" in changes:
set_message(position, "message", changes.pop("message"))
for field, value in changes.items(): for field, value in changes.items():
setattr(position, field, value) setattr(position, field, value)
session.commit() session.commit()
+217 -31
View File
@@ -7,8 +7,11 @@ from pydantic import BaseModel, ConfigDict, Field
from app.models import ( from app.models import (
AssignmentSource, AssignmentSource,
AttendanceStatus,
BadgeAlign, BadgeAlign,
BadgeFace,
BadgeFieldType, BadgeFieldType,
BadgePhotoShape,
FeePaymentStatus, FeePaymentStatus,
FormFieldType, FormFieldType,
PositionCategory, PositionCategory,
@@ -33,23 +36,34 @@ class EditionRead(ApiModel):
start_date: dt.date start_date: dt.date
end_date: dt.date end_date: dt.date
retention_days: int | None retention_days: int | None
volunteer_message: str | None
volunteer_message_updated_at: dt.datetime | None
created_at: dt.datetime created_at: dt.datetime
updated_at: dt.datetime updated_at: dt.datetime
# Plafond d'un message (organisation, poste) : quelques paragraphes, pas un
# document.
MESSAGE_MAX_LENGTH = 2000
class EditionUpdate(ApiModel): class EditionUpdate(ApiModel):
name: str | None = None name: str | None = None
year: int | None = None year: int | None = None
start_date: dt.date | None = None start_date: dt.date | None = None
end_date: dt.date | None = None end_date: dt.date | None = None
retention_days: int | None = None retention_days: int | None = None
# Vide ou blanc = plus de message.
volunteer_message: str | None = Field(default=None, max_length=MESSAGE_MAX_LENGTH)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Volunteer # # Volunteer #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
class VolunteerCreate(ApiModel): class VolunteerCreate(ApiModel):
first_name: str # Facultatifs à l'ajout manuel par la coordination (fiche complétée plus
last_name: str # tard) ; le formulaire public et l'import les exigent toujours.
first_name: str = ""
last_name: str = ""
email: str | None = None email: str | None = None
phone: str | None = None phone: str | None = None
comment: str | None = None comment: str | None = None
@@ -74,6 +88,11 @@ class VolunteerUpdate(ApiModel):
# Issus du formulaire public (`app/api/forms.py`), éditables ensuite # Issus du formulaire public (`app/api/forms.py`), éditables ensuite
# depuis la fiche — voir CLAUDE.md § 2 (Volunteer). # depuis la fiche — voir CLAUDE.md § 2 (Volunteer).
companion_wish_note: str | None = None companion_wish_note: str | None = None
# Texte libre du badge (champ `FREE_TEXT`), saisi par la coordination.
badge_text: str | None = None
# Exemplaires du badge sur les planches (1 à 100 ; garde-fou contre une
# faute de frappe qui imprimerait des centaines de pages).
badge_count: int | None = Field(default=None, ge=1, le=100)
self_declared_available: bool | None = None self_declared_available: bool | None = None
willing_to_pay_fee: bool | None = None willing_to_pay_fee: bool | None = None
@@ -91,6 +110,8 @@ class VolunteerRead(ApiModel):
wished_companion_ids: list[uuid.UUID] wished_companion_ids: list[uuid.UUID]
has_photo: bool has_photo: bool
companion_wish_note: str | None companion_wish_note: str | None
badge_text: str | None
badge_count: int
self_declared_available: bool self_declared_available: bool
willing_to_pay_fee: bool willing_to_pay_fee: bool
# Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) — # Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) —
@@ -102,6 +123,10 @@ class VolunteerRead(ApiModel):
fee_amount: float | None fee_amount: float | None
fee_payment_status: FeePaymentStatus | None fee_payment_status: FeePaymentStatus | None
fee_paid_at: dt.datetime | None fee_paid_at: dt.datetime | None
# Transaction SumUp ayant réglé la cotisation (code du tableau de bord
# SumUp + identifiant technique) — `None` tant que non payée.
fee_transaction_code: str | None
fee_transaction_id: str | None
# Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un # Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un
# type de paiement « mécénat » (voir `FormPaymentOption.requires_mecenat_info`). # type de paiement « mécénat » (voir `FormPaymentOption.requires_mecenat_info`).
mecenat_address: str | None mecenat_address: str | None
@@ -128,6 +153,9 @@ class VolunteerPlanningEntry(ApiModel):
# pas rattaché à une fiche bénévole (pas de téléphone à donner alors). # pas rattaché à une fiche bénévole (pas de téléphone à donner alors).
referent_name: str | None referent_name: str | None
referent_phone: str | None referent_phone: str | None
# Message du poste (voir `Position.message`).
position_message: str | None
position_message_updated_at: dt.datetime | None
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -174,6 +202,8 @@ class PositionUpdate(ApiModel):
location: str | None = None location: str | None = None
category: PositionCategory | None = None category: PositionCategory | None = None
lead_account_id: uuid.UUID | None = None lead_account_id: uuid.UUID | None = None
# Vide ou blanc = plus de message.
message: str | None = Field(default=None, max_length=MESSAGE_MAX_LENGTH)
class PositionRead(ApiModel): class PositionRead(ApiModel):
@@ -183,6 +213,8 @@ class PositionRead(ApiModel):
location: str | None location: str | None
category: PositionCategory category: PositionCategory
lead_account_id: uuid.UUID | None lead_account_id: uuid.UUID | None
message: str | None
message_updated_at: dt.datetime | None
created_at: dt.datetime created_at: dt.datetime
updated_at: dt.datetime updated_at: dt.datetime
@@ -254,33 +286,6 @@ class DayPartUpdate(ApiModel):
order: int | None = None order: int | None = None
# --------------------------------------------------------------------------- #
# Availability (disponibilités déclarées par le bénévole) #
# --------------------------------------------------------------------------- #
class AvailabilityWindowIn(ApiModel):
date: dt.date
start_time: dt.time
# end_time <= start_time ⇒ la fenêtre se termine le lendemain.
end_time: dt.time
class AvailabilitySet(ApiModel):
"""Remplace l'intégralité des disponibilités d'un bénévole."""
windows: list[AvailabilityWindowIn] = Field(default_factory=list)
class AvailabilityRead(ApiModel):
id: uuid.UUID
edition_id: uuid.UUID
volunteer_id: uuid.UUID
date: dt.date
start_time: dt.time
end_time: dt.time
created_at: dt.datetime
updated_at: dt.datetime
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Assignment # # Assignment #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -303,10 +308,17 @@ class AssignmentRead(ApiModel):
shift_id: uuid.UUID shift_id: uuid.UUID
source: AssignmentSource source: AssignmentSource
locked: bool locked: bool
attendance: AttendanceStatus | None
attendance_marked_at: dt.datetime | None
created_at: dt.datetime created_at: dt.datetime
updated_at: dt.datetime updated_at: dt.datetime
class AttendanceUpdate(ApiModel):
# `None` = annuler le pointage.
attendance: AttendanceStatus | None
class AssignmentWriteResult(ApiModel): class AssignmentWriteResult(ApiModel):
assignment: AssignmentRead assignment: AssignmentRead
warnings: list[Violation] = Field(default_factory=list) warnings: list[Violation] = Field(default_factory=list)
@@ -366,6 +378,7 @@ class MatrixResponse(ApiModel):
# Badges (planches PDF) # # Badges (planches PDF) #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
class BadgeFieldIn(ApiModel): class BadgeFieldIn(ApiModel):
face: BadgeFace = BadgeFace.FRONT
field_type: BadgeFieldType field_type: BadgeFieldType
x_mm: float x_mm: float
y_mm: float y_mm: float
@@ -375,11 +388,21 @@ class BadgeFieldIn(ApiModel):
align: BadgeAlign = BadgeAlign.LEFT align: BadgeAlign = BadgeAlign.LEFT
bold: bool = False bold: bool = False
color: str = "#000000" color: str = "#000000"
# `FREE_TEXT` seulement (voir `BadgeField.text`), ignoré sinon.
text: str | None = None
# Fond de la zone (`#rrggbb`), `None` = transparent — voir `BadgeField`.
bg_color: str | None = None
auto_fit: bool = False
# `PHOTO` seulement, ignoré sinon.
photo_shape: BadgePhotoShape = BadgePhotoShape.RECT
# Ordre d'empilement sur la face (voir `BadgeField.z_order`).
z_order: int = 0
class BadgeFieldsSet(ApiModel): class BadgeFieldsSet(ApiModel):
"""Remplace l'intégralité des champs du recto — au plus un par """Remplace l'intégralité des champs du recto et du verso — au plus un
`field_type` (décocher un champ dans l'éditeur retire sa ligne).""" par (`face`, `field_type`) (décocher un champ dans l'éditeur retire sa
ligne)."""
fields: list[BadgeFieldIn] = Field(default_factory=list) fields: list[BadgeFieldIn] = Field(default_factory=list)
@@ -493,6 +516,33 @@ class PlanningNotificationResult(ApiModel):
recipients: list[uuid.UUID] = Field(default_factory=list) recipients: list[uuid.UUID] = Field(default_factory=list)
class TestEmailRequest(ApiModel):
email: str
# Modèle du récapitulatif de planning en cours d'édition (mêmes
# variables que `PlanningNotificationRequest`) ; absent = modèle par
# défaut.
subject: str | None = None
body: str | None = None
# Bénévole dont les données remplissent les variables ; absent = le
# premier (par nom) qui a au moins un créneau.
volunteer_id: uuid.UUID | None = None
class TestEmailResult(ApiModel):
"""`status` : `sent` / `disabled` / `connection_failed` / `rejected` —
voir `app/email/sender.py:deliver_test`. `detail` : message du serveur
SMTP en cas d'échec."""
status: str
detail: str | None = None
smtp_host: str
smtp_from: str
# Bénévole dont les données ont rempli le modèle (`None` : exemple
# fictif, l'édition n'a encore aucun bénévole).
example_volunteer_id: uuid.UUID | None = None
example_volunteer_name: str
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Formulaire public d'inscription # # Formulaire public d'inscription #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -647,3 +697,139 @@ class SumUpWebhookPayload(ApiModel):
event_type: str event_type: str
id: str id: str
# --------------------------------------------------------------------------- #
# Statistiques d'une édition (`GET /editions/{id}/stats`, app/api/stats.py) #
# --------------------------------------------------------------------------- #
class StatsRegistrationDay(ApiModel):
date: dt.date
count: int
cumulative: int
class StatsRegistrations(ApiModel):
"""Bénévoles en base, par jour d'ajout (`Volunteer.created_at`) — jours
sans inscription inclus (à 0), pour une courbe sans trou."""
total: int
last_7_days: int
by_day: list[StatsRegistrationDay]
class StatsAnswers(ApiModel):
"""Nombre de bénévoles ayant renseigné chaque information (sur `total`)."""
total: int
email: int
phone: int
photo: int
comment: int
companion_note: int
available: int
position_wishes: int
artist_wishes: int
companion_wishes: int
class StatsPaymentOption(ApiModel):
label: str
mecenat: bool
count: int
amount_total: float
class StatsPayments(ApiModel):
"""Mode de paiement (même règle que `lib/fee.ts:feeMode`) : `online`
(paiement en ligne confirmé), `on_site`, `none`. `pending_unfinished` :
formulaires remplis dont le paiement en ligne n'a jamais abouti — aucun
bénévole créé pour eux (`PendingFormSubmission` non promue)."""
online: int
on_site: int
none: int
pending_unfinished: int
total_collected: float
mecenat_count: int
mecenat_total: float
by_option: list[StatsPaymentOption]
class StatsArtistWish(ApiModel):
id: uuid.UUID
name: str
date: dt.date
count: int
class StatsNeedDay(ApiModel):
"""Par journée de festival (`validation/engine.py:festival_day`) : heures
× personnes demandées (effectif minimal / idéal), heures déjà affectées."""
date: dt.date
shifts: int
min_hours: float
ideal_hours: float
assigned_hours: float
class StatsNeedPosition(ApiModel):
"""Places (personnes × créneaux) demandées et pourvues pour un poste, et
nombre de bénévoles qui l'ont mis dans leurs vœux."""
position_id: uuid.UUID
name: str
shifts: int
min_slots: int
ideal_slots: int
assigned: int
# Places pourvues, plafonnées créneau par créneau à l'effectif idéal : un
# sureffectif ne compense jamais un trou ailleurs.
filled: int
wishes: int
class StatsNeeds(ApiModel):
by_day: list[StatsNeedDay]
by_position: list[StatsNeedPosition]
volunteers_with_assignment: int
class StatsAttendanceCounts(ApiModel):
"""Pointage (`Assignment.attendance`) d'un ensemble d'affectations :
`assigned` = `present` + `absent` + `unmarked`."""
assigned: int = 0
present: int = 0
absent: int = 0
unmarked: int = 0
class StatsAttendancePosition(ApiModel):
position_id: uuid.UUID
name: str
# Créneaux terminés (`ends_at <= as_of`) et créneaux en cours.
past: StatsAttendanceCounts
live: StatsAttendanceCounts
live_shifts: int
class StatsAttendance(ApiModel):
"""Présents / absents pointés par les référents, par poste : sur les
créneaux déjà terminés et sur ceux en cours à `as_of` (heure du calcul,
locale). Les créneaux à venir ne comptent pas."""
as_of: dt.datetime
past: StatsAttendanceCounts
live: StatsAttendanceCounts
live_shifts: int
by_position: list[StatsAttendancePosition]
class EditionStats(ApiModel):
registrations: StatsRegistrations
answers: StatsAnswers
payments: StatsPayments
artist_wishes: list[StatsArtistWish]
needs: StatsNeeds
attendance: StatsAttendance
+16
View File
@@ -2,6 +2,8 @@
coordination affecte elle-même, dans l'écran habituel, en tenant compte des coordination affecte elle-même, dans l'écran habituel, en tenant compte des
bénévoles remontés en tête de liste.""" bénévoles remontés en tête de liste."""
import logging
import time
import uuid import uuid
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@@ -14,6 +16,8 @@ from app.db import get_session
from app.models import Account, Edition from app.models import Account, Edition
from app.solver import build_solver_input, solve from app.solver import build_solver_input, solve
logger = logging.getLogger("app.solver")
router = APIRouter(prefix="/editions/{edition_id}/solver", tags=["solver"]) router = APIRouter(prefix="/editions/{edition_id}/solver", tags=["solver"])
@@ -23,8 +27,20 @@ def run_solver(
_guard: Account = Depends(require_staff), _guard: Account = Depends(require_staff),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> SolverRunResult: ) -> SolverRunResult:
started = time.monotonic()
data = build_solver_input(session=session, edition=edition) data = build_solver_input(session=session, edition=edition)
loaded = time.monotonic()
solution = solve(data) solution = solve(data)
logger.info(
"Solveur (édition %s) : %d créneaux, %d bénévoles, %d suggestions — "
"chargement %d ms, résolution %d ms",
edition.id,
len(data.shifts),
len(data.volunteers),
len(solution.suggestions),
(loaded - started) * 1000,
(time.monotonic() - loaded) * 1000,
)
return SolverRunResult( return SolverRunResult(
suggestions=[ suggestions=[
SolverSuggestion( SolverSuggestion(
+317
View File
@@ -0,0 +1,317 @@
"""Statistiques d'une édition : inscriptions (nombre, évolution), réponses au
formulaire, paiements de la cotisation, besoins face aux disponibilités.
Lecture seule, recalculée à chaque appel (quelques centaines de lignes au
plus pour un festival de 50 à 200 bénévoles) — rien n'est stocké.
"""
import datetime as dt
from collections import Counter, defaultdict
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from app.api.deps import get_edition
from app.api.schemas import (
EditionStats,
StatsAnswers,
StatsArtistWish,
StatsAttendance,
StatsAttendanceCounts,
StatsAttendancePosition,
StatsNeedDay,
StatsNeedPosition,
StatsNeeds,
StatsPaymentOption,
StatsPayments,
StatsRegistrationDay,
StatsRegistrations,
)
from app.auth.deps import require_coordinator
from app.db import get_session
from app.models import (
Account,
Artist,
Assignment,
AttendanceStatus,
DayPart,
Edition,
FeePaymentStatus,
PendingFormSubmission,
Position,
Shift,
Volunteer,
VolunteerArtist,
VolunteerCompanionWish,
VolunteerPositionWish,
local_now,
)
from app.validation.engine import day_boundary, festival_day
router = APIRouter(prefix="/editions/{edition_id}/stats", tags=["stats"])
def _hours(starts_at: dt.datetime, ends_at: dt.datetime) -> float:
return (ends_at - starts_at).total_seconds() / 3600
def _round(value: float) -> float:
return round(value, 2)
def _registrations(volunteers: list[Volunteer]) -> StatsRegistrations:
per_day = Counter(v.created_at.date() for v in volunteers)
by_day: list[StatsRegistrationDay] = []
if per_day:
day, last, cumulative = min(per_day), max(per_day), 0
while day <= last:
cumulative += per_day[day]
by_day.append(
StatsRegistrationDay(date=day, count=per_day[day], cumulative=cumulative)
)
day += dt.timedelta(days=1)
since = local_now() - dt.timedelta(days=7)
return StatsRegistrations(
total=len(volunteers),
last_7_days=sum(1 for v in volunteers if v.created_at >= since),
by_day=by_day,
)
def _payments(
volunteers: list[Volunteer], pending_unfinished: int
) -> StatsPayments:
# Même règle que `lib/fee.ts:feeMode` / `exporting/xlsx.py:_payment_mode`.
online = [v for v in volunteers if v.fee_payment_status is not None]
paid = [v for v in online if v.fee_payment_status == FeePaymentStatus.PAID]
by_label: dict[str, list[Volunteer]] = defaultdict(list)
for v in paid:
by_label[v.fee_payment_label or ""].append(v)
mecenat = [v for v in paid if v.mecenat_address]
return StatsPayments(
online=len(online),
on_site=sum(
1 for v in volunteers if v.fee_payment_status is None and v.willing_to_pay_fee
),
none=sum(
1 for v in volunteers if v.fee_payment_status is None and not v.willing_to_pay_fee
),
pending_unfinished=pending_unfinished,
total_collected=_round(sum(v.fee_amount or 0 for v in paid)),
mecenat_count=len(mecenat),
mecenat_total=_round(sum(v.fee_amount or 0 for v in mecenat)),
by_option=[
StatsPaymentOption(
label=label,
mecenat=any(v.mecenat_address for v in group),
count=len(group),
amount_total=_round(sum(v.fee_amount or 0 for v in group)),
)
for label, group in sorted(by_label.items())
],
)
def _count(counts: StatsAttendanceCounts, assignment: Assignment) -> None:
counts.assigned += 1
if assignment.attendance == AttendanceStatus.PRESENT:
counts.present += 1
elif assignment.attendance == AttendanceStatus.ABSENT:
counts.absent += 1
else:
counts.unmarked += 1
def _attendance(
positions: list[Position], shifts: list[Shift], assignments: list[Assignment]
) -> StatsAttendance:
"""Pointage par poste, sur les créneaux terminés et ceux en cours."""
now = local_now()
past = StatsAttendanceCounts()
live = StatsAttendanceCounts()
by_position = {
p.id: StatsAttendancePosition(
position_id=p.id,
name=p.name,
past=StatsAttendanceCounts(),
live=StatsAttendanceCounts(),
live_shifts=0,
)
for p in positions
}
# Créneau terminé ⇒ « past », en cours ⇒ « live », à venir : ignoré.
phase: dict = {}
for s in shifts:
if s.ends_at <= now:
phase[s.id] = ("past", s.position_id)
elif s.starts_at <= now:
phase[s.id] = ("live", s.position_id)
by_position[s.position_id].live_shifts += 1
for a in assignments:
if a.shift_id not in phase:
continue
when, position_id = phase[a.shift_id]
row = by_position[position_id]
_count(past if when == "past" else live, a)
_count(row.past if when == "past" else row.live, a)
return StatsAttendance(
as_of=now,
past=past,
live=live,
live_shifts=sum(r.live_shifts for r in by_position.values()),
by_position=[
r for r in by_position.values() if r.past.assigned or r.live.assigned or r.live_shifts
],
)
@router.get("", response_model=EditionStats)
def get_stats(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> EditionStats:
volunteers = list(session.exec(select(Volunteer).where(Volunteer.edition_id == edition.id)))
volunteer_ids = [v.id for v in volunteers]
positions = list(
session.exec(
select(Position).where(Position.edition_id == edition.id).order_by(Position.name)
)
)
shifts = list(session.exec(select(Shift).where(Shift.edition_id == edition.id)))
assignments = list(
session.exec(select(Assignment).where(Assignment.edition_id == edition.id))
)
artists = list(
session.exec(
select(Artist)
.where(Artist.edition_id == edition.id)
.order_by(Artist.performance_date, Artist.start_time)
)
)
position_wishes = (
list(
session.exec(
select(VolunteerPositionWish).where(
VolunteerPositionWish.volunteer_id.in_(volunteer_ids)
)
)
)
if volunteer_ids
else []
)
artist_wishes = (
list(
session.exec(
select(VolunteerArtist).where(VolunteerArtist.volunteer_id.in_(volunteer_ids))
)
)
if volunteer_ids
else []
)
companion_wishers = (
set(
session.exec(
select(VolunteerCompanionWish.volunteer_id).where(
VolunteerCompanionWish.volunteer_id.in_(volunteer_ids)
)
)
)
if volunteer_ids
else set()
)
pending_unfinished = len(
session.exec(
select(PendingFormSubmission.id).where(
PendingFormSubmission.edition_id == edition.id,
PendingFormSubmission.promoted_volunteer_id.is_(None),
)
).all()
)
boundary = day_boundary(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
answers = StatsAnswers(
total=len(volunteers),
email=sum(1 for v in volunteers if v.email),
phone=sum(1 for v in volunteers if v.phone),
photo=sum(1 for v in volunteers if v.photo is not None),
comment=sum(1 for v in volunteers if v.comment),
companion_note=sum(1 for v in volunteers if v.companion_wish_note),
available=sum(1 for v in volunteers if v.self_declared_available),
position_wishes=len({w.volunteer_id for w in position_wishes}),
artist_wishes=len({w.volunteer_id for w in artist_wishes}),
companion_wishes=len(companion_wishers),
)
fans = Counter(w.artist_id for w in artist_wishes)
artist_stats = [
StatsArtistWish(id=a.id, name=a.name, date=a.performance_date, count=fans[a.id])
for a in artists
]
# Besoins par journée de festival : une soirée qui passe minuit reste sur
# le jour où elle a commencé (CLAUDE.md § 3).
shifts_by_id = {s.id: s for s in shifts}
day_rows: dict[dt.date, dict] = defaultdict(
lambda: {
"shifts": 0, "min_hours": 0.0, "ideal_hours": 0.0, "assigned_hours": 0.0,
}
)
for s in shifts:
row = day_rows[festival_day(s.starts_at, boundary)]
hours = _hours(s.starts_at, s.ends_at)
row["shifts"] += 1
row["min_hours"] += hours * s.min_staff
row["ideal_hours"] += hours * s.ideal_staff
for a in assignments:
s = shifts_by_id.get(a.shift_id)
if s is not None:
day_rows[festival_day(s.starts_at, boundary)]["assigned_hours"] += _hours(
s.starts_at, s.ends_at
)
wishers = Counter(w.position_id for w in position_wishes)
assigned_by_shift = Counter(a.shift_id for a in assignments)
shifts_by_position: dict = defaultdict(list)
for s in shifts:
shifts_by_position[s.position_id].append(s)
needs = StatsNeeds(
by_day=[
StatsNeedDay(
date=day,
shifts=row["shifts"],
min_hours=_round(row["min_hours"]),
ideal_hours=_round(row["ideal_hours"]),
assigned_hours=_round(row["assigned_hours"]),
)
for day, row in sorted(day_rows.items())
],
by_position=[
StatsNeedPosition(
position_id=p.id,
name=p.name,
shifts=len(shifts_by_position[p.id]),
min_slots=sum(s.min_staff for s in shifts_by_position[p.id]),
ideal_slots=sum(s.ideal_staff for s in shifts_by_position[p.id]),
assigned=sum(assigned_by_shift[s.id] for s in shifts_by_position[p.id]),
filled=sum(
min(assigned_by_shift[s.id], s.ideal_staff) for s in shifts_by_position[p.id]
),
wishes=wishers[p.id],
)
for p in positions
],
volunteers_with_assignment=len({a.volunteer_id for a in assignments}),
)
return EditionStats(
registrations=_registrations(volunteers),
answers=answers,
payments=_payments(volunteers, pending_unfinished),
artist_wishes=artist_stats,
needs=needs,
attendance=_attendance(positions, shifts, assignments),
)
+42 -1
View File
@@ -5,12 +5,13 @@ L'email n'est pas unique : plusieurs bénévoles peuvent en partager un même
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api._common import ( from app.api._common import (
owned_or_404, owned_or_404,
read_photo,
replace_links, replace_links,
resolve_owned_ids, resolve_owned_ids,
set_artists, set_artists,
@@ -316,6 +317,8 @@ def get_volunteer_planning(
position_location=p.location, position_location=p.location,
referent_name=f"{r.first_name} {r.last_name}" if r else None, referent_name=f"{r.first_name} {r.last_name}" if r else None,
referent_phone=r.phone if r else None, referent_phone=r.phone if r else None,
position_message=p.message,
position_message_updated_at=p.message_updated_at,
) )
for a, s, p, r in rows for a, s, p, r in rows
] ]
@@ -342,6 +345,44 @@ def get_volunteer_photo(
) )
@router.put("/{volunteer_id}/photo", response_model=VolunteerRead)
async def set_volunteer_photo(
volunteer_id: uuid.UUID,
file: UploadFile = File(...),
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> VolunteerRead:
"""Ajout (ou remplacement) de la photo par la coordination, depuis la
fiche — pour un bénévole qui n'en a pas envoyé au formulaire public ou
ajouté à la main. Même validation que le formulaire (`read_photo`)."""
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
photo = await read_photo(file)
if photo is None:
raise HTTPException(status_code=400, detail={"code": "volunteer.empty_photo"})
volunteer.photo, volunteer.photo_content_type = photo
session.add(volunteer)
session.commit()
session.refresh(volunteer)
return _read(session, volunteer)
@router.delete("/{volunteer_id}/photo", response_model=VolunteerRead)
def delete_volunteer_photo(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> VolunteerRead:
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
volunteer.photo = None
volunteer.photo_content_type = None
session.add(volunteer)
session.commit()
session.refresh(volunteer)
return _read(session, volunteer)
@router.patch("/{volunteer_id}", response_model=VolunteerRead) @router.patch("/{volunteer_id}", response_model=VolunteerRead)
def update_volunteer( def update_volunteer(
volunteer_id: uuid.UUID, volunteer_id: uuid.UUID,
+5 -1
View File
@@ -4,7 +4,7 @@ import uuid
from collections.abc import Callable from collections.abc import Callable
import jwt import jwt
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlmodel import Session from sqlmodel import Session
@@ -31,6 +31,7 @@ def _forbidden() -> HTTPException:
def get_current_account( def get_current_account(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> Account: ) -> Account:
@@ -44,6 +45,9 @@ def get_current_account(
account = session.get(Account, account_id) account = session.get(Account, account_id)
if account is None: if account is None:
raise _unauthorized() raise _unauthorized()
# Repris sur la ligne d'accès de la requête (`app/request_logging.py`) :
# qui a rencontré l'erreur, par identifiant seulement.
request.state.account_id = account.id
return account return account
+64
View File
@@ -0,0 +1,64 @@
"""Lien de connexion permanent — celui du récapitulatif de planning
(`{lien_connexion}`, `app/api/notifications.py`).
Contrairement au lien magique de la page de connexion (`magic_link.py` :
aléatoire, haché en base, 30 min, usage unique), ce lien se réutilise sans
limite de durée : le bénévole retrouve son planning en un clic depuis
n'importe quel email reçu. Il n'est stocké nulle part : c'est une signature
HMAC (clé `JWT_SECRET`) de l'identifiant du compte et de sa
`login_link_version`, donc le même lien figure dans tous les emails d'un
bénévole. Révocation : incrémenter la version (`POST .../accounts/{id}/
reset-login-link`) invalide tous les liens déjà envoyés à ce compte ;
changer `JWT_SECRET` les invalide tous. Bénévoles et référents seulement,
jamais la coordination ni un compte à mot de passe."""
import base64
import hashlib
import hmac
import uuid
from sqlmodel import Session
from app.config import get_settings
from app.models import Account, Role
# Jamais la coordination, même sans mot de passe : son accès complet ne
# doit pas tenir à un lien qui circule par email.
_ALLOWED_ROLES = (Role.VOLUNTEER, Role.LEAD)
PREFIX = "pl."
def _signature(account_id: uuid.UUID, version: int) -> str:
digest = hmac.new(
get_settings().jwt_secret.encode("utf-8"),
f"permanent-login:{account_id}:{version}".encode(),
hashlib.sha256,
).digest()
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
def permanent_login_token(account: Account) -> str:
"""Jeton en clair du lien permanent d'un compte (toujours le même tant
que sa version ne change pas)."""
return f"{PREFIX}{account.id.hex}.{_signature(account.id, account.login_link_version)}"
def account_from_permanent_token(session: Session, token: str) -> Account | None:
"""Compte désigné par un jeton permanent valide, `None` sinon (format,
compte inconnu, signature ou version périmée, compte à mot de passe ou
de la coordination)."""
if not token.startswith(PREFIX):
return None
raw_id, _, signature = token[len(PREFIX) :].partition(".")
try:
account_id = uuid.UUID(hex=raw_id)
except ValueError:
return None
account = session.get(Account, account_id)
if account is None or account.password_hash is not None or account.role not in _ALLOWED_ROLES:
return None
expected = _signature(account.id, account.login_link_version)
if not hmac.compare_digest(signature, expected):
return None
return account
+411 -25
View File
@@ -8,27 +8,43 @@ Format du badge : dérivé de l'image de fond du recto (pas de format fixe
imposé) — voir `image_size_mm`. Tant qu'aucune image n'est importée, imposé) — voir `image_size_mm`. Tant qu'aucune image n'est importée,
`DEFAULT_BADGE_WIDTH_MM`/`DEFAULT_BADGE_HEIGHT_MM` (CR80, 85,6 × 54 mm — la `DEFAULT_BADGE_WIDTH_MM`/`DEFAULT_BADGE_HEIGHT_MM` (CR80, 85,6 × 54 mm — la
taille standard des badges/cartes d'événement) servent de repère à taille standard des badges/cartes d'événement) servent de repère à
l'éditeur. Planche : grille sur page A4 portrait. Le verso est identique l'éditeur. Planche : grille sur page A4 portrait. Chaque page de recto est
pour tous les bénévoles : la planche imprime toutes les pages de recto suivie de sa page de verso (même image de fond à chaque emplacement rempli,
(personnalisées) puis, pour chacune, la page de verso correspondante (même plus les champs du verso de chaque bénévole) — impression recto-verso
disposition, même image répétée à chaque emplacement rempli) — impression classique, retournement sur le bord long : sur la page de verso, chaque
recto-verso classique, page de verso n directement après la page de recto n. badge est placé en miroir horizontal (colonne `cols - 1 - col`, même rangée)
pour tomber exactement derrière son recto — voir `_cell_origin`.
""" """
import io import io
from dataclasses import dataclass from dataclasses import dataclass
from PIL import Image, ImageOps, UnidentifiedImageError
from reportlab.lib.colors import HexColor from reportlab.lib.colors import HexColor
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader from reportlab.lib.utils import ImageReader
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
DEFAULT_BADGE_WIDTH_MM = 85.6 DEFAULT_BADGE_WIDTH_MM = 85.6
DEFAULT_BADGE_HEIGHT_MM = 54.0 DEFAULT_BADGE_HEIGHT_MM = 54.0
# Minimums pour compter combien de badges tiennent sur la page (marge
# d'impression, écart entre deux badges) ; l'espace effectivement laissé est
# ensuite réparti à parts égales — voir `_spacing`.
_MARGIN_MM = 10.0 _MARGIN_MM = 10.0
_GUTTER_MM = 4.0 _GUTTER_MM = 4.0
_LINE_HEIGHT_FACTOR = 1.25 _LINE_HEIGHT_FACTOR = 1.25
# Tableau (champ « postes ») : marges intérieures des cellules et épaisseur
# des filets.
_CELL_PAD_X_MM = 0.8
_CELL_PAD_Y_MM = 0.5
_TABLE_LINE_WIDTH_PT = 0.4
# Réduction automatique de la taille du texte (`auto_fit`) : pas et plancher.
_AUTO_FIT_STEP_PT = 0.5
_AUTO_FIT_MIN_PT = 4.0
# Photo absente ou illisible : initiales sur ce fond, à cette taille (part de
# la largeur du cadre).
# Résolution supposée d'une image de fond importée, pour convertir ses pixels # Résolution supposée d'une image de fond importée, pour convertir ses pixels
# en millimètres réels (donc en taille de badge) — 300 dpi est la résolution # en millimètres réels (donc en taille de badge) — 300 dpi est la résolution
@@ -46,11 +62,40 @@ def image_size_mm(width_px: int, height_px: int, dpi: float = ASSUMED_DPI) -> tu
return (width_px / dpi * 25.4, height_px / dpi * 25.4) return (width_px / dpi * 25.4, height_px / dpi * 25.4)
@dataclass(frozen=True)
class BadgeTableSection:
"""Un bloc du tableau : une ligne d'en-tête sur toute la largeur (le
jour), puis ses lignes (horaire, poste)."""
title: str
rows: tuple[tuple[str, ...], ...]
@dataclass(frozen=True)
class BadgeTable:
"""Contenu d'un champ affiché en tableau plutôt qu'en texte libre : les
créneaux du bénévole regroupés par jour."""
sections: tuple[BadgeTableSection, ...]
@dataclass(frozen=True) @dataclass(frozen=True)
class BadgeFieldSpec: class BadgeFieldSpec:
"""Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n` """Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n`
possibles — ex. un poste par ligne avec son horaire), positionné depuis possibles — ex. prénom puis nom), positionné depuis le coin haut-gauche
le coin haut-gauche du badge (comme dans l'éditeur visuel du front-end).""" du badge (comme dans l'éditeur visuel du front-end).
`is_photo` : le champ est la photo du bénévole (`image`, ses octets
bruts tels que stockés) plutôt qu'un texte, découpée selon
`photo_shape` (`RECT`/`OVAL`). Sans photo (ou photo illisible), rien
n'est imprimé : le cadre reste vide.
`table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`.
`bg_color` : fond plein de la zone du champ (`None` = transparent ;
ignoré pour la photo). `auto_fit` : réduit la taille du texte jusqu'à ce
que tout tienne dans le cadre (sinon, les lignes en trop ne sont pas
imprimées). `valign` : `TOP` ou `MIDDLE` (centré verticalement)."""
text: str text: str
x_mm: float x_mm: float
@@ -61,13 +106,22 @@ class BadgeFieldSpec:
align: str # "LEFT" | "CENTER" | "RIGHT" align: str # "LEFT" | "CENTER" | "RIGHT"
bold: bool bold: bool
color: str # "#rrggbb" color: str # "#rrggbb"
is_photo: bool = False
image: bytes | None = None
table: BadgeTable | None = None
bg_color: str | None = None
auto_fit: bool = False
valign: str = "TOP" # "TOP" | "MIDDLE"
photo_shape: str = "RECT" # "RECT" | "OVAL"
@dataclass(frozen=True) @dataclass(frozen=True)
class BadgeSpec: class BadgeSpec:
"""Un badge = un bénévole, avec ses champs déjà résolus.""" """Un badge = un bénévole, avec ses champs déjà résolus : `fields` pour
le recto, `back_fields` pour le verso."""
fields: tuple[BadgeFieldSpec, ...] fields: tuple[BadgeFieldSpec, ...]
back_fields: tuple[BadgeFieldSpec, ...] = ()
def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]: def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]:
@@ -78,14 +132,45 @@ def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]
return cols, rows return cols, rows
def _spacing(count: int, page_mm: float, size_mm: float) -> tuple[float, float]:
"""`(marge extérieure, écart entre badges)` sur un axe, en mm : l'espace
libre est réparti **à parts égales** — même écart à gauche, entre chaque
badge et à droite (resp. en haut, entre les rangées et en bas) — pour une
grille centrée et symétrique. Si cet écart passait sous la marge
d'impression (`_MARGIN_MM`), la marge est gardée de chaque côté et le
reste réparti entre les badges : toujours symétrique. Avant, la grille
partait du coin haut-gauche et tout l'espace restant tombait à droite et
en bas ; une fois la feuille retournée, le verso se décalait d'autant par
rapport au recto (bug corrigé une fois)."""
free = page_mm - count * size_mm
even = free / (count + 1)
if even >= _MARGIN_MM or count == 1:
return (even, even) if count > 1 else (free / 2, 0.0)
return _MARGIN_MM, (free - 2 * _MARGIN_MM) / (count - 1)
def _cell_origin( def _cell_origin(
index: int, cols: int, badge_width_mm: float, badge_height_mm: float index: int,
cols: int,
badge_width_mm: float,
badge_height_mm: float,
mirrored: bool = False,
) -> tuple[float, float]: ) -> tuple[float, float]:
"""Coin bas-gauche (repère PDF) de l'emplacement `index` sur la page, """Coin bas-gauche (repère PDF) de l'emplacement `index` sur la page,
en mm depuis le coin bas-gauche de la page.""" en mm depuis le coin bas-gauche de la page.
`mirrored` : page de verso. Une feuille retournée sur son bord long
inverse gauche et droite : le verso du badge de la colonne `col` doit
être imprimé en colonne `cols - 1 - col` (même rangée) pour tomber
derrière son recto — y compris sur une dernière planche incomplète."""
col, row = index % cols, index // cols col, row = index % cols, index // cols
x0 = _MARGIN_MM + col * (badge_width_mm + _GUTTER_MM) if mirrored:
y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * badge_height_mm - row * _GUTTER_MM col = cols - 1 - col
rows = _grid_size(badge_width_mm, badge_height_mm)[1]
outer_x, gap_x = _spacing(cols, _PAGE_WIDTH_MM, badge_width_mm)
outer_y, gap_y = _spacing(rows, _PAGE_HEIGHT_MM, badge_height_mm)
x0 = outer_x + col * (badge_width_mm + gap_x)
y0 = _PAGE_HEIGHT_MM - outer_y - (row + 1) * badge_height_mm - row * gap_y
return x0, y0 return x0, y0
@@ -114,30 +199,310 @@ def _draw_background(
) )
def _draw_fields( def _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None:
"""Recadre la photo au ratio du cadre (centrée, sans déformation — une
photo de téléphone n'a jamais le ratio du cadre) et la réduit à la
résolution d'impression : une photo de plusieurs Mo répétée sur 200
badges alourdirait inutilement le PDF. Applique l'orientation EXIF (une
photo prise en portrait au téléphone est souvent stockée couchée).
`None` si l'image est illisible : le badge s'imprime sans photo plutôt
que de faire échouer toute la planche."""
target = (
max(1, round(width_mm / 25.4 * ASSUMED_DPI)),
max(1, round(height_mm / 25.4 * ASSUMED_DPI)),
)
try:
with Image.open(io.BytesIO(image)) as img:
# JPEG : décodage directement à taille réduite (bien plus rapide
# qu'une photo de téléphone décodée en pleine résolution).
img.draft("RGB", (target[0] * 2, target[1] * 2))
fitted = ImageOps.fit(ImageOps.exif_transpose(img).convert("RGB"), target)
except (UnidentifiedImageError, OSError):
return None
out = io.BytesIO()
fitted.save(out, format="JPEG", quality=88)
return out.getvalue()
def _frame_rect(
f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
) -> tuple[float, float, float, float]:
"""Cadre du champ en points, repère PDF : (gauche, bas, largeur,
hauteur). `f.y_mm` est le haut du cadre depuis le HAUT du badge ;
reportlab dessine depuis le BAS de la page."""
return (
(x0_mm + f.x_mm) * mm,
(y0_mm + badge_height_mm - f.y_mm - f.height_mm) * mm,
f.width_mm * mm,
f.height_mm * mm,
)
def _shape_path(c: canvas.Canvas, shape: str, x: float, y: float, w: float, h: float):
path = c.beginPath()
if shape == "OVAL":
path.ellipse(x, y, w, h)
else:
path.rect(x, y, w, h)
return path
# Photos déjà recadrées pendant une génération, par (photo, largeur,
# hauteur du cadre) : un bénévole a souvent la même photo au recto, au verso
# et sur plusieurs exemplaires (`badge_count`) — elle n'est traitée qu'une
# fois.
PhotoCache = dict[tuple[bytes, float, float], bytes | None]
def _draw_photo(
c: canvas.Canvas, c: canvas.Canvas,
fields: tuple[BadgeFieldSpec, ...], f: BadgeFieldSpec,
x0_mm: float, x0_mm: float,
y0_mm: float, y0_mm: float,
badge_height_mm: float, badge_height_mm: float,
cache: PhotoCache,
) -> None: ) -> None:
for f in fields: x, y, w, h = _frame_rect(f, x0_mm, y0_mm, badge_height_mm)
c.setFont("Helvetica-Bold" if f.bold else "Helvetica", f.font_size) photo = None
if f.image:
key = (f.image, f.width_mm, f.height_mm)
if key not in cache:
cache[key] = _fit_photo(f.image, f.width_mm, f.height_mm)
photo = cache[key]
if photo is None:
# Pas de photo (ou illisible) : cadre laissé vide, choix de la
# coordination — avant, les initiales étaient imprimées à la place.
return
c.saveState()
if f.photo_shape == "OVAL":
# Découpe en ellipse inscrite dans le cadre.
c.clipPath(_shape_path(c, "OVAL", x, y, w, h), stroke=0, fill=0)
c.drawImage(ImageReader(io.BytesIO(photo)), x, y, width=w, height=h)
c.restoreState()
def _draw_zone_background(
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
) -> None:
if not f.bg_color:
return
c.saveState()
c.setFillColor(HexColor(f.bg_color))
c.rect(*_frame_rect(f, x0_mm, y0_mm, badge_height_mm), stroke=0, fill=1)
c.restoreState()
def wrap_text(text: str, font_name: str, font_size: float, max_width_pt: float) -> list[str]:
"""Découpe `text` en lignes qui tiennent dans `max_width_pt` : les `\\n`
du texte restent des retours à la ligne, puis chaque ligne trop longue
passe à la ligne entre deux mots — ou au milieu d'un mot qui, seul, ne
tient pas (nom très long dans un cadre étroit). Sans cela, un prénom +
nom ou une ligne de poste avec horaire débordait du cadre du champ."""
def fits(candidate: str) -> bool:
return stringWidth(candidate, font_name, font_size) <= max_width_pt
lines: list[str] = []
for paragraph in text.split("\n"):
current = ""
# `split(" ")` (et non `split()`) garde les doubles espaces voulus,
# ex. entre le nom du poste et son horaire.
for word in paragraph.split(" "):
candidate = f"{current} {word}" if current else word
if fits(candidate):
current = candidate
continue
if current:
lines.append(current.rstrip())
current = word
while current and not fits(current):
cut = len(current) - 1
while cut > 1 and not fits(current[:cut]):
cut -= 1
lines.append(current[:cut])
current = current[cut:]
lines.append(current.rstrip())
return lines
def _column_widths(natural: list[float], available: float) -> list[float]:
"""Largeurs des colonnes d'un tableau dans `available` : une colonne
courte (jour, horaire) garde sa largeur naturelle, les plus longues
(poste, référent) se partagent le reste à parts égales et passent à la
ligne. S'il reste de la place, la dernière colonne la prend."""
widths = [0.0] * len(natural)
pending = sorted(range(len(natural)), key=lambda i: natural[i])
remaining = available
while pending:
share = remaining / len(pending)
if natural[pending[0]] > share:
for i in pending:
widths[i] = share
return widths
i = pending.pop(0)
widths[i] = natural[i]
remaining -= natural[i]
widths[-1] += remaining
return widths
_TableLine = tuple[list[list[str]], str, bool, float]
def _table_layout(
table: BadgeTable, width_pt: float, font_size: float
) -> tuple[list[float], list[_TableLine]]:
"""Largeurs des colonnes et lignes du tableau à `font_size` : pour
chaque ligne, (texte replié de chaque cellule, police, en-tête de
section ?, hauteur en points)."""
regular, bold = "Helvetica", "Helvetica-Bold"
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
line_height = font_size * _LINE_HEIGHT_FACTOR
body = [row for section in table.sections for row in section.rows]
columns = max(len(row) for row in body) if body else 1
natural = [
max(stringWidth(row[col], regular, font_size) for row in body) + 2 * pad_x
for col in range(columns)
] if body else [0.0]
widths = _column_widths(natural, width_pt)
total_width = sum(widths)
# Un en-tête n'a qu'une cellule, sur toute la largeur.
cells_to_draw = []
for section in table.sections:
cells_to_draw.append(((section.title,), bold, True))
cells_to_draw.extend((row, regular, False) for row in section.rows)
lines: list[_TableLine] = []
for cells, font, is_title in cells_to_draw:
cell_widths = [total_width] if is_title else widths
wrapped = [
wrap_text(text, font, font_size, max(1.0, cell_widths[col] - 2 * pad_x))
for col, text in enumerate(cells)
]
row_height = max(len(w) for w in wrapped) * line_height + 2 * pad_y
lines.append((wrapped, font, is_title, row_height))
return widths, lines
def _draw_table(
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
) -> None:
"""Tableau dans le cadre du champ : pour chaque section, une ligne
d'en-tête en gras sur toute la largeur (le jour), puis ses lignes ;
filets à la couleur du champ, texte de chaque cellule replié dans sa
colonne (`wrap_text`). Une ligne qui ne tient plus dans la hauteur du
cadre n'est pas dessinée, ni les suivantes — comme les lignes d'un champ
texte — sauf avec `auto_fit`, qui réduit la taille du texte jusqu'à ce
que toutes les lignes tiennent (plancher `_AUTO_FIT_MIN_PT`)."""
table = f.table
if table is None or not table.sections:
return
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
width_pt, height_pt = f.width_mm * mm, f.height_mm * mm
font_size = f.font_size
widths, lines_to_draw = _table_layout(table, width_pt, font_size)
if f.auto_fit:
while (
sum(line[3] for line in lines_to_draw) > height_pt + 0.01
and font_size - _AUTO_FIT_STEP_PT >= _AUTO_FIT_MIN_PT
):
font_size -= _AUTO_FIT_STEP_PT
widths, lines_to_draw = _table_layout(table, width_pt, font_size)
line_height = font_size * _LINE_HEIGHT_FACTOR
total_width = sum(widths)
left = (x0_mm + f.x_mm) * mm
top = (y0_mm + badge_height_mm - f.y_mm) * mm
bottom_limit = top - height_pt
color = HexColor(f.color)
c.setFillColor(color)
c.setStrokeColor(color)
c.setLineWidth(_TABLE_LINE_WIDTH_PT)
y = top
for wrapped, font, is_title, row_height in lines_to_draw:
cell_widths = [total_width] if is_title else widths
if y - row_height < bottom_limit - 0.01:
break # la ligne déborderait du cadre du champ
c.setFont(font, font_size)
x = left
for col, lines in enumerate(wrapped):
for i, line in enumerate(lines):
baseline = y - pad_y - font_size * 0.85 - i * line_height
c.drawString(x + pad_x, baseline, line)
x += cell_widths[col]
if not is_title:
# Séparations de colonnes, sur la hauteur de cette ligne seulement.
x = left
for width in widths[:-1]:
x += width
c.line(x, y, x, y - row_height)
y -= row_height
c.line(left, y, left + total_width, y)
if y == top:
return # pas même le premier en-tête ne tient
c.rect(left, y, total_width, top - y, stroke=1, fill=0)
def _lines_that_fit(font_size: float, height_mm: float) -> int:
"""Nombre de lignes de texte qui tiennent dans `height_mm` (première
ligne de base à 0,85 × la taille sous le haut du cadre, puis un
interligne par ligne)."""
font_size_mm = font_size / mm
first = font_size_mm * 0.85
if first > height_mm + 1e-9:
return 0
return 1 + int((height_mm - first) / (font_size_mm * _LINE_HEIGHT_FACTOR) + 1e-9)
def fit_text(
text: str, font_name: str, font_size: float, width_mm: float, height_mm: float,
auto_fit: bool,
) -> tuple[float, list[str]]:
"""Taille retenue et lignes à imprimer d'un champ texte. Sans
`auto_fit` : la taille du champ, lignes en trop retirées. Avec : taille
réduite par pas de `_AUTO_FIT_STEP_PT` (plancher `_AUTO_FIT_MIN_PT`)
jusqu'à ce que toutes les lignes repliées tiennent dans le cadre."""
lines = wrap_text(text, font_name, font_size, width_mm * mm)
if auto_fit:
while (
len(lines) > _lines_that_fit(font_size, height_mm)
and font_size - _AUTO_FIT_STEP_PT >= _AUTO_FIT_MIN_PT
):
font_size -= _AUTO_FIT_STEP_PT
lines = wrap_text(text, font_name, font_size, width_mm * mm)
return font_size, lines[: _lines_that_fit(font_size, height_mm)]
def _draw_text(
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
) -> None:
font_name = "Helvetica-Bold" if f.bold else "Helvetica"
font_size, lines = fit_text(
f.text, font_name, f.font_size, f.width_mm, f.height_mm, f.auto_fit
)
if not lines:
return
c.setFont(font_name, font_size)
c.setFillColor(HexColor(f.color)) c.setFillColor(HexColor(f.color))
left_x = x0_mm + f.x_mm left_x = x0_mm + f.x_mm
# `f.font_size` est en points (unité reportlab) ; le reste du calcul # `font_size` est en points (unité reportlab) ; le reste du calcul
# travaille en mm ⇒ on convertit d'abord (1 pt = 1/mm mm, `mm` étant # travaille en mm ⇒ on convertit d'abord (1 pt = 1/mm mm, `mm` étant
# ici le facteur de conversion pt/mm de reportlab). # ici le facteur de conversion pt/mm de reportlab).
font_size_mm = f.font_size / mm font_size_mm = font_size / mm
line_height_mm = font_size_mm * _LINE_HEIGHT_FACTOR line_height_mm = font_size_mm * _LINE_HEIGHT_FACTOR
# `f.y_mm` est depuis le HAUT du badge (repère écran) ; reportlab # `f.y_mm` est depuis le HAUT du badge (repère écran) ; reportlab
# dessine depuis le BAS de la page. # dessine depuis le BAS de la page.
top_y = y0_mm + badge_height_mm - f.y_mm top_y = y0_mm + badge_height_mm - f.y_mm
bottom_limit = top_y - f.height_mm if f.valign == "MIDDLE":
for i, line in enumerate(f.text.split("\n")): # Hauteur du bloc : interlignes entre les lignes + une hauteur de
# caractère pour la dernière.
block = (len(lines) - 1) * line_height_mm + font_size_mm
top_y -= max(0.0, (f.height_mm - block) / 2)
for i, line in enumerate(lines):
baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm
if baseline_y < bottom_limit:
break # la ligne déborderait de la boîte du champ
if f.align == "CENTER": if f.align == "CENTER":
c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, line) c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, line)
elif f.align == "RIGHT": elif f.align == "RIGHT":
@@ -146,6 +511,25 @@ def _draw_fields(
c.drawString(left_x * mm, baseline_y * mm, line) c.drawString(left_x * mm, baseline_y * mm, line)
def _draw_fields(
c: canvas.Canvas,
fields: tuple[BadgeFieldSpec, ...],
x0_mm: float,
y0_mm: float,
badge_height_mm: float,
photos: PhotoCache,
) -> None:
for f in fields:
if f.is_photo:
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm, photos)
continue
_draw_zone_background(c, f, x0_mm, y0_mm, badge_height_mm)
if f.table is not None:
_draw_table(c, f, x0_mm, y0_mm, badge_height_mm)
else:
_draw_text(c, f, x0_mm, y0_mm, badge_height_mm)
def generate_badge_sheets_pdf( def generate_badge_sheets_pdf(
badges: list[BadgeSpec], badges: list[BadgeSpec],
front_image: bytes | None, front_image: bytes | None,
@@ -161,16 +545,18 @@ def generate_badge_sheets_pdf(
buffer = io.BytesIO() buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4) c = canvas.Canvas(buffer, pagesize=A4)
photos: PhotoCache = {}
for page_badges in pages: for page_badges in pages:
for i, badge in enumerate(page_badges): for i, badge in enumerate(page_badges):
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm) x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
_draw_background(c, front_image, x0, y0, badge_width_mm, badge_height_mm) _draw_background(c, front_image, x0, y0, badge_width_mm, badge_height_mm)
_draw_fields(c, badge.fields, x0, y0, badge_height_mm) _draw_fields(c, badge.fields, x0, y0, badge_height_mm, photos)
c.showPage() c.showPage()
for i in range(len(page_badges)): for i, badge in enumerate(page_badges):
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm) x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm, mirrored=True)
_draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm) _draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm)
_draw_fields(c, badge.back_fields, x0, y0, badge_height_mm, photos)
c.showPage() c.showPage()
c.save() c.save()
return buffer.getvalue() return buffer.getvalue()
+63 -10
View File
@@ -9,6 +9,8 @@ que dans le texte déjà journalisable). Pas de CSS externe ni de police web
en ligne, polices système en repli du style « Archivo »/« DM Mono ».""" en ligne, polices système en repli du style « Archivo »/« DM Mono »."""
import html as _html import html as _html
import re
from collections.abc import Mapping, Sequence
_INK = "#14110E" _INK = "#14110E"
_CREAM = "#F5EFE1" _CREAM = "#F5EFE1"
@@ -26,21 +28,72 @@ _FONT_MONO = "'Courier New', Courier, monospace"
LOGO_CID = "edition-logo" LOGO_CID = "edition-logo"
def _paragraphs(body: str) -> str: _P_OPEN = '<p style="margin:0 0 14px;">'
def _paragraphs(body: str, blocks: Mapping[str, str]) -> str:
"""`\n\n` sépare les paragraphes, un simple `\n` reste un retour à la """`\n\n` sépare les paragraphes, un simple `\n` reste un retour à la
ligne — même convention que les modèles texte (`app/email/templates.py`).""" ligne — même convention que les modèles texte (`app/email/templates.py`).
blocks = body.strip().split("\n\n") `blocks` : repère → HTML déjà construit (ex. tableau des créneaux),
return "".join( inséré tel quel à la place du repère, hors de tout paragraphe."""
f'<p style="margin:0 0 14px;">{_html.escape(block).replace(chr(10), "<br>")}</p>' out = []
for block in blocks for block in body.strip().split("\n\n"):
if block.strip() if not block.strip():
continue
html = _P_OPEN + _html.escape(block).replace(chr(10), "<br>") + "</p>"
for marker, fragment in blocks.items():
html = html.replace(_html.escape(marker), f"</p>{fragment}{_P_OPEN}")
# Paragraphes vidés par l'insertion d'un bloc (repère seul, ou
# entouré de retours à la ligne).
html = re.sub(rf"{re.escape(_P_OPEN)}(?:<br>|\s)*</p>", "", html)
out.append(html)
return "".join(out)
_CELL = f"padding:6px 8px;border:1px solid {_INK};vertical-align:top;"
def render_planning_table(
sections: Sequence[tuple[str, Sequence[tuple[str, str, str]]]],
headers: tuple[str, str, str],
) -> str:
"""Tableau des créneaux d'un bénévole, comme sur les badges : le jour
en en-tête sur toute la largeur, puis une ligne par créneau (horaire,
poste, référent). `sections` = `[(jour, [(horaire, poste, référent)])]`."""
head = "".join(
f'<th align="left" style="{_CELL}background:{_INK};color:{_CREAM};'
f'font-family:{_FONT_DISPLAY};font-size:11px;text-transform:uppercase;">'
f"{_html.escape(h)}</th>"
for h in headers
)
rows = [f"<tr>{head}</tr>"]
for day, lines in sections:
rows.append(
f'<tr><td colspan="3" style="{_CELL}background:{_YELLOW};'
f'font-family:{_FONT_DISPLAY};font-weight:800;">{_html.escape(day)}</td></tr>'
)
for time, position, referent in lines:
rows.append(
f'<tr><td style="{_CELL}white-space:nowrap;font-weight:700;">'
f"{_html.escape(time)}</td>"
f'<td style="{_CELL}font-family:{_FONT_DISPLAY};font-weight:700;">'
f"{_html.escape(position)}</td>"
f'<td style="{_CELL}">{_html.escape(referent).replace(chr(10), "<br>")}</td></tr>'
)
return (
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
f'style="border-collapse:collapse;margin:0 0 14px;font-size:12px;'
f'font-family:{_FONT_MONO};background:{_PAPER};">' + "".join(rows) + "</table>"
) )
def render_html(*, subject: str, body: str, has_logo: bool) -> str: def render_html(
*, subject: str, body: str, has_logo: bool, blocks: Mapping[str, str] | None = None
) -> str:
"""`has_logo` : voir `app/email/branding.py:edition_logo` — `False` tant """`has_logo` : voir `app/email/branding.py:edition_logo` — `False` tant
que l'édition n'a pas importé de logo, l'en-tête retombe alors sur un que l'édition n'a pas importé de logo, l'en-tête retombe alors sur un
simple bandeau texte (le sujet, en capitales).""" simple bandeau texte (le sujet, en capitales). `blocks` : voir
`_paragraphs`."""
if has_logo: if has_logo:
header = ( header = (
f'<img src="cid:{LOGO_CID}" alt="" width="160" ' f'<img src="cid:{LOGO_CID}" alt="" width="160" '
@@ -72,7 +125,7 @@ def render_html(*, subject: str, body: str, has_logo: bool) -> str:
<td style="background:{_PAPER};border:3px solid {_INK}; <td style="background:{_PAPER};border:3px solid {_INK};
border-top:none;padding:22px;font-size:13px; border-top:none;padding:22px;font-size:13px;
line-height:1.6;color:{_INK};font-family:{_FONT_MONO};"> line-height:1.6;color:{_INK};font-family:{_FONT_MONO};">
{_paragraphs(body)} {_paragraphs(body, blocks or {})}
</td> </td>
</tr> </tr>
</table> </table>
+54 -3
View File
@@ -8,7 +8,7 @@ Pour diagnostiquer : `python -m app.email.check <destinataire>`."""
import logging import logging
import smtplib import smtplib
import ssl import ssl
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Mapping, Sequence
from email.message import EmailMessage from email.message import EmailMessage
from app.config import Settings, get_settings from app.config import Settings, get_settings
@@ -44,8 +44,15 @@ def build_message(
*, *,
logo: tuple[bytes, str] | None = None, logo: tuple[bytes, str] | None = None,
attachments: Sequence[Attachment] = (), attachments: Sequence[Attachment] = (),
html_body: str | None = None,
html_blocks: Mapping[str, str] | None = None,
) -> EmailMessage: ) -> EmailMessage:
"""`logo` = (octets, content-type) (voir `app/email/branding.py: """`html_body` / `html_blocks` : corps de la version HTML quand il
diffère du texte — repères remplacés par des blocs HTML déjà construits
(ex. tableau des créneaux, `app/email/html.py:render_planning_table`) ;
le texte brut garde sa propre version lisible.
`logo` = (octets, content-type) (voir `app/email/branding.py:
edition_logo`) : ajoute une version HTML au thème de l'appli edition_logo`) : ajoute une version HTML au thème de l'appli
(`app/email/html.py`) en alternative au texte brut — le texte reste la (`app/email/html.py`) en alternative au texte brut — le texte reste la
version de référence, envoyée telle quelle si aucun client ne préfère le version de référence, envoyée telle quelle si aucun client ne préfère le
@@ -61,7 +68,12 @@ def build_message(
message["Subject"] = subject message["Subject"] = subject
message.set_content(body) message.set_content(body)
message.add_alternative( message.add_alternative(
render_html(subject=subject, body=body, has_logo=logo is not None), render_html(
subject=subject,
body=html_body if html_body is not None else body,
has_logo=logo is not None,
blocks=html_blocks,
),
subtype="html", subtype="html",
) )
if logo is not None: if logo is not None:
@@ -145,6 +157,39 @@ def deliver_many(messages: list[EmailMessage]) -> None:
logger.info("Emails remis : %d/%d", sent, len(messages)) logger.info("Emails remis : %d/%d", sent, len(messages))
def deliver_test(message: EmailMessage) -> tuple[str, str | None]:
"""Envoi d'un email de test, **synchrone** et sans « best effort » :
renvoie `(statut, détail)` au lieu de seulement journaliser, pour que la
coordination voie tout de suite ce qui ne va pas (`POST
.../notifications/test`). Statuts : `sent`, `disabled`
(`EMAIL_ENABLED=false`), `connection_failed` (connexion ou
authentification), `rejected` (le serveur a refusé le message). Détail :
message du serveur ou type d'erreur — jamais l'adresse du destinataire
(`_smtp_error_summary`)."""
settings = get_settings()
if not settings.email_enabled:
logger.info("Email de test non envoyé (EMAIL_ENABLED=false)")
return "disabled", None
try:
smtp = open_smtp(settings)
except smtplib.SMTPException as exc:
logger.error("Email de test : connexion SMTP refusée : %s", _smtp_error_summary(exc))
return "connection_failed", _smtp_error_summary(exc)
except OSError as exc:
logger.error("Email de test : SMTP injoignable : %s", exc)
return "connection_failed", f"{exc.__class__.__name__} : {exc}"
with smtp:
try:
smtp.send_message(message)
except smtplib.SMTPException as exc:
logger.error(
"Email de test refusé (From=%s) : %s", message["From"], _smtp_error_summary(exc)
)
return "rejected", _smtp_error_summary(exc)
logger.info("Email de test remis")
return "sent", None
def send( def send(
to: str, to: str,
subject: str, subject: str,
@@ -156,6 +201,12 @@ def send(
deliver_many([build_message(to, subject, body, logo=logo, attachments=attachments)]) deliver_many([build_message(to, subject, body, logo=logo, attachments=attachments)])
def send_messages(messages: list[EmailMessage]) -> None:
"""Remet des messages déjà construits (`build_message`) — ex. le
récapitulatif de planning, dont la version HTML porte un tableau."""
deliver_many(messages)
def send_many( def send_many(
items: Iterable[tuple[str, str, str]], *, logo: tuple[bytes, str] | None = None items: Iterable[tuple[str, str, str]], *, logo: tuple[bytes, str] | None = None
) -> None: ) -> None:
+21
View File
@@ -24,15 +24,20 @@ _HEADER = [
"Artistes souhaités", "Artistes souhaités",
"Compagnons souhaités", "Compagnons souhaités",
"Note de compagnon", "Note de compagnon",
"Mode de paiement",
"Cotisation choisie", "Cotisation choisie",
"Montant (€)", "Montant (€)",
"Statut du paiement", "Statut du paiement",
"Payée le", "Payée le",
"Transaction SumUp (code)",
"Transaction SumUp (identifiant)",
"Mécénat — adresse", "Mécénat — adresse",
"Mécénat — ville", "Mécénat — ville",
"Mécénat — code postal", "Mécénat — code postal",
"Mécénat — pays", "Mécénat — pays",
"Commentaire", "Commentaire",
"Texte du badge",
"Nombre de badges",
"Inscrit le", "Inscrit le",
] ]
@@ -56,6 +61,17 @@ def _safe_text(value: str) -> str:
return f"'{value}" if value.startswith(_FORMULA_PREFIXES) else value return f"'{value}" if value.startswith(_FORMULA_PREFIXES) else value
def _payment_mode(v: VolunteerRead) -> str:
"""En ligne (un checkout SumUp a été créé), sur place (« je participe à
la cotisation » sans paiement en ligne), ou rien — même règle que
`frontend/src/lib/fee.ts:feeMode`."""
if v.fee_payment_status is not None:
return "En ligne"
if v.willing_to_pay_fee:
return "Sur place"
return ""
def _yes_no(value: bool) -> str: def _yes_no(value: bool) -> str:
return "Oui" if value else "Non" return "Oui" if value else "Non"
@@ -97,15 +113,20 @@ def generate_volunteers_xlsx(
_join_names(v.artist_ids, artist_names), _join_names(v.artist_ids, artist_names),
_join_names(v.wished_companion_ids, volunteer_names), _join_names(v.wished_companion_ids, volunteer_names),
_safe_text(v.companion_wish_note or ""), _safe_text(v.companion_wish_note or ""),
_payment_mode(v),
_safe_text(v.fee_payment_label or ""), _safe_text(v.fee_payment_label or ""),
v.fee_amount if v.fee_amount is not None else "", v.fee_amount if v.fee_amount is not None else "",
_FEE_STATUS_LABELS.get(v.fee_payment_status, ""), _FEE_STATUS_LABELS.get(v.fee_payment_status, ""),
_fmt_dt(v.fee_paid_at), _fmt_dt(v.fee_paid_at),
_safe_text(v.fee_transaction_code or ""),
_safe_text(v.fee_transaction_id or ""),
_safe_text(v.mecenat_address or ""), _safe_text(v.mecenat_address or ""),
_safe_text(v.mecenat_city or ""), _safe_text(v.mecenat_city or ""),
_safe_text(v.mecenat_postal_code or ""), _safe_text(v.mecenat_postal_code or ""),
_safe_text(v.mecenat_country or ""), _safe_text(v.mecenat_country or ""),
_safe_text(v.comment or ""), _safe_text(v.comment or ""),
_safe_text(v.badge_text or ""),
v.badge_count,
_fmt_dt(v.created_at), _fmt_dt(v.created_at),
] ]
) )
+21 -7
View File
@@ -43,9 +43,6 @@
"import.row.updated_existing": "Bénévole existant mis à jour.", "import.row.updated_existing": "Bénévole existant mis à jour.",
"import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l'édition.", "import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l'édition.",
"validation.SHIFT_OVERLAP": "Chevauchement avec un autre créneau du bénévole.", "validation.SHIFT_OVERLAP": "Chevauchement avec un autre créneau du bénévole.",
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées le {date} ({uncovered_minutes} min non couvertes).",
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h d'affectation cumulées le {date}.",
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux.",
"notification.no_recipients": "Aucun bénévole ciblé.", "notification.no_recipients": "Aucun bénévole ciblé.",
"email.magic_link.subject": "Ton lien de connexion", "email.magic_link.subject": "Ton lien de connexion",
"email.magic_link.body": "Bonjour,\n\nVoici ton lien de connexion, valable {minutes} minutes :\n\n{link}\n\nSi tu n'es pas à l'origine de cette demande, ignore simplement cet email.", "email.magic_link.body": "Bonjour,\n\nVoici ton lien de connexion, valable {minutes} minutes :\n\n{link}\n\nSi tu n'es pas à l'origine de cette demande, ignore simplement cet email.",
@@ -56,20 +53,36 @@
"email.registration_confirmation_paid.mecenat_receipt_note": "Tu trouveras en pièce jointe ton reçu fiscal au titre des dons (cerfa n° 11580*05), à conserver pour ta déclaration de revenus.", "email.registration_confirmation_paid.mecenat_receipt_note": "Tu trouveras en pièce jointe ton reçu fiscal au titre des dons (cerfa n° 11580*05), à conserver pour ta déclaration de revenus.",
"email.planning.default_subject": "Ton planning bénévole", "email.planning.default_subject": "Ton planning bénévole",
"email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !", "email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !",
"email.test.subject_prefix": "[Test] {subject}",
"email.test.example_first_name": "Camille",
"email.test.example_last_name": "Exemple",
"notifications.invalid_email": "Adresse email invalide.",
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.", "email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
"email.planning.shift_line": "- {position} : {start} → {end}", "email.planning.table.time": "Horaire",
"availability.date_outside_edition": "La date {date} est hors des dates de l'édition.", "email.planning.table.position": "Poste",
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes.", "email.planning.table.referent": "Référent",
"email.planning.table.no_referent": "à désigner",
"email.planning.table.text_line": " {time} · {position} · référent : {referent}",
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.", "badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
"badge.image_unreadable": "L'image est illisible ou corrompue.", "badge.image_unreadable": "L'image est illisible ou corrompue.",
"badge.empty_image": "L'image est vide.", "badge.empty_image": "L'image est vide.",
"badge.image_too_large": "L'image est trop volumineuse.", "badge.image_too_large": "L'image est trop volumineuse.",
"badge.duplicate_field_type": "Le champ « {field_type} » ne peut apparaître qu'une fois.", "badge.duplicate_field_type": "Le champ « {field_type} » ne peut apparaître qu'une fois.",
"badge.invalid_size": "La taille du badge doit être strictement positive.", "badge.invalid_size": "La taille du badge doit être strictement positive.",
"badge.no_fields": "Aucun champ n'est configuré sur le recto du badge.", "badge.invalid_color": "La couleur « {color} » n'est pas valide (format attendu : #rrggbb).",
"badge.no_fields": "Aucun champ n'est configuré sur le badge.",
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.", "badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
"badge.role.referent": "Référent", "badge.role.referent": "Référent",
"badge.role.volunteer": "Bénévole", "badge.role.volunteer": "Bénévole",
"position_sheets.footer_title": "Message de l'organisation",
"badge.table.day_label": "{weekday} {day}",
"badge.weekday.0": "Lundi",
"badge.weekday.1": "Mardi",
"badge.weekday.2": "Mercredi",
"badge.weekday.3": "Jeudi",
"badge.weekday.4": "Vendredi",
"badge.weekday.5": "Samedi",
"badge.weekday.6": "Dimanche",
"position.category.bar": "Bar", "position.category.bar": "Bar",
"position.category.ticketing": "Billetterie", "position.category.ticketing": "Billetterie",
"position.category.welcome": "Accueil", "position.category.welcome": "Accueil",
@@ -83,6 +96,7 @@
"form.captcha_failed": "Vérification anti-robot invalide, réessayez.", "form.captcha_failed": "Vérification anti-robot invalide, réessayez.",
"form.invalid_payload": "Données du formulaire invalides.", "form.invalid_payload": "Données du formulaire invalides.",
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.", "form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
"volunteer.empty_photo": "Le fichier de la photo est vide.",
"form.photo_too_large": "La photo est trop volumineuse.", "form.photo_too_large": "La photo est trop volumineuse.",
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.", "form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
"form.logo_empty": "Le logo est vide.", "form.logo_empty": "Le logo est vide.",
+26 -1
View File
@@ -1,4 +1,5 @@
import logging import logging
from contextvars import ContextVar
from app.config import get_settings from app.config import get_settings
@@ -7,6 +8,19 @@ from app.config import get_settings
# téléphone dans les logs. On journalise des identifiants (UUID/entier) et des # téléphone dans les logs. On journalise des identifiants (UUID/entier) et des
# codes. Toute nouvelle instruction de log doit respecter cette règle. # codes. Toute nouvelle instruction de log doit respecter cette règle.
# Identifiant de la requête HTTP en cours (voir `app/request_logging.py`),
# repris sur chaque ligne de log émise pendant son traitement — y compris
# dans ses tâches de fond (`BackgroundTasks`, même contexte) — pour retrouver
# d'un `grep` tout ce qui s'est passé lors de l'erreur qu'un utilisateur
# signale (la référence lui est affichée, voir `frontend/.../state.tsx`).
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
class RequestIdFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = request_id_var.get()
return True
def configure_logging() -> None: def configure_logging() -> None:
# `force=True` : on réinstalle le handler racine même si l'import de # `force=True` : on réinstalle le handler racine même si l'import de
@@ -14,6 +28,17 @@ def configure_logging() -> None:
# sinon les logs `app.*` n'apparaissent pas dans la sortie du conteneur. # sinon les logs `app.*` n'apparaissent pas dans la sortie du conteneur.
logging.basicConfig( logging.basicConfig(
level=get_settings().log_level, level=get_settings().log_level,
format="%(asctime)s %(levelname)s %(name)s %(message)s", format="%(asctime)s %(levelname)s [%(request_id)s] %(name)s %(message)s",
force=True, force=True,
) )
# Filtre posé sur le handler (pas sur un logger) : il voit aussi les
# lignes des bibliothèques (uvicorn, sqlalchemy…) propagées à la racine.
for handler in logging.getLogger().handlers:
handler.addFilter(RequestIdFilter())
# Journal d'accès d'uvicorn remplacé par `app/request_logging.py` : le
# sien écrit l'URL complète, chaîne de requête comprise — donc le jeton
# `access_token` du formulaire public (CLAUDE.md § 2, `FormTemplate`).
logging.getLogger("uvicorn.access").disabled = True
# Bavard au niveau INFO (une ligne par appel SumUp/hCaptcha), sans
# intérêt à côté des logs de l'appli.
logging.getLogger("httpx").setLevel(logging.WARNING)
+5 -2
View File
@@ -7,7 +7,6 @@ from app.api.accounts import router as account_router
from app.api.artists import router as artist_router from app.api.artists import router as artist_router
from app.api.assignments import router as assignment_router from app.api.assignments import router as assignment_router
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
from app.api.availability import router as availability_router
from app.api.badges import router as badge_router from app.api.badges import router as badge_router
from app.api.day_parts import router as day_part_router from app.api.day_parts import router as day_part_router
from app.api.editions import router as edition_router from app.api.editions import router as edition_router
@@ -20,9 +19,11 @@ from app.api.position_sheets import router as position_sheets_router
from app.api.positions import router as position_router from app.api.positions import router as position_router
from app.api.shifts import router as shift_router from app.api.shifts import router as shift_router
from app.api.solver import router as solver_router from app.api.solver import router as solver_router
from app.api.stats import router as stats_router
from app.api.volunteers import router as volunteer_router from app.api.volunteers import router as volunteer_router
from app.config import get_settings from app.config import get_settings
from app.logging_config import configure_logging from app.logging_config import configure_logging
from app.request_logging import REQUEST_ID_HEADER, install_request_logging
configure_logging() configure_logging()
@@ -56,7 +57,9 @@ app.add_middleware(
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
expose_headers=[REQUEST_ID_HEADER],
) )
install_request_logging(app)
app.include_router(health_router) app.include_router(health_router)
app.include_router(auth_router) app.include_router(auth_router)
@@ -66,12 +69,12 @@ app.include_router(account_router)
app.include_router(import_router) app.include_router(import_router)
app.include_router(artist_router) app.include_router(artist_router)
app.include_router(volunteer_router) app.include_router(volunteer_router)
app.include_router(availability_router)
app.include_router(position_router) app.include_router(position_router)
app.include_router(shift_router) app.include_router(shift_router)
app.include_router(assignment_router) app.include_router(assignment_router)
app.include_router(solver_router) app.include_router(solver_router)
app.include_router(matrix_router) app.include_router(matrix_router)
app.include_router(stats_router)
app.include_router(notification_router) app.include_router(notification_router)
app.include_router(badge_router) app.include_router(badge_router)
app.include_router(form_router) app.include_router(form_router)
@@ -0,0 +1,55 @@
"""Suppression des disponibilités déclarées
Revision ID: 0022_drop_availability
Revises: 0021_payment_option_free_amount
Create Date: 2026-10-05
Les fenêtres de disponibilité (`availability`) disparaissent, avec la règle
`OUTSIDE_AVAILABILITY` qui les exploitait. Les données existantes sont
perdues : le `downgrade` recrée la table vide. La case « je suis
disponible » du formulaire public (`volunteer.self_declared_available`)
n'est pas concernée.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0022_drop_availability"
down_revision: str | None = "0021_payment_option_free_amount"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.drop_index(op.f("ix_availability_volunteer_id"), table_name="availability")
op.drop_index(op.f("ix_availability_edition_id"), table_name="availability")
op.drop_table("availability")
def downgrade() -> None:
op.create_table(
"availability",
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
sa.Column("date", sa.Date(), nullable=False),
sa.Column("start_time", sa.Time(), nullable=False),
sa.Column("end_time", sa.Time(), nullable=False),
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"volunteer_id", "date", "start_time", "end_time",
name="uq_availability_window",
),
)
op.create_index(
op.f("ix_availability_edition_id"), "availability", ["edition_id"], unique=False
)
op.create_index(
op.f("ix_availability_volunteer_id"), "availability", ["volunteer_id"], unique=False
)
@@ -0,0 +1,40 @@
"""Transaction SumUp de la cotisation
Revision ID: 0023_fee_transaction
Revises: 0022_drop_availability
Create Date: 2026-10-12
`volunteer.fee_transaction_code` (code affiché dans le tableau de bord SumUp,
ex. `TEENSK4W2K`) et `fee_transaction_id` (identifiant technique) : la
transaction qui a réglé la cotisation, posée à la confirmation du paiement —
voir `app/api/forms.py:_promote_pending`. Les bénévoles déjà payés restent
à `NULL`.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0023_fee_transaction"
down_revision: str | None = "0022_drop_availability"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"volunteer", sa.Column("fee_transaction_code", sa.String(), nullable=True)
)
op.create_index(
"ix_volunteer_fee_transaction_code", "volunteer", ["fee_transaction_code"]
)
op.add_column(
"volunteer", sa.Column("fee_transaction_id", sa.String(), nullable=True)
)
def downgrade() -> None:
op.drop_column("volunteer", "fee_transaction_id")
op.drop_index("ix_volunteer_fee_transaction_code", table_name="volunteer")
op.drop_column("volunteer", "fee_transaction_code")
@@ -0,0 +1,30 @@
"""Texte libre du badge
Revision ID: 0024_volunteer_badge_text
Revises: 0023_fee_transaction
Create Date: 2026-10-27
`volunteer.badge_text` : texte propre à chaque bénévole, saisi par la
coordination sur sa fiche et imprimé sur le badge par le champ
`FREE_TEXT`. Les nouveaux types de champ de badge (`PHOTO`, `FREE_TEXT`)
tiennent dans la colonne `badge_field.field_type` existante (VARCHAR sans
contrainte CHECK, longueur 9 inchangée) : rien à migrer de ce côté.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0024_volunteer_badge_text"
down_revision: str | None = "0023_fee_transaction"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("volunteer", sa.Column("badge_text", sa.String(), nullable=True))
def downgrade() -> None:
op.drop_column("volunteer", "badge_text")
@@ -0,0 +1,28 @@
"""Texte commun du champ libre des badges
Revision ID: 0025_badge_field_text
Revises: 0024_volunteer_badge_text
Create Date: 2026-10-27
`badge_field.text` : texte du champ `FREE_TEXT` saisi dans l'éditeur de
badges, imprimé sur tous les badges — sauf ceux dont la fiche porte un
`volunteer.badge_text`, qui le remplace pour ce bénévole.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0025_badge_field_text"
down_revision: str | None = "0024_volunteer_badge_text"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("badge_field", sa.Column("text", sa.String(), nullable=True))
def downgrade() -> None:
op.drop_column("badge_field", "text")
@@ -0,0 +1,62 @@
"""Éditeur de badges v2 (verso, fond de zone, auto-ajustement, photo ovale)
Revision ID: 0026_badge_editor_v2
Revises: 0025_badge_field_text
Create Date: 2026-10-27
`badge_field.face` : face du badge (`FRONT`/`BACK`) — les champs existants
deviennent des champs du recto. La contrainte d'unicité passe de
(`edition_id`, `field_type`) à (`edition_id`, `face`, `field_type`) : un même
champ peut figurer sur les deux faces. `bg_color` (fond de la zone,
transparent si `NULL`), `auto_fit` (réduction automatique du texte) et
`photo_shape` (`RECT`/`OVAL`, champ `PHOTO`).
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0026_badge_editor_v2"
down_revision: str | None = "0025_badge_field_text"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_FACE = sa.Enum("FRONT", "BACK", name="badgeface", native_enum=False)
_PHOTO_SHAPE = sa.Enum("RECT", "OVAL", name="badgephotoshape", native_enum=False)
def upgrade() -> None:
op.add_column(
"badge_field",
sa.Column("face", _FACE, server_default="FRONT", nullable=False),
)
op.add_column("badge_field", sa.Column("bg_color", sa.String(), nullable=True))
op.add_column(
"badge_field",
sa.Column("auto_fit", sa.Boolean(), server_default="false", nullable=False),
)
op.add_column(
"badge_field",
sa.Column("photo_shape", _PHOTO_SHAPE, server_default="RECT", nullable=False),
)
op.drop_constraint("uq_badge_field_edition_type", "badge_field", type_="unique")
op.create_unique_constraint(
"uq_badge_field_edition_face_type",
"badge_field",
["edition_id", "face", "field_type"],
)
def downgrade() -> None:
# Le verso n'existait pas : ses champs sont perdus (sinon l'ancienne
# contrainte d'unicité échouerait sur un champ présent sur les deux faces).
op.execute("DELETE FROM badge_field WHERE face = 'BACK'")
op.drop_constraint("uq_badge_field_edition_face_type", "badge_field", type_="unique")
op.create_unique_constraint(
"uq_badge_field_edition_type", "badge_field", ["edition_id", "field_type"]
)
op.drop_column("badge_field", "photo_shape")
op.drop_column("badge_field", "auto_fit")
op.drop_column("badge_field", "bg_color")
op.drop_column("badge_field", "face")
@@ -0,0 +1,35 @@
"""Nombre de badges par bénévole
Revision ID: 0027_volunteer_badge_count
Revises: 0026_badge_editor_v2
Create Date: 2026-10-27
`volunteer.badge_count` : nombre d'exemplaires de son badge imprimés sur les
planches (`POST .../badges/generate`), 1 par défaut — les bénévoles existants
gardent un seul badge.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0027_volunteer_badge_count"
down_revision: str | None = "0026_badge_editor_v2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"volunteer",
sa.Column("badge_count", sa.Integer(), nullable=False, server_default="1"),
)
op.create_check_constraint(
"ck_volunteer_badge_count_positive", "volunteer", "badge_count >= 1"
)
def downgrade() -> None:
op.drop_constraint("ck_volunteer_badge_count_positive", "volunteer", type_="check")
op.drop_column("volunteer", "badge_count")
@@ -0,0 +1,45 @@
"""Ordre d'empilement des champs du badge
Revision ID: 0028_badge_field_z_order
Revises: 0027_volunteer_badge_count
Create Date: 2026-10-27
`badge_field.z_order` : les champs d'une face sont dessinés du plus petit au
plus grand. Avant, ils l'étaient dans l'ordre alphabétique de leur type, et
la photo recouvrait le nom et le texte libre. Les champs existants sont
numérotés photo d'abord (dessous), puis les autres par type.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0028_badge_field_z_order"
down_revision: str | None = "0027_volunteer_badge_count"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"badge_field",
sa.Column("z_order", sa.Integer(), nullable=False, server_default="0"),
)
op.execute(
"""
UPDATE badge_field AS b SET z_order = ranked.rank
FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY edition_id, face
ORDER BY (field_type = 'PHOTO') DESC, field_type
) - 1 AS rank
FROM badge_field
) AS ranked
WHERE b.id = ranked.id
"""
)
def downgrade() -> None:
op.drop_column("badge_field", "z_order")
@@ -0,0 +1,37 @@
"""Messages aux bénévoles
Revision ID: 0029_volunteer_messages
Revises: 0028_badge_field_z_order
Create Date: 2026-10-28
`edition.volunteer_message` : message de l'organisation, le même pour tous
les bénévoles, saisi par la coordination. `position.message` : message du
poste (consignes…), saisi par son référent, montré aux bénévoles affectés.
Chacun avec la date de sa dernière modification (`*_updated_at`).
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0029_volunteer_messages"
down_revision: str | None = "0028_badge_field_z_order"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("edition", sa.Column("volunteer_message", sa.String(), nullable=True))
op.add_column(
"edition", sa.Column("volunteer_message_updated_at", sa.DateTime(), nullable=True)
)
op.add_column("position", sa.Column("message", sa.String(), nullable=True))
op.add_column("position", sa.Column("message_updated_at", sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column("position", "message_updated_at")
op.drop_column("position", "message")
op.drop_column("edition", "volunteer_message_updated_at")
op.drop_column("edition", "volunteer_message")
@@ -0,0 +1,34 @@
"""Pointage des bénévoles
Revision ID: 0030_assignment_attendance
Revises: 0029_volunteer_messages
Create Date: 2026-10-28
`assignment.attendance` (`PRESENT`/`ABSENT`, `NULL` = pas pointé) et
`attendance_marked_at` : le pointage du référent était gardé dans le
navigateur seulement et se perdait au rechargement.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0030_assignment_attendance"
down_revision: str | None = "0029_volunteer_messages"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_ATTENDANCE = sa.Enum("PRESENT", "ABSENT", name="attendancestatus", native_enum=False)
def upgrade() -> None:
op.add_column("assignment", sa.Column("attendance", _ATTENDANCE, nullable=True))
op.add_column(
"assignment", sa.Column("attendance_marked_at", sa.DateTime(), nullable=True)
)
def downgrade() -> None:
op.drop_column("assignment", "attendance_marked_at")
op.drop_column("assignment", "attendance")
@@ -0,0 +1,31 @@
"""Lien de connexion permanent
Revision ID: 0031_account_login_link_version
Revises: 0030_assignment_attendance
Create Date: 2026-10-28
`account.login_link_version` : version du lien de connexion permanent du
récapitulatif de planning (`app/auth/permanent_link.py`) — l'incrémenter
révoque tous les liens déjà envoyés à ce compte.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0031_account_login_link_version"
down_revision: str | None = "0030_assignment_attendance"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"account",
sa.Column("login_link_version", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("account", "login_link_version")
+6 -2
View File
@@ -9,7 +9,6 @@ from app.models.domain import (
Account, Account,
Artist, Artist,
Assignment, Assignment,
Availability,
BadgeField, BadgeField,
BadgeTemplate, BadgeTemplate,
DayPart, DayPart,
@@ -28,8 +27,11 @@ from app.models.domain import (
) )
from app.models.enums import ( from app.models.enums import (
AssignmentSource, AssignmentSource,
AttendanceStatus,
BadgeAlign, BadgeAlign,
BadgeFace,
BadgeFieldType, BadgeFieldType,
BadgePhotoShape,
FeePaymentStatus, FeePaymentStatus,
FormFieldType, FormFieldType,
PositionCategory, PositionCategory,
@@ -41,10 +43,12 @@ __all__ = [
"Artist", "Artist",
"Assignment", "Assignment",
"AssignmentSource", "AssignmentSource",
"Availability", "AttendanceStatus",
"BadgeAlign", "BadgeAlign",
"BadgeFace",
"BadgeField", "BadgeField",
"BadgeFieldType", "BadgeFieldType",
"BadgePhotoShape",
"BadgeTemplate", "BadgeTemplate",
"DayPart", "DayPart",
"Edition", "Edition",
+89 -43
View File
@@ -17,8 +17,11 @@ from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
from app.models.base import TimestampMixin from app.models.base import TimestampMixin
from app.models.enums import ( from app.models.enums import (
AssignmentSource, AssignmentSource,
AttendanceStatus,
BadgeAlign, BadgeAlign,
BadgeFace,
BadgeFieldType, BadgeFieldType,
BadgePhotoShape,
FeePaymentStatus, FeePaymentStatus,
FormFieldType, FormFieldType,
PositionCategory, PositionCategory,
@@ -90,6 +93,11 @@ class Edition(TimestampMixin, table=True):
end_date: dt.date end_date: dt.date
# Durée de rétention des données nominatives, en jours. None = pas de purge. # Durée de rétention des données nominatives, en jours. None = pas de purge.
retention_days: int | None = None retention_days: int | None = None
# Message de l'organisation, le même pour tous les bénévoles (écran
# bénévole) — saisi par la coordination. `_updated_at` : date affichée
# sous le message, posée par l'API à chaque changement de texte.
volunteer_message: str | None = None
volunteer_message_updated_at: dt.datetime | None = None
day_parts: list["DayPart"] = Relationship( day_parts: list["DayPart"] = Relationship(
back_populates="edition", sa_relationship_kwargs=_PASSIVE back_populates="edition", sa_relationship_kwargs=_PASSIVE
@@ -160,6 +168,10 @@ class Artist(TimestampMixin, table=True):
class Volunteer(TimestampMixin, table=True): class Volunteer(TimestampMixin, table=True):
__table_args__ = (
CheckConstraint("badge_count >= 1", name="ck_volunteer_badge_count_positive"),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field( edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE" foreign_key="edition.id", index=True, ondelete="CASCADE"
@@ -183,9 +195,17 @@ class Volunteer(TimestampMixin, table=True):
# cette note sur la fiche et la transforme, si besoin, en un vrai # cette note sur la fiche et la transforme, si besoin, en un vrai
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits. # `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
companion_wish_note: str | None = None companion_wish_note: str | None = None
# Texte du badge propre à ce bénévole (champ `BadgeFieldType.FREE_TEXT`),
# saisi par la coordination sur la fiche — ex. « Accès backstage ». Vide :
# le texte commun de l'éditeur (`BadgeField.text`) s'applique.
badge_text: str | None = None
# Nombre d'exemplaires de son badge à imprimer sur les planches (badge
# perdu, plusieurs accréditations…) — 1 par défaut.
badge_count: int = Field(
default=1, sa_column=Column(Integer, nullable=False, server_default="1")
)
# Cases à cocher du formulaire public — purement informatives : aucune # Cases à cocher du formulaire public — purement informatives : aucune
# logique n'y est attachée (pas de lien avec `Availability`, pas de # logique n'y est attachée (pas de workflow de paiement).
# workflow de paiement).
self_declared_available: bool = Field( self_declared_available: bool = Field(
default=False, default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"), sa_column=Column(Boolean, nullable=False, server_default="false"),
@@ -212,6 +232,13 @@ class Volunteer(TimestampMixin, table=True):
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True), sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
) )
fee_paid_at: dt.datetime | None = None fee_paid_at: dt.datetime | None = None
# Transaction SumUp qui a réglé la cotisation (`app/sumup/client.py:
# SumUpCheckout`), posée à la confirmation du paiement : le code court
# (`TEENSK4W2K`) est celui du tableau de bord SumUp et de ses reçus —
# rapprochement comptable paiement ↔ bénévole. Exposés (fiche, export
# Excel), contrairement à `fee_checkout_id`.
fee_transaction_code: str | None = Field(default=None, index=True)
fee_transaction_id: str | None = None
# Coordonnées collectées uniquement pour un type de paiement « mécénat » # Coordonnées collectées uniquement pour un type de paiement « mécénat »
# (`FormPaymentOption.requires_mecenat_info`), avant le paiement (voir # (`FormPaymentOption.requires_mecenat_info`), avant le paiement (voir
# `PendingFormSubmission` et `app/api/forms.py:submit_mecenat_info`) — # `PendingFormSubmission` et `app/api/forms.py:submit_mecenat_info`) —
@@ -237,9 +264,6 @@ class Volunteer(TimestampMixin, table=True):
link_model=VolunteerPositionWish, link_model=VolunteerPositionWish,
sa_relationship_kwargs=_PASSIVE, sa_relationship_kwargs=_PASSIVE,
) )
availabilities: list["Availability"] = Relationship(
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
)
assignments: list["Assignment"] = Relationship( assignments: list["Assignment"] = Relationship(
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
) )
@@ -270,6 +294,12 @@ class Account(TimestampMixin, table=True):
nullable=True, nullable=True,
) )
# Version du lien de connexion permanent (`app/auth/permanent_link.py`) :
# l'incrémenter révoque tous les liens déjà envoyés à ce compte.
login_link_version: int = Field(
default=0, sa_column=Column(Integer, nullable=False, server_default="0")
)
@property @property
def auth_method(self) -> str: def auth_method(self) -> str:
return "password" if self.password_hash else "magic_link" return "password" if self.password_hash else "magic_link"
@@ -308,6 +338,10 @@ class Position(TimestampMixin, table=True):
ondelete="SET NULL", ondelete="SET NULL",
nullable=True, nullable=True,
) )
# Message du poste (consignes…), saisi par son référent ou la
# coordination, montré aux bénévoles affectés au poste.
message: str | None = None
message_updated_at: dt.datetime | None = None
shifts: list["Shift"] = Relationship( shifts: list["Shift"] = Relationship(
back_populates="position", sa_relationship_kwargs=_PASSIVE back_populates="position", sa_relationship_kwargs=_PASSIVE
@@ -352,43 +386,11 @@ class Shift(TimestampMixin, table=True):
) )
class Availability(TimestampMixin, table=True):
"""Déclaration du bénévole : « tel jour, je peux venir de telle à telle
heure ». Fenêtre horaire libre (pas liée aux créneaux types). Plusieurs
fenêtres par jour possibles.
Heures naïves, locales Europe/Paris. `end_time <= start_time` ⇒ la fenêtre
passe minuit (se termine le lendemain). À ne pas confondre avec Assignment
(décision de l'organisation).
"""
__table_args__ = (
UniqueConstraint(
"volunteer_id", "date", "start_time", "end_time",
name="uq_availability_window",
),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE"
)
volunteer_id: uuid.UUID = Field(
foreign_key="volunteer.id", index=True, ondelete="CASCADE"
)
date: dt.date
start_time: dt.time
end_time: dt.time
volunteer: Volunteer = Relationship(back_populates="availabilities")
class Assignment(TimestampMixin, table=True): class Assignment(TimestampMixin, table=True):
"""Décision de l'organisation : « tu tiens tel créneau ». """Décision de l'organisation : « tu tiens tel créneau ».
Existe ⇒ le bénévole est affecté (pas d'étape de confirmation). Retirer une Existe ⇒ le bénévole est affecté (pas d'étape de confirmation). Retirer une
affectation, c'est supprimer la ligne. À ne pas confondre avec Availability affectation, c'est supprimer la ligne.
(déclaration du bénévole).
""" """
__table_args__ = ( __table_args__ = (
@@ -419,6 +421,14 @@ class Assignment(TimestampMixin, table=True):
default=False, default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"), sa_column=Column(Boolean, nullable=False, server_default="false"),
) )
# Pointage par le référent du poste (ou la coordination) : `None` tant
# que personne n'a pointé. `attendance_marked_at` : heure du dernier
# pointage, affichée sur l'écran référent.
attendance: AttendanceStatus | None = Field(
default=None,
sa_column=Column(SAEnum(AttendanceStatus, native_enum=False), nullable=True),
)
attendance_marked_at: dt.datetime | None = None
volunteer: Volunteer = Relationship(back_populates="assignments") volunteer: Volunteer = Relationship(back_populates="assignments")
shift: Shift = Relationship(back_populates="assignments") shift: Shift = Relationship(back_populates="assignments")
@@ -454,16 +464,17 @@ class BadgeTemplate(TimestampMixin, table=True):
class BadgeField(TimestampMixin, table=True): class BadgeField(TimestampMixin, table=True):
"""Un champ positionné sur le recto du badge (nom, poste(s), catégorie, """Un champ positionné sur une face du badge (`face` : recto ou verso —
rôle — voir `BadgeFieldType`). Coordonnées en millimètres depuis le coin nom, poste(s), catégorie, rôle… voir `BadgeFieldType`). Coordonnées en
haut-gauche du badge (format CR80, 85,6 × 54 mm — voir `app/badges/pdf.py`). millimètres depuis le coin haut-gauche du badge (voir `app/badges/pdf.py`).
Au plus un champ par `field_type` et par édition : décocher un champ dans Au plus un champ par `field_type` et par face pour une édition (le même
champ peut figurer au recto et au verso) : décocher un champ dans
l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`).""" l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`)."""
__tablename__ = "badge_field" __tablename__ = "badge_field"
__table_args__ = ( __table_args__ = (
UniqueConstraint( UniqueConstraint(
"edition_id", "field_type", name="uq_badge_field_edition_type" "edition_id", "face", "field_type", name="uq_badge_field_edition_face_type"
), ),
) )
@@ -471,6 +482,14 @@ class BadgeField(TimestampMixin, table=True):
edition_id: uuid.UUID = Field( edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE" foreign_key="edition.id", index=True, ondelete="CASCADE"
) )
face: BadgeFace = Field(
default=BadgeFace.FRONT,
sa_column=Column(
SAEnum(BadgeFace, native_enum=False),
nullable=False,
server_default=BadgeFace.FRONT.value,
),
)
field_type: BadgeFieldType = Field( field_type: BadgeFieldType = Field(
sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False) sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False)
) )
@@ -492,6 +511,33 @@ class BadgeField(TimestampMixin, table=True):
sa_column=Column(Boolean, nullable=False, server_default="false"), sa_column=Column(Boolean, nullable=False, server_default="false"),
) )
color: str = Field(default="#000000") color: str = Field(default="#000000")
# Champ `FREE_TEXT` seulement : texte saisi dans l'éditeur, imprimé sur
# tous les badges — sauf ceux dont la fiche porte un « Texte du badge »
# (`Volunteer.badge_text`), qui le remplace pour ce bénévole.
text: str | None = None
# Fond de la zone du champ (`#rrggbb`) ; `None` = transparent. Sans
# effet sur le champ `PHOTO`.
bg_color: str | None = None
# Réduit la taille du texte (par pas de 0,5 pt) jusqu'à ce que tout
# tienne dans le cadre, au lieu de tronquer les lignes en trop.
auto_fit: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
# Champ `PHOTO` seulement : rectangle ou ellipse inscrite dans le cadre.
photo_shape: BadgePhotoShape = Field(
default=BadgePhotoShape.RECT,
sa_column=Column(
SAEnum(BadgePhotoShape, native_enum=False),
nullable=False,
server_default=BadgePhotoShape.RECT.value,
),
)
# Ordre d'empilement sur sa face : les champs sont dessinés du plus petit
# au plus grand, un champ recouvre donc ceux d'un `z_order` inférieur.
z_order: int = Field(
default=0, sa_column=Column(Integer, nullable=False, server_default="0")
)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+30 -1
View File
@@ -18,6 +18,13 @@ class AssignmentSource(enum.StrEnum):
MANUAL = "MANUAL" MANUAL = "MANUAL"
class AttendanceStatus(enum.StrEnum):
"""Pointage d'un bénévole sur son créneau, par le référent du poste."""
PRESENT = "PRESENT"
ABSENT = "ABSENT"
class PositionCategory(enum.StrEnum): class PositionCategory(enum.StrEnum):
BAR = "BAR" BAR = "BAR"
TICKETING = "TICKETING" TICKETING = "TICKETING"
@@ -31,12 +38,34 @@ class PositionCategory(enum.StrEnum):
class BadgeFieldType(enum.StrEnum): class BadgeFieldType(enum.StrEnum):
"""Champ affichable sur le recto d'un badge (voir `app/badges/`).""" """Champ affichable sur le recto ou le verso d'un badge (voir `app/badges/`)."""
FULL_NAME = "FULL_NAME" FULL_NAME = "FULL_NAME"
POSITIONS = "POSITIONS" POSITIONS = "POSITIONS"
CATEGORY = "CATEGORY" CATEGORY = "CATEGORY"
ROLE = "ROLE" ROLE = "ROLE"
# Photo du bénévole (`Volunteer.photo`), ajustée au cadre du champ.
PHOTO = "PHOTO"
# Texte libre saisi par la coordination dans l'éditeur (`BadgeField.text`,
# commun à tous les badges), remplacé pour un bénévole par le « Texte du
# badge » de sa fiche (`Volunteer.badge_text`) s'il en a un.
FREE_TEXT = "FREE_TEXT"
class BadgeFace(enum.StrEnum):
"""Face du badge sur laquelle un champ est imprimé : le verso est
personnalisable comme le recto (même champ possible sur les deux)."""
FRONT = "FRONT"
BACK = "BACK"
class BadgePhotoShape(enum.StrEnum):
"""Forme de découpe du champ `PHOTO` (cadre rectangulaire ou ellipse
inscrite dans le cadre)."""
RECT = "RECT"
OVAL = "OVAL"
class BadgeAlign(enum.StrEnum): class BadgeAlign(enum.StrEnum):
+130
View File
@@ -0,0 +1,130 @@
"""Journal des requêtes HTTP — une ligne par requête, pour diagnostiquer
après coup une erreur rencontrée par un utilisateur (voir CLAUDE.md § 8,
RGPD : identifiants et codes seulement).
- Chaque requête reçoit un identifiant (`X-Request-ID`, repris s'il est
fourni et bien formé, généré sinon), renvoyé dans l'en-tête de la réponse
et préfixé à toutes les lignes de log émises pendant son traitement
(`app/logging_config.py:request_id_var`).
- La ligne d'accès donne la route **modèle** (`/editions/{edition_id}/…`) et
ses paramètres d'identifiants, jamais l'URL brute : la chaîne de requête
peut porter un jeton (`access_token` du formulaire public), et les
paramètres de chemin nommés `*token*` (inscription en attente) sont omis.
- Code d'erreur métier (`detail.code`) et compte connecté ajoutés quand ils
existent ; détails d'une erreur de validation 422 (emplacement + type,
jamais la valeur saisie).
- Une exception non gérée est journalisée avec sa trace et renvoie un 500
JSON `{detail: {code: "error.internal", request_id}}` plutôt qu'un texte
brut.
"""
import logging
import re
import time
import uuid
from fastapi import FastAPI, HTTPException, Request
from fastapi.exception_handlers import (
http_exception_handler,
request_validation_exception_handler,
)
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.logging_config import request_id_var
logger = logging.getLogger("app.request")
REQUEST_ID_HEADER = "X-Request-ID"
_INCOMING_ID = re.compile(r"^[A-Za-z0-9-]{8,64}$")
# Sondes de santé (Docker, Caddy) : pas une ligne INFO toutes les 10 s.
_QUIET_PATHS = {"/health"}
def _route_label(request: Request) -> str:
route = request.scope.get("route")
template = getattr(route, "path", None) or "<non routé>"
params = {
key: value
for key, value in (request.scope.get("path_params") or {}).items()
if "token" not in key
}
if not params:
return template
return f"{template} " + " ".join(f"{k}={v}" for k, v in params.items())
def install_request_logging(app: FastAPI) -> None:
@app.middleware("http")
async def log_requests(request: Request, call_next):
incoming = request.headers.get(REQUEST_ID_HEADER, "")
request_id = incoming if _INCOMING_ID.match(incoming) else uuid.uuid4().hex[:12]
reset = request_id_var.set(request_id)
started = time.monotonic()
try:
try:
response = await call_next(request)
except Exception:
logger.exception(
"Exception non gérée sur %s %s", request.method, _route_label(request)
)
response = JSONResponse(
status_code=500,
content={"detail": {"code": "error.internal", "request_id": request_id}},
)
duration_ms = (time.monotonic() - started) * 1000
response.headers[REQUEST_ID_HEADER] = request_id
status_code = response.status_code
if status_code >= 500:
level = logging.ERROR
elif status_code >= 400:
level = logging.WARNING
elif request.url.path in _QUIET_PATHS or request.method == "OPTIONS":
level = logging.DEBUG
else:
level = logging.INFO
extras = []
error_code = getattr(request.state, "error_code", None)
if error_code:
extras.append(f"code={error_code}")
account_id = getattr(request.state, "account_id", None)
if account_id:
extras.append(f"compte={account_id}")
logger.log(
level,
"%s %s → %d (%d ms)%s",
request.method,
_route_label(request),
status_code,
duration_ms,
(" " + " ".join(extras)) if extras else "",
)
return response
finally:
request_id_var.reset(reset)
@app.exception_handler(StarletteHTTPException)
async def log_http_exception(request: Request, exc: HTTPException):
detail = exc.detail
if isinstance(detail, dict) and detail.get("code"):
request.state.error_code = detail["code"]
violations = detail.get("violations")
if violations:
# Codes de violation seulement (`SHIFT_OVERLAP`…) : les
# paramètres peuvent porter des horaires, pas utiles ici.
request.state.error_code += " violations=" + ",".join(
sorted({str(v.get("code")) for v in violations if isinstance(v, dict)})
)
return await http_exception_handler(request, exc)
@app.exception_handler(RequestValidationError)
async def log_validation_error(request: Request, exc: RequestValidationError):
# Emplacement + type d'erreur, jamais `input` (la valeur saisie, qui
# peut être un nom, un email…).
request.state.error_code = "validation " + "; ".join(
f"{'.'.join(str(p) for p in err.get('loc', ()))}:{err.get('type')}"
for err in exc.errors()[:10]
)
return await request_validation_exception_handler(request, exc)
+3 -163
View File
@@ -9,9 +9,8 @@ Déterministe (graine fixe). Aucune donnée nominative réelle.
import datetime as dt import datetime as dt
import random import random
from collections import Counter from collections import Counter
from dataclasses import dataclass, field from dataclasses import dataclass
from sqlalchemy import delete
from sqlmodel import Session, select from sqlmodel import Session, select
from app.auth.passwords import hash_password from app.auth.passwords import hash_password
@@ -22,7 +21,6 @@ from app.models import (
Artist, Artist,
Assignment, Assignment,
AssignmentSource, AssignmentSource,
Availability,
DayPart, DayPart,
Edition, Edition,
FeePaymentStatus, FeePaymentStatus,
@@ -35,14 +33,7 @@ from app.models import (
VolunteerCompanionWish, VolunteerCompanionWish,
VolunteerPositionWish, VolunteerPositionWish,
) )
from app.validation import ( from app.validation import Severity, validate
AvailabilityWindow,
DayPartWindow,
Severity,
ShiftInfo,
ValidationInput,
validate,
)
from app.validation.loader import build_validation_input from app.validation.loader import build_validation_input
SEED = 20260709 SEED = 20260709
@@ -227,7 +218,6 @@ class Summary:
companion_wishes: int = 0 companion_wishes: int = 0
fee_paid_classique: int = 0 fee_paid_classique: int = 0
fee_paid_mecenat: int = 0 fee_paid_mecenat: int = 0
warnings: Counter = field(default_factory=Counter)
blocking: int = 0 blocking: int = 0
def __str__(self) -> str: def __str__(self) -> str:
@@ -245,10 +235,7 @@ class Summary:
f" Vœux de compagnon déclarés : {self.companion_wishes}", f" Vœux de compagnon déclarés : {self.companion_wishes}",
f" Cotisation payée (classique) : {self.fee_paid_classique}", f" Cotisation payée (classique) : {self.fee_paid_classique}",
f" Cotisation payée (mécénat) : {self.fee_paid_mecenat}", f" Cotisation payée (mécénat) : {self.fee_paid_mecenat}",
f" Avertissements actifs : {sum(self.warnings.values())}",
] ]
for code, count in sorted(self.warnings.items()):
lines.append(f" {code} : {count}")
lines.append(f" Violations bloquantes : {self.blocking}") lines.append(f" Violations bloquantes : {self.blocking}")
lines.append("") lines.append("")
lines.append( lines.append(
@@ -309,9 +296,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
] ]
session.add_all(day_parts) session.add_all(day_parts)
session.flush() session.flush()
dp_windows = tuple(
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order) for dp in day_parts
)
coordination = Account( coordination = Account(
edition_id=edition.id, edition_id=edition.id,
@@ -428,27 +412,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
) )
) )
n_companion_wishes += 1 n_companion_wishes += 1
# Disponibilités : chaque bénévole déclare, pour ~80 % des jours, une
# large fenêtre couvrant la journée et la nuit (06:00 → 04:00 le lendemain).
_FULL_DAY = (dt.time(6, 0), dt.time(4, 0))
availabilities: dict[object, list[AvailabilityWindow]] = {}
for volunteer in volunteers:
windows: list[AvailabilityWindow] = []
for day in dates:
if rng.random() < 0.8:
start, end = _FULL_DAY
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=day,
start_time=start,
end_time=end,
)
)
windows.append(AvailabilityWindow(day, start, end))
availabilities[volunteer.id] = windows
shifts: list[Shift] = [] shifts: list[Shift] = []
for position, (_n, category, _l, _r) in zip(positions, POSITIONS, strict=True): for position, (_n, category, _l, _r) in zip(positions, POSITIONS, strict=True):
for day in dates: for day in dates:
@@ -470,28 +433,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
assigned: dict[object, list[Shift]] = {v.id: [] for v in volunteers} assigned: dict[object, list[Shift]] = {v.id: [] for v in volunteers}
# Trois bénévoles réservés aux scénarios d'avertissement : exclus de
# l'affectation générale pour rester libres.
reserved = volunteers[:3]
reserved_ids = {v.id for v in reserved}
assignable = [v for v in volunteers if v.id not in reserved_ids]
def _clean(volunteer: Volunteer, shift: Shift) -> bool:
data = ValidationInput(
candidate_shift=ShiftInfo(
id=str(shift.id),
starts_at=shift.starts_at,
ends_at=shift.ends_at,
),
existing_shifts=tuple(
ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at)
for s in assigned[volunteer.id]
),
availabilities=tuple(availabilities[volunteer.id]),
day_parts=dp_windows,
)
return not validate(data)
n_assignments = 0 n_assignments = 0
order = shifts[:] order = shifts[:]
rng.shuffle(order) rng.shuffle(order)
@@ -500,7 +441,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
target = max(0, shift.min_staff - 1) target = max(0, shift.min_staff - 1)
else: else:
target = rng.randint(shift.min_staff, shift.ideal_staff) target = rng.randint(shift.min_staff, shift.ideal_staff)
pool = assignable[:] pool = volunteers[:]
rng.shuffle(pool) rng.shuffle(pool)
placed = 0 placed = 0
for volunteer in pool: for volunteer in pool:
@@ -508,8 +449,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
break break
if any(_overlap(shift, s) for s in assigned[volunteer.id]): if any(_overlap(shift, s) for s in assigned[volunteer.id]):
continue continue
if not _clean(volunteer, shift):
continue
session.add( session.add(
Assignment( Assignment(
edition_id=edition.id, edition_id=edition.id,
@@ -522,9 +461,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
placed += 1 placed += 1
n_assignments += 1 n_assignments += 1
n_assignments += _add_demo_warnings(
session, edition, shifts, reserved, assigned, availabilities
)
n_fee_classique, n_fee_mecenat = _add_demo_payments(session, volunteers, rng) n_fee_classique, n_fee_mecenat = _add_demo_payments(session, volunteers, rng)
session.flush() session.flush()
@@ -558,74 +494,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
return summary return summary
def _clear_day(session, volunteer, day, availabilities) -> None:
session.execute(
delete(Availability)
.where(Availability.volunteer_id == volunteer.id)
.where(Availability.date == day)
)
availabilities[volunteer.id] = [
w for w in availabilities[volunteer.id] if w.date != day
]
def _declare_window(
session, edition, volunteer, day, start, end, availabilities
) -> None:
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=day,
start_time=start,
end_time=end,
)
)
availabilities[volunteer.id].append(AvailabilityWindow(day, start, end))
def _add_demo_warnings(
session, edition, shifts, reserved, assigned, availabilities
) -> int:
"""Quelques affectations qui déclenchent des avertissements (et rien
d'autre) : hors disponibilité, repos insuffisant. `reserved` = trois
bénévoles tenus à l'écart de l'affectation générale.
"""
a, b, tired = reserved
added = 0
# OUTSIDE_AVAILABILITY : deux bénévoles affectés un jour où ils n'ont
# déclaré aucune disponibilité.
plain = sorted(shifts, key=lambda s: s.starts_at)
for volunteer, shift in ((a, plain[3]), (b, plain[7])):
_clear_day(session, volunteer, shift.starts_at.date(), availabilities)
_add_active(session, edition, volunteer, shift, assigned)
added += 1
# INSUFFICIENT_REST : deux créneaux espacés de moins de 8 h, sur deux
# journées (pour ne pas déclencher le cumul), tous deux couverts par une
# disponibilité.
pair = _find_rest_pair(shifts)
if pair is not None:
first, second = pair
for shift in (first, second):
_clear_day(session, tired, shift.starts_at.date(), availabilities)
_declare_window(
session,
edition,
tired,
shift.starts_at.date(),
dt.time(6, 0),
dt.time(4, 0),
availabilities,
)
_add_active(session, edition, tired, first, assigned)
_add_active(session, edition, tired, second, assigned)
added += 2
return added
def _add_demo_payments( def _add_demo_payments(
session: Session, volunteers: list[Volunteer], rng: random.Random session: Session, volunteers: list[Volunteer], rng: random.Random
) -> tuple[int, int]: ) -> tuple[int, int]:
@@ -664,32 +532,6 @@ def _add_demo_payments(
return len(classique), len(mecenat) return len(classique), len(mecenat)
def _find_rest_pair(shifts: list[Shift]) -> tuple[Shift, Shift] | None:
plain = sorted(shifts, key=lambda s: s.starts_at)
for i, first in enumerate(plain):
for second in plain[i + 1 :]:
gap = second.starts_at - first.ends_at
if (
first.ends_at <= second.starts_at
and dt.timedelta(0) < gap < dt.timedelta(hours=8)
and first.starts_at.date() != second.starts_at.date()
):
return first, second
return None
def _add_active(session, edition, volunteer, shift, assigned) -> None:
session.add(
Assignment(
edition_id=edition.id,
volunteer_id=volunteer.id,
shift_id=shift.id,
source=AssignmentSource.MANUAL,
)
)
assigned[volunteer.id].append(shift)
def _verify(session: Session, edition: Edition, summary: Summary) -> None: def _verify(session: Session, edition: Edition, summary: Summary) -> None:
session.flush() session.flush()
assignments = session.exec( assignments = session.exec(
@@ -709,8 +551,6 @@ def _verify(session: Session, edition: Edition, summary: Summary) -> None:
): ):
if violation.severity is Severity.BLOCKING: if violation.severity is Severity.BLOCKING:
summary.blocking += 1 summary.blocking += 1
else:
summary.warnings[violation.code] += 1
def main() -> None: def main() -> None:
+156 -31
View File
@@ -12,12 +12,14 @@ bénévole, une feuille identifie un poste pour la journée.
import datetime as dt import datetime as dt
import io import io
from dataclasses import dataclass from dataclasses import dataclass
from xml.sax.saxutils import escape
from PIL import Image as PILImage
from PIL import ImageOps, UnidentifiedImageError
from reportlab.lib import colors from reportlab.lib import colors
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.platypus import ( from reportlab.platypus import (
Image, Image,
PageBreak, PageBreak,
@@ -28,7 +30,12 @@ from reportlab.platypus import (
TableStyle, TableStyle,
) )
_PHOTO_BOX_MM = 14 * mm # Mise en page compacte (une feuille = une page autant que possible) :
# vignette de 10 mm, deux bénévoles par ligne.
_PHOTO_BOX_MM = 10 * mm
# Côté de la vignette en pixels : 10 mm à 300 dpi.
_PHOTO_BOX_PX = round(10 / 25.4 * 300)
_PER_ROW = 2
_WEEKDAYS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"] _WEEKDAYS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
_MONTHS_FR = [ _MONTHS_FR = [
@@ -90,69 +97,187 @@ def _styles() -> dict[str, ParagraphStyle]:
base = getSampleStyleSheet() base = getSampleStyleSheet()
return { return {
"title": ParagraphStyle( "title": ParagraphStyle(
"SheetTitle", parent=base["Title"], alignment=0, fontSize=22, spaceAfter=2 "SheetTitle", parent=base["Title"], alignment=0,
fontSize=18, leading=21, spaceBefore=0, spaceAfter=1,
), ),
"subtitle": ParagraphStyle( "subtitle": ParagraphStyle(
"SheetSubtitle", parent=base["Normal"], "SheetSubtitle", parent=base["Normal"],
fontSize=12, textColor=colors.grey, spaceAfter=4, fontSize=10.5, leading=13, textColor=colors.grey, spaceAfter=2,
), ),
"referent": ParagraphStyle( "referent": ParagraphStyle(
"SheetReferent", parent=base["Normal"], "SheetReferent", parent=base["Normal"],
fontSize=11.5, fontName="Helvetica-Bold", spaceAfter=6, fontSize=10.5, leading=13, fontName="Helvetica-Bold", spaceAfter=3,
), ),
"artists": ParagraphStyle( "artists": ParagraphStyle(
"SheetArtists", parent=base["Normal"], "SheetArtists", parent=base["Normal"],
fontSize=9.5, textColor=colors.HexColor("#4a4a4a"), spaceAfter=16, fontSize=8.5, leading=10.5, textColor=colors.HexColor("#4a4a4a"), spaceAfter=4,
), ),
"shiftHeader": ParagraphStyle( "shiftHeader": ParagraphStyle(
"ShiftHeader", parent=base["Heading3"], fontSize=13, spaceBefore=12, spaceAfter=4 "ShiftHeader", parent=base["Heading3"],
fontSize=11, leading=13, spaceBefore=7, spaceAfter=3,
), ),
"shiftHeaderWarning": ParagraphStyle( "shiftHeaderWarning": ParagraphStyle(
"ShiftHeaderWarning", parent=base["Heading3"], "ShiftHeaderWarning", parent=base["Heading3"],
fontSize=13, spaceBefore=12, spaceAfter=4, textColor=colors.HexColor("#b23a1f"), fontSize=11, leading=13, spaceBefore=7, spaceAfter=3,
textColor=colors.HexColor("#b23a1f"),
),
"volunteerName": ParagraphStyle(
"SheetVolunteerName", parent=base["Normal"],
fontName="Helvetica-Bold", fontSize=9.5, leading=11.5,
),
"volunteerPhone": ParagraphStyle(
"SheetVolunteerPhone", parent=base["Normal"], fontSize=9, leading=11,
),
"footerTitle": ParagraphStyle(
"SheetFooterTitle", parent=base["Normal"],
fontName="Helvetica-Bold", fontSize=8.5, leading=11,
),
"footer": ParagraphStyle(
"SheetFooter", parent=base["Normal"], fontSize=8.5, leading=11,
), ),
"normal": base["Normal"], "normal": base["Normal"],
} }
def _photo_thumbnail(photo: bytes) -> Image: @dataclass(frozen=True)
"""Vignette carrée (14 mm de côté max), proportions préservées.""" class SheetFooter:
width_px, height_px = ImageReader(io.BytesIO(photo)).getSize() """Message imprimé en bas de chaque page (message de l'organisation,
`Edition.volunteer_message`) : `title` en gras, puis `message` —
retours à la ligne conservés."""
title: str
message: str
_MARGIN_X = 15 * mm
_MARGIN_TOP = 12 * mm
_MARGIN_BOTTOM = 12 * mm
# Écart entre le bas du contenu et le filet du pied de page.
_FOOTER_GAP = 5 * mm
def _footer_paragraphs(footer: SheetFooter, styles: dict) -> list[Paragraph]:
message = escape(footer.message).replace("\n", "<br/>")
return [
Paragraph(escape(footer.title), styles["footerTitle"]),
Paragraph(message, styles["footer"]),
]
def _thumbnail(photo: bytes) -> tuple[bytes, int, int] | None:
"""Photo réduite à la taille de la vignette (orientation EXIF appliquée),
en JPEG : `(octets, largeur, hauteur)`, `None` si illisible. La photo
d'origine (souvent plusieurs Mo, prise au téléphone) intégrée telle
quelle pour chaque bénévole de chaque créneau faisait tourner la
génération plusieurs minutes et produisait un PDF énorme — jusqu'à
l'erreur (bug corrigé une fois)."""
try:
with PILImage.open(io.BytesIO(photo)) as img:
# JPEG : décodage directement à taille réduite (bien plus rapide).
img.draft("RGB", (_PHOTO_BOX_PX, _PHOTO_BOX_PX))
thumb = ImageOps.exif_transpose(img).convert("RGB")
thumb.thumbnail((_PHOTO_BOX_PX, _PHOTO_BOX_PX))
except (UnidentifiedImageError, OSError, ValueError):
return None
out = io.BytesIO()
thumb.save(out, format="JPEG", quality=85)
return out.getvalue(), thumb.width, thumb.height
def _photo_cell(photo: bytes | None, cache: dict[bytes, tuple[bytes, int, int] | None]):
"""Vignette carrée (14 mm de côté max), proportions préservées. Chaque
photo n'est réduite qu'une fois par PDF (`cache`), même si le bénévole
figure sur plusieurs créneaux."""
if not photo:
return ""
if photo not in cache:
cache[photo] = _thumbnail(photo)
thumb = cache[photo]
if thumb is None:
return ""
data, width_px, height_px = thumb
scale = _PHOTO_BOX_MM / max(width_px, height_px) scale = _PHOTO_BOX_MM / max(width_px, height_px)
return Image(io.BytesIO(photo), width=width_px * scale, height=height_px * scale) return Image(io.BytesIO(data), width=width_px * scale, height=height_px * scale)
def _shift_table(shift: SheetShift) -> Table: def _shift_table(
rows = [["", "Bénévole", "Téléphone"]] + [ shift: SheetShift,
[_photo_thumbnail(v.photo) if v.photo else "", v.name, v.phone or "—"] cache: dict[bytes, tuple[bytes, int, int] | None],
styles: dict[str, ParagraphStyle],
width: float,
) -> Table:
"""Bénévoles du créneau, deux par ligne : vignette, puis nom (gras) et
téléphone l'un sous l'autre. Deux fois moins de lignes qu'une liste à
une personne par ligne (avant l'étape 65, souvent deux pages par
feuille)."""
photo_w = _PHOTO_BOX_MM + 3 * mm
text_w = width / _PER_ROW - photo_w
cells = [
[
_photo_cell(v.photo, cache),
[
Paragraph(escape(v.name), styles["volunteerName"]),
Paragraph(escape(v.phone or "—"), styles["volunteerPhone"]),
],
]
for v in shift.volunteers for v in shift.volunteers
] ]
table = Table(rows, colWidths=[18 * mm, 92 * mm, 55 * mm]) rows = []
table.setStyle( for i in range(0, len(cells), _PER_ROW):
TableStyle( chunk = cells[i : i + _PER_ROW]
[ chunk += [["", ""]] * (_PER_ROW - len(chunk))
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), rows.append([cell for pair in chunk for cell in pair])
("FONTSIZE", (0, 0), (-1, -1), 10.5), table = Table(rows, colWidths=[photo_w, text_w] * _PER_ROW)
("TOPPADDING", (0, 0), (-1, -1), 5), style = [
("BOTTOMPADDING", (0, 0), (-1, -1), 5), ("TOPPADDING", (0, 0), (-1, -1), 2),
("BOTTOMPADDING", (0, 0), (-1, -1), 2),
("LEFTPADDING", (0, 0), (-1, -1), 3),
("RIGHTPADDING", (0, 0), (-1, -1), 3),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LINEBELOW", (0, 0), (-1, 0), 0.75, colors.black), ("LINEBELOW", (0, 0), (-1, -1), 0.25, colors.lightgrey),
("GRID", (0, 1), (-1, -1), 0.25, colors.lightgrey), ("LINEABOVE", (0, 0), (-1, 0), 0.25, colors.lightgrey),
] ]
) # Filet vertical entre les deux bénévoles d'une même ligne.
) for col in range(2, 2 * _PER_ROW, 2):
style.append(("LINEBEFORE", (col, 0), (col, -1), 0.25, colors.lightgrey))
table.setStyle(TableStyle(style))
return table return table
def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes: def generate_position_sheets_pdf(
sheets: list[PositionDaySheet], footer: SheetFooter | None = None
) -> bytes:
"""`footer` : imprimé en bas de **chaque** page (y compris la suite
d'une feuille sur une seconde page) ; la marge du bas s'agrandit de sa
hauteur pour que le contenu ne passe jamais dessous."""
styles = _styles() styles = _styles()
buffer = io.BytesIO() buffer = io.BytesIO()
width = A4[0] - 2 * _MARGIN_X
paragraphs = _footer_paragraphs(footer, styles) if footer else []
footer_height = sum(p.wrap(width, A4[1])[1] for p in paragraphs)
doc = SimpleDocTemplate( doc = SimpleDocTemplate(
buffer, pagesize=A4, buffer, pagesize=A4,
leftMargin=18 * mm, rightMargin=18 * mm, topMargin=16 * mm, bottomMargin=16 * mm, leftMargin=_MARGIN_X, rightMargin=_MARGIN_X, topMargin=_MARGIN_TOP,
bottomMargin=_MARGIN_BOTTOM + (footer_height + _FOOTER_GAP if paragraphs else 0),
) )
def draw_footer(canvas, _doc) -> None:
if not paragraphs:
return
canvas.saveState()
top = _MARGIN_BOTTOM + footer_height
canvas.setStrokeColor(colors.black)
canvas.setLineWidth(0.75)
canvas.line(_MARGIN_X, top + 2 * mm, _MARGIN_X + width, top + 2 * mm)
y = top
for p in paragraphs:
_, h = p.wrap(width, A4[1])
y -= h
p.drawOn(canvas, _MARGIN_X, y)
canvas.restoreState()
story = [] story = []
thumbnails: dict[bytes, tuple[bytes, int, int] | None] = {}
for i, sheet in enumerate(sheets): for i, sheet in enumerate(sheets):
if i > 0: if i > 0:
story.append(PageBreak()) story.append(PageBreak())
@@ -190,10 +315,10 @@ def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes:
style = styles["shiftHeaderWarning"] if shift.understaffed else styles["shiftHeader"] style = styles["shiftHeaderWarning"] if shift.understaffed else styles["shiftHeader"]
story.append(Paragraph(header, style)) story.append(Paragraph(header, style))
if shift.volunteers: if shift.volunteers:
story.append(_shift_table(shift)) story.append(_shift_table(shift, thumbnails, styles, width))
else: else:
story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"])) story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"]))
story.append(Spacer(1, 0)) story.append(Spacer(1, 0))
doc.build(story) doc.build(story, onFirstPage=draw_footer, onLaterPages=draw_footer)
return buffer.getvalue() return buffer.getvalue()
+25 -47
View File
@@ -5,18 +5,16 @@ effectif idéal, une liste ordonnée de bénévoles candidats. C'est la
coordination qui affecte (dans l'app, via l'écran habituel — le solveur se coordination qui affecte (dans l'app, via l'écran habituel — le solveur se
contente de faire remonter ces candidats en tête de liste). contente de faire remonter ces candidats en tête de liste).
Contrainte dure : aucun chevauchement de créneaux pour un même bénévole (règle Contrainte dure : jamais deux suggestions qui se chevauchent pour un même
`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (disponibilité, repos, bénévole dans un même calcul. Un bénévole déjà affecté à un autre poste au
chaînage entre créneaux consécutifs, vœu de poste, vœu de compagnon, vœu même moment reste proposable (la validation ne fait qu'avertir,
d'artiste, équité de charge) est un terme de l'objectif, pas une contrainte — `SHIFT_OVERLAP`) mais avec un fort malus. Tout le reste (chaînage entre
comme le moteur de validation, qui reste seul juge au moment où la créneaux consécutifs, vœu de poste, vœu de compagnon, vœu d'artiste, équité de
coordination confirme une affectation. charge) est un terme de l'objectif, pas une contrainte. Aucune limite d'heures
Pas de plafond de cumul journalier côté solveur : `DAILY_HOURS_EXCEEDED` par journée ni de temps de repos : ni le solveur ni la validation n'en
reste vérifiée par la validation, à la confirmation. tiennent compte.
""" """
import datetime as dt
from ortools.sat.python import cp_model from ortools.sat.python import cp_model
from app.solver.models import ( from app.solver.models import (
@@ -26,22 +24,16 @@ from app.solver.models import (
SolverSolution, SolverSolution,
SolverVolunteer, SolverVolunteer,
) )
from app.validation.engine import uncovered_minutes
from app.validation.models import ShiftInfo from app.validation.models import ShiftInfo
_EIGHT_HOURS_MIN = 8 * 60
# Poids de l'objectif (unités arbitraires, cf. docstring de `_score`). # Poids de l'objectif (unités arbitraires, cf. docstring de `_score`).
# L'écart à un effectif minimal non atteint prime sur tout le reste : le # L'écart à un effectif minimal non atteint prime sur tout le reste : le
# solveur doit toujours proposer quelqu'un pour un créneau sous-staffé, quitte # solveur doit toujours proposer quelqu'un pour un créneau sous-staffé, quitte
# à ce que la disponibilité soit imparfaite (avertissement, pas un blocage — # à ce que le candidat soit mal assorti. Au-delà du minimum, un candidat mal
# la validation reste seule juge à la confirmation). Au-delà du minimum, un # assorti peut légitimement ne pas être suggéré du tout.
# candidat mal assorti peut légitimement ne pas être suggéré du tout.
_URGENT_BASE = 1000 # créneau encore sous son effectif minimal _URGENT_BASE = 1000 # créneau encore sous son effectif minimal
_NORMAL_BASE = 20 # créneau entre effectif minimal et idéal _NORMAL_BASE = 20 # créneau entre effectif minimal et idéal
_WISH_BONUS = 30 _WISH_BONUS = 30
_AVAIL_PENALTY_PER_HALF_HOUR = 5
_REST_PENALTY = 60
_FAIRNESS_PENALTY_PER_HOUR = 2 _FAIRNESS_PENALTY_PER_HOUR = 2
# Le créneau lui ferait manquer le passage d'un artiste qu'il veut voir. # Le créneau lui ferait manquer le passage d'un artiste qu'il veut voir.
_ARTIST_CONFLICT_PENALTY = 80 _ARTIST_CONFLICT_PENALTY = 80
@@ -52,28 +44,17 @@ _CHAIN_PENALTY = 40
# Par compagnon souhaité déjà affecté au créneau (0 à N vœux par bénévole, # Par compagnon souhaité déjà affecté au créneau (0 à N vœux par bénévole,
# rendus réciproques par le loader — voir `SolverVolunteer.wished_companion_ids`). # rendus réciproques par le loader — voir `SolverVolunteer.wished_companion_ids`).
_COMPANION_BONUS_PER_MATCH = 25 _COMPANION_BONUS_PER_MATCH = 25
# Le bénévole est déjà affecté (affectation décidée) à un créneau qui
# chevauche celui-ci. Supérieur à tous les bonus cumulables et à la base
# normale : il n'est proposé que pour un créneau sous son minimum, et
# seulement faute de mieux.
_ALREADY_BUSY_PENALTY = 300
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool: def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
return a.starts_at < b.ends_at and b.starts_at < a.ends_at return a.starts_at < b.ends_at and b.starts_at < a.ends_at
def _has_insufficient_rest(volunteer: SolverVolunteer, shift: SolverShift) -> bool:
candidate = shift.shift_info
for other in volunteer.existing_shifts:
if _overlaps(candidate, other):
continue
if other.ends_at <= candidate.starts_at:
gap = candidate.starts_at - other.ends_at
elif candidate.ends_at <= other.starts_at:
gap = other.starts_at - candidate.ends_at
else:
continue
if gap < dt.timedelta(minutes=_EIGHT_HOURS_MIN):
return True
return False
def _chained_from_predecessor(volunteer: SolverVolunteer, shift: SolverShift) -> bool: def _chained_from_predecessor(volunteer: SolverVolunteer, shift: SolverShift) -> bool:
"""Le bénévole a déjà (affectation décidée) l'un des créneaux qui """Le bénévole a déjà (affectation décidée) l'un des créneaux qui
précèdent immédiatement celui-ci, le même jour — n'importe quel poste : précèdent immédiatement celui-ci, le même jour — n'importe quel poste :
@@ -88,20 +69,14 @@ def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int:
"""Score d'affinité (bénévole, créneau) : plus haut = meilleur candidat. """Score d'affinité (bénévole, créneau) : plus haut = meilleur candidat.
Purement additif (aucune interaction entre deux affectations proposées), Purement additif (aucune interaction entre deux affectations proposées),
ce qui garde le modèle CP-SAT linéaire simple. Repos, chaînage et ce qui garde le modèle CP-SAT linéaire simple. Chaînage et compagnons ne
compagnons ne sont vérifiés que contre les affectations déjà décidées, sont vérifiés que contre les affectations déjà décidées, pas entre deux
pas entre deux suggestions du même calcul : la validation finale (à la suggestions du même calcul (deux compagnons suggérés ensemble ne se
confirmation) reste seule juge en cas de cas limite entre deux renforcent pas mutuellement, faute d'interaction entre variables).
suggestions (et deux compagnons suggérés ensemble dans le même calcul ne
se renforcent pas mutuellement, faute d'interaction entre variables).
""" """
score = _URGENT_BASE if shift.already_assigned < shift.min_staff else _NORMAL_BASE score = _URGENT_BASE if shift.already_assigned < shift.min_staff else _NORMAL_BASE
if shift.position_id in volunteer.wished_position_ids: if shift.position_id in volunteer.wished_position_ids:
score += _WISH_BONUS score += _WISH_BONUS
missed = uncovered_minutes(shift.shift_info, volunteer.availability)
score -= (missed // 30) * _AVAIL_PENALTY_PER_HALF_HOUR
if _has_insufficient_rest(volunteer, shift):
score -= _REST_PENALTY
if _chained_from_predecessor(volunteer, shift): if _chained_from_predecessor(volunteer, shift):
score -= _CHAIN_PENALTY score -= _CHAIN_PENALTY
companions_present = len(volunteer.wished_companion_ids & shift.assigned_volunteer_ids) companions_present = len(volunteer.wished_companion_ids & shift.assigned_volunteer_ids)
@@ -110,6 +85,8 @@ def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int:
_overlaps(shift.shift_info, passage) for passage in volunteer.wished_artist_windows _overlaps(shift.shift_info, passage) for passage in volunteer.wished_artist_windows
): ):
score -= _ARTIST_CONFLICT_PENALTY score -= _ARTIST_CONFLICT_PENALTY
if any(_overlaps(shift.shift_info, other) for other in volunteer.existing_shifts):
score -= _ALREADY_BUSY_PENALTY
score -= (_FAIRNESS_PENALTY_PER_HOUR * volunteer.existing_minutes) // 60 score -= (_FAIRNESS_PENALTY_PER_HOUR * volunteer.existing_minutes) // 60
return score return score
@@ -130,7 +107,8 @@ def solve(
if gap <= 0: if gap <= 0:
continue continue
for vi, volunteer in enumerate(data.volunteers): for vi, volunteer in enumerate(data.volunteers):
if any(_overlaps(shift.shift_info, other) for other in volunteer.existing_shifts): # Déjà affecté à ce créneau : rien à suggérer.
if any(other.id == shift.id for other in volunteer.existing_shifts):
continue continue
key = (vi, si) key = (vi, si)
variables[key] = model.NewBoolVar(f"x_{vi}_{si}") variables[key] = model.NewBoolVar(f"x_{vi}_{si}")
@@ -147,8 +125,8 @@ def solve(
if shift_vars and gap > 0: if shift_vars and gap > 0:
model.Add(sum(shift_vars) <= gap) model.Add(sum(shift_vars) <= gap)
# Aucun chevauchement entre deux créneaux nouvellement suggérés au même # Jamais deux créneaux qui se chevauchent suggérés au même bénévole dans
# bénévole (règle `SHIFT_OVERLAP`, bloquante). # un même calcul (contre les affectations décidées : malus, cf. `_score`).
for vi in range(len(data.volunteers)): for vi in range(len(data.volunteers)):
my_shifts = [si for si in range(len(data.shifts)) if (vi, si) in variables] my_shifts = [si for si in range(len(data.shifts)) if (vi, si) in variables]
for a in range(len(my_shifts)): for a in range(len(my_shifts)):
+9 -12
View File
@@ -9,7 +9,7 @@ from sqlmodel import Session, select
from app.models import ( from app.models import (
Artist, Artist,
Assignment, Assignment,
Availability, DayPart,
Edition, Edition,
Shift, Shift,
Volunteer, Volunteer,
@@ -18,7 +18,8 @@ from app.models import (
VolunteerPositionWish, VolunteerPositionWish,
) )
from app.solver.models import SolverInput, SolverShift, SolverVolunteer from app.solver.models import SolverInput, SolverShift, SolverVolunteer
from app.validation.models import AvailabilityWindow, ShiftInfo from app.validation.engine import day_boundary, festival_day
from app.validation.models import ShiftInfo
def _artist_window(artist: Artist) -> ShiftInfo: def _artist_window(artist: Artist) -> ShiftInfo:
@@ -41,6 +42,11 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
) )
shifts_by_id = {s.id: s for s in shifts} shifts_by_id = {s.id: s for s in shifts}
# « Même jour » = même journée de festival (CLAUDE.md § 3) : 00h–01h
# enchaîne bien 23h–00h de la veille au calendrier.
boundary = day_boundary(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
# Prédécesseur(s) : créneaux (n'importe quel poste) du même jour dont la # Prédécesseur(s) : créneaux (n'importe quel poste) du même jour dont la
# fin coïncide avec le début de celui-ci — sert au malus de chaînage # fin coïncide avec le début de celui-ci — sert au malus de chaînage
# (voir `engine._score`), pour limiter les enchaînements de shifts plutôt # (voir `engine._score`), pour limiter les enchaînements de shifts plutôt
@@ -49,7 +55,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
predecessor_ids: dict = {} predecessor_ids: dict = {}
by_day: dict = defaultdict(list) by_day: dict = defaultdict(list)
for s in shifts: for s in shifts:
by_day[s.starts_at.date()].append(s) by_day[festival_day(s.starts_at, boundary)].append(s)
for day_shifts in by_day.values(): for day_shifts in by_day.values():
ids_by_end: dict = defaultdict(list) ids_by_end: dict = defaultdict(list)
for s in day_shifts: for s in day_shifts:
@@ -86,14 +92,6 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
if assigned_count.get(s.id, 0) < s.ideal_staff if assigned_count.get(s.id, 0) < s.ideal_staff
) )
availabilities: dict = defaultdict(list)
for a in session.exec(
select(Availability).where(Availability.edition_id == edition.id)
):
availabilities[a.volunteer_id].append(
AvailabilityWindow(date=a.date, start_time=a.start_time, end_time=a.end_time)
)
wishes: dict = defaultdict(set) wishes: dict = defaultdict(set)
for w in session.exec( for w in session.exec(
select(VolunteerPositionWish).join( select(VolunteerPositionWish).join(
@@ -134,7 +132,6 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
solver_volunteers = tuple( solver_volunteers = tuple(
SolverVolunteer( SolverVolunteer(
id=str(v.id), id=str(v.id),
availability=tuple(availabilities.get(v.id, ())),
wished_position_ids=frozenset(wishes.get(v.id, ())), wished_position_ids=frozenset(wishes.get(v.id, ())),
wished_companion_ids=frozenset(companion_wishes.get(v.id, ())), wished_companion_ids=frozenset(companion_wishes.get(v.id, ())),
wished_artist_windows=tuple(artist_wishes.get(v.id, ())), wished_artist_windows=tuple(artist_wishes.get(v.id, ())),
+3 -4
View File
@@ -2,13 +2,13 @@
Le solveur ne connaît que ces structures simples (jamais les modèles Le solveur ne connaît que ces structures simples (jamais les modèles
SQLModel), comme le moteur de validation dont il réutilise les briques SQLModel), comme le moteur de validation dont il réutilise les briques
(`app.validation.models`, `app.validation.engine.uncovered_minutes`). (`app.validation.models`).
""" """
import datetime as dt import datetime as dt
from dataclasses import dataclass, field from dataclasses import dataclass, field
from app.validation.models import AvailabilityWindow, ShiftInfo from app.validation.models import ShiftInfo
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -39,7 +39,6 @@ class SolverShift:
@dataclass(frozen=True) @dataclass(frozen=True)
class SolverVolunteer: class SolverVolunteer:
id: str id: str
availability: tuple[AvailabilityWindow, ...] = ()
wished_position_ids: frozenset[str] = frozenset() wished_position_ids: frozenset[str] = frozenset()
# Bénévoles (0 à N) avec qui il voudrait être affecté. Le vœu est saisi à # Bénévoles (0 à N) avec qui il voudrait être affecté. Le vœu est saisi à
# sens unique (`VolunteerCompanionWish`) mais rendu réciproque ici par le # sens unique (`VolunteerCompanionWish`) mais rendu réciproque ici par le
@@ -50,7 +49,7 @@ class SolverVolunteer:
# lui ferait manquer le passage. # lui ferait manquer le passage.
wished_artist_windows: tuple[ShiftInfo, ...] = () wished_artist_windows: tuple[ShiftInfo, ...] = ()
# Créneaux déjà occupés (affectations existantes, décisions figées) : # Créneaux déjà occupés (affectations existantes, décisions figées) :
# comptent pour chevauchement, repos, chaînage. # comptent pour chevauchement et chaînage.
existing_shifts: tuple[ShiftInfo, ...] = () existing_shifts: tuple[ShiftInfo, ...] = ()
# Total déjà affecté sur l'édition (minutes), pour répartir la charge. # Total déjà affecté sur l'édition (minutes), pour répartir la charge.
existing_minutes: int = 0 existing_minutes: int = 0
+70 -3
View File
@@ -9,6 +9,7 @@ l'inscription au formulaire public n'échoue jamais pour autant.
""" """
import logging import logging
import time
from dataclasses import dataclass from dataclasses import dataclass
import httpx import httpx
@@ -24,9 +25,17 @@ class SumUpCheckout:
id: str id: str
status: str # PENDING / PAID / FAILED / EXPIRED (voir FeePaymentStatus) status: str # PENDING / PAID / FAILED / EXPIRED (voir FeePaymentStatus)
hosted_checkout_url: str | None hosted_checkout_url: str | None
# Transaction qui a réglé le checkout (renseignés par SumUp une fois
# payé, `None` avant) : `transaction_code` est le code court affiché dans
# le tableau de bord SumUp et sur ses reçus (ex. `TEENSK4W2K`) — c'est
# lui que la coordination recherche pour rapprocher un paiement d'un
# bénévole ; `transaction_id` est l'identifiant technique (API).
transaction_code: str | None = None
transaction_id: str | None = None
def _call(method: str, path: str, api_key: str, json: dict | None = None) -> dict | None: def _call(method: str, path: str, api_key: str, json: dict | None = None) -> dict | None:
started = time.monotonic()
try: try:
resp = httpx.request( resp = httpx.request(
method, method,
@@ -36,19 +45,71 @@ def _call(method: str, path: str, api_key: str, json: dict | None = None) -> dic
timeout=_TIMEOUT_SECONDS, timeout=_TIMEOUT_SECONDS,
) )
resp.raise_for_status() resp.raise_for_status()
return resp.json() data = resp.json()
except httpx.HTTPError as exc: except httpx.HTTPStatusError as exc:
# Le corps d'erreur SumUp (`error_code`/`message`, voir leur doc) dit
# pourquoi l'appel est refusé (identifiants invalides, montant,
# référence déjà utilisée…) — il ne contient que des codes, pas de
# données du payeur.
logger.warning(
"Échec de l'appel API SumUp (%s %s) : HTTP %d %s",
method,
path,
exc.response.status_code,
_error_summary(exc.response),
)
return None
except (httpx.HTTPError, ValueError) as exc:
logger.warning( logger.warning(
"Échec de l'appel API SumUp (%s %s) : %s", method, path, exc.__class__.__name__ "Échec de l'appel API SumUp (%s %s) : %s", method, path, exc.__class__.__name__
) )
return None return None
logger.debug(
"API SumUp %s %s : OK en %d ms",
method,
path,
(time.monotonic() - started) * 1000,
)
return data
def _error_summary(resp: httpx.Response) -> str:
try:
body = resp.json()
except ValueError:
return ""
if isinstance(body, list): # SumUp renvoie parfois une liste d'erreurs
body = body[0] if body else {}
if not isinstance(body, dict):
return ""
return " ".join(
str(body[key])[:200] for key in ("error_code", "message", "param") if body.get(key)
)
def _to_checkout(data: dict) -> SumUpCheckout: def _to_checkout(data: dict) -> SumUpCheckout:
transaction_code = data.get("transaction_code")
transaction_id = data.get("transaction_id")
if not transaction_code:
# Selon la version de l'API, seuls les essais de paiement
# (`transactions`) portent le code : on retient celui qui a abouti.
successful = next(
(
tx
for tx in data.get("transactions") or []
if isinstance(tx, dict) and tx.get("status") == "SUCCESSFUL"
),
None,
)
if successful is not None:
transaction_code = successful.get("transaction_code")
transaction_id = transaction_id or successful.get("id")
return SumUpCheckout( return SumUpCheckout(
id=data["id"], id=data["id"],
status=data["status"], status=data["status"],
hosted_checkout_url=data.get("hosted_checkout_url"), hosted_checkout_url=data.get("hosted_checkout_url"),
transaction_code=transaction_code,
transaction_id=transaction_id,
) )
@@ -65,7 +126,13 @@ def create_checkout(
) -> SumUpCheckout | None: ) -> SumUpCheckout | None:
"""Crée un checkout hébergé (`hosted_checkout.enabled=true`) : la page de """Crée un checkout hébergé (`hosted_checkout.enabled=true`) : la page de
paiement SumUp est à `hosted_checkout_url`. `None` en cas d'échec (réseau, paiement SumUp est à `hosted_checkout_url`. `None` en cas d'échec (réseau,
identifiants invalides…).""" identifiants invalides…).
L'API de création n'accepte pas de métadonnées libres : `description`
(« affichée dans les outils SumUp ») et `checkout_reference` sont les
seuls champs du marchand repris sur la transaction — c'est donc par eux
que passe le nom du bénévole (voir `app/api/forms.py:
_checkout_description`)."""
data = _call( data = _call(
"POST", "POST",
"/v0.1/checkouts", "/v0.1/checkouts",
-2
View File
@@ -5,7 +5,6 @@ Voir CLAUDE.md § 3 pour les règles et la sémantique.
from app.validation.engine import has_blocking, validate from app.validation.engine import has_blocking, validate
from app.validation.models import ( from app.validation.models import (
AvailabilityWindow,
DayPartWindow, DayPartWindow,
Severity, Severity,
ShiftInfo, ShiftInfo,
@@ -15,7 +14,6 @@ from app.validation.models import (
) )
__all__ = [ __all__ = [
"AvailabilityWindow",
"DayPartWindow", "DayPartWindow",
"Severity", "Severity",
"ShiftInfo", "ShiftInfo",
+47 -108
View File
@@ -7,7 +7,6 @@ sortie. Règles et sémantique figées : voir CLAUDE.md § 3.
import datetime as dt import datetime as dt
from app.validation.models import ( from app.validation.models import (
AvailabilityWindow,
Severity, Severity,
ShiftInfo, ShiftInfo,
ValidationInput, ValidationInput,
@@ -15,15 +14,10 @@ from app.validation.models import (
ViolationCode, ViolationCode,
) )
_EIGHT_HOURS = dt.timedelta(hours=8)
def validate(data: ValidationInput) -> list[Violation]: def validate(data: ValidationInput) -> list[Violation]:
violations: list[Violation] = [] violations: list[Violation] = []
violations.extend(_check_overlap(data)) violations.extend(_check_overlap(data))
violations.extend(_check_outside_availability(data))
violations.extend(_check_daily_hours(data))
violations.extend(_check_insufficient_rest(data))
return violations return violations
@@ -32,7 +26,7 @@ def has_blocking(violations: list[Violation]) -> bool:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# SHIFT_OVERLAP (bloquante) # # SHIFT_OVERLAP (avertissement) #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool: def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
# Bornes strictes : deux créneaux jointifs (fin == début) ne se chevauchent # Bornes strictes : deux créneaux jointifs (fin == début) ne se chevauchent
@@ -55,115 +49,60 @@ def _check_overlap(data: ValidationInput) -> list[Violation]:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# OUTSIDE_AVAILABILITY (avertissement) # # « Journée de festival » — plus utilisée par la validation elle-même, mais #
# partagée par le solveur, les feuilles de poste et les statistiques. #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def _window_interval(w: AvailabilityWindow) -> tuple[dt.datetime, dt.datetime]: _MINUTES_PER_DAY = 24 * 60
start = dt.datetime.combine(w.date, w.start_time)
end_day = w.date if w.end_time > w.start_time else w.date + dt.timedelta(days=1)
return start, dt.datetime.combine(end_day, w.end_time)
def uncovered_minutes( def day_boundary(day_parts) -> dt.time:
shift: ShiftInfo, windows: tuple[AvailabilityWindow, ...] """Début d'une « journée de festival » : début du plus long creux que
) -> int: laissent les créneaux types (`DayPart`) sur le cadran de 24 h — l'heure à
"""Minutes du créneau qui ne tombent dans aucune fenêtre de disponibilité. laquelle le dernier de la journée se termine —, minuit à défaut — voir
CLAUDE.md § 3. Avec Matin 08 h–13 h, Après-midi 13 h–19 h, Soirée
Le bénévole peut déclarer plusieurs fenêtres ; on regarde leur union. 19 h–02 h, le creux 02 h–08 h est le plus long : la journée
""" commence à 02 h, la soirée qui passe minuit reste sur le jour où elle a
cursor = shift.starts_at commencé. Indépendant de `order`, qui n'est qu'un ordre d'affichage et
intervals = sorted( ne suit pas les heures quand on les modifie (un créneau type d'ordre 0
(iv for iv in map(_window_interval, windows) if iv[1] > iv[0]), déplacé à 21 h faisait commencer chaque journée à 21 h). Sans creux (les
key=lambda iv: iv[0], créneaux types couvrent 24 h), début du premier par `order`. Accepte
) aussi bien les `DayPartInfo` de ce module que les lignes `DayPart`.
gap = dt.timedelta() Miroir front : `lib/planning.ts:dayBoundaryFromDayParts`."""
for start, end in intervals: day_parts = list(day_parts)
if end <= cursor:
continue
if start > shift.ends_at:
break
if start > cursor:
gap += min(start, shift.ends_at) - cursor
cursor = max(cursor, end)
if cursor >= shift.ends_at:
return int(gap.total_seconds() // 60)
if cursor < shift.ends_at:
gap += shift.ends_at - cursor
return int(gap.total_seconds() // 60)
def _check_outside_availability(data: ValidationInput) -> list[Violation]:
missing = uncovered_minutes(data.candidate_shift, data.availabilities)
if missing == 0:
return []
return [
Violation.of(
ViolationCode.OUTSIDE_AVAILABILITY,
date=data.candidate_shift.starts_at.date().isoformat(),
uncovered_minutes=missing,
)
]
# --------------------------------------------------------------------------- #
# DAILY_HOURS_EXCEEDED (avertissement) #
# --------------------------------------------------------------------------- #
def _day_boundary(day_parts: tuple) -> dt.time:
if not day_parts: if not day_parts:
return dt.time(0, 0) return dt.time(0, 0)
covered = [False] * _MINUTES_PER_DAY
for part in day_parts:
start = part.start_time.hour * 60 + part.start_time.minute
end = part.end_time.hour * 60 + part.end_time.minute
length = (end - start) % _MINUTES_PER_DAY or _MINUTES_PER_DAY
for offset in range(length):
covered[(start + offset) % _MINUTES_PER_DAY] = True
if all(covered):
return min(day_parts, key=lambda part: part.order).start_time return min(day_parts, key=lambda part: part.order).start_time
# Parcours circulaire à partir d'une minute couverte : chaque creux est
# vu d'un seul tenant, même s'il enjambe minuit.
origin = covered.index(True)
best_start, best_length = 0, 0
run_start, run_length = 0, 0
for offset in range(1, _MINUTES_PER_DAY + 1):
minute = (origin + offset) % _MINUTES_PER_DAY
if covered[minute]:
run_length = 0
continue
if run_length == 0:
run_start = minute
run_length += 1
if run_length > best_length:
best_start, best_length = run_start, run_length
return dt.time(best_start // 60, best_start % 60)
def _day_key(moment: dt.datetime, boundary: dt.time) -> dt.date: def festival_day(moment: dt.datetime, boundary: dt.time) -> dt.date:
"""Journée de festival d'un instant : avant `boundary`, c'est encore la
nuit de la veille (ex. 01:00 le dimanche ⇒ samedi). Partagé avec le
solveur et les feuilles de poste, pour qu'une soirée qui passe minuit
reste d'un seul tenant partout."""
if moment.time() >= boundary: if moment.time() >= boundary:
return moment.date() return moment.date()
return moment.date() - dt.timedelta(days=1) return moment.date() - dt.timedelta(days=1)
def _check_daily_hours(data: ValidationInput) -> list[Violation]:
boundary = _day_boundary(data.day_parts)
candidate = data.candidate_shift
key = _day_key(candidate.starts_at, boundary)
total = candidate.ends_at - candidate.starts_at
for other in data.existing_shifts:
if _day_key(other.starts_at, boundary) == key:
total += other.ends_at - other.starts_at
if total > _EIGHT_HOURS:
return [
Violation.of(
ViolationCode.DAILY_HOURS_EXCEEDED,
date=key.isoformat(),
total_minutes=int(total.total_seconds() // 60),
)
]
return []
# --------------------------------------------------------------------------- #
# INSUFFICIENT_REST (avertissement) #
# --------------------------------------------------------------------------- #
def _check_insufficient_rest(data: ValidationInput) -> list[Violation]:
candidate = data.candidate_shift
out: list[Violation] = []
for other in data.existing_shifts:
if _overlaps(candidate, other):
continue
if other.ends_at <= candidate.starts_at:
gap = candidate.starts_at - other.ends_at
direction = "BEFORE"
elif candidate.ends_at <= other.starts_at:
gap = other.starts_at - candidate.ends_at
direction = "AFTER"
else: # pragma: no cover - inatteignable : non-chevauchement ⇒ avant ou après
continue
if gap < _EIGHT_HOURS:
out.append(
Violation.of(
ViolationCode.INSUFFICIENT_REST,
other_shift_id=other.id,
direction=direction,
gap_minutes=int(gap.total_seconds() // 60),
)
)
return out
-24
View File
@@ -8,15 +8,11 @@ from sqlmodel import Session, select
from app.models import ( from app.models import (
Assignment, Assignment,
Availability,
DayPart,
Edition, Edition,
Shift, Shift,
Volunteer, Volunteer,
) )
from app.validation.models import ( from app.validation.models import (
AvailabilityWindow,
DayPartWindow,
ShiftInfo, ShiftInfo,
ValidationInput, ValidationInput,
) )
@@ -30,10 +26,6 @@ def build_validation_input(
shift: Shift, shift: Shift,
exclude_assignment_id: uuid.UUID | None = None, exclude_assignment_id: uuid.UUID | None = None,
) -> ValidationInput: ) -> ValidationInput:
day_parts = list(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
# Toute affectation existante du bénévole compte (une affectation = une # Toute affectation existante du bénévole compte (une affectation = une
# décision, toujours active). # décision, toujours active).
stmt = ( stmt = (
@@ -49,17 +41,6 @@ def build_validation_input(
for s in session.exec(stmt) for s in session.exec(stmt)
) )
declarations = tuple(
AvailabilityWindow(
date=a.date, start_time=a.start_time, end_time=a.end_time
)
for a in session.exec(
select(Availability).where(
Availability.volunteer_id == volunteer.id
)
)
)
return ValidationInput( return ValidationInput(
candidate_shift=ShiftInfo( candidate_shift=ShiftInfo(
id=str(shift.id), id=str(shift.id),
@@ -67,9 +48,4 @@ def build_validation_input(
ends_at=shift.ends_at, ends_at=shift.ends_at,
), ),
existing_shifts=existing_shifts, existing_shifts=existing_shifts,
availabilities=declarations,
day_parts=tuple(
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order)
for dp in day_parts
),
) )
+5 -23
View File
@@ -18,16 +18,12 @@ class Severity(StrEnum):
class ViolationCode(StrEnum): class ViolationCode(StrEnum):
SHIFT_OVERLAP = "SHIFT_OVERLAP" SHIFT_OVERLAP = "SHIFT_OVERLAP"
OUTSIDE_AVAILABILITY = "OUTSIDE_AVAILABILITY"
DAILY_HOURS_EXCEEDED = "DAILY_HOURS_EXCEEDED"
INSUFFICIENT_REST = "INSUFFICIENT_REST"
SEVERITY_BY_CODE: dict[ViolationCode, Severity] = { SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
ViolationCode.SHIFT_OVERLAP: Severity.BLOCKING, # Avertissement seulement : un bénévole peut tenir deux postes à la fois
ViolationCode.OUTSIDE_AVAILABILITY: Severity.WARNING, # (le solveur l'évite par un malus, cf. `app.solver.engine`).
ViolationCode.DAILY_HOURS_EXCEEDED: Severity.WARNING, ViolationCode.SHIFT_OVERLAP: Severity.WARNING,
ViolationCode.INSUFFICIENT_REST: Severity.WARNING,
} }
@@ -53,8 +49,8 @@ class ShiftInfo:
@dataclass(frozen=True) @dataclass(frozen=True)
class DayPartWindow: class DayPartWindow:
"""Créneau type. Ne sert plus qu'à fixer la borne de « journée » pour le """Créneau type. Ne sert plus qu'à fixer la borne de « journée de
cumul des 8 h (début du premier créneau type).""" festival » (`engine.day_boundary`)."""
code: str code: str
start_time: dt.time start_time: dt.time
@@ -63,18 +59,6 @@ class DayPartWindow:
order: int order: int
@dataclass(frozen=True)
class AvailabilityWindow:
"""« Le <date>, je peux de <start_time> à <end_time>. »
`end_time <= start_time` ⇒ la fenêtre se termine le lendemain.
"""
date: dt.date
start_time: dt.time
end_time: dt.time
@dataclass(frozen=True) @dataclass(frozen=True)
class ValidationInput: class ValidationInput:
"""Tout ce qu'il faut pour valider une affectation candidate. """Tout ce qu'il faut pour valider une affectation candidate.
@@ -85,5 +69,3 @@ class ValidationInput:
candidate_shift: ShiftInfo candidate_shift: ShiftInfo
existing_shifts: tuple[ShiftInfo, ...] = () existing_shifts: tuple[ShiftInfo, ...] = ()
availabilities: tuple[AvailabilityWindow, ...] = ()
day_parts: tuple[DayPartWindow, ...] = ()
-30
View File
@@ -4,7 +4,6 @@ import datetime as dt
from app.models import ( from app.models import (
Artist, Artist,
Availability,
Position, Position,
PositionCategory, PositionCategory,
Shift, Shift,
@@ -74,34 +73,5 @@ def make_volunteer(
return volunteer return volunteer
def make_availability(
session, edition, volunteer, *, date, start_time, end_time
):
"""Fenêtre de disponibilité libre. `end_time <= start_time` ⇒ passe minuit."""
row = Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=date,
start_time=start_time,
end_time=end_time,
)
session.add(row)
session.commit()
return row
def declare_available_all_day(session, edition, volunteer, date):
"""Disponible de 00:00 à 23:59 le jour donné (couvre tout un créneau
diurne ; pour la nuit, déclarer une fenêtre dédiée)."""
make_availability(
session,
edition,
volunteer,
date=date,
start_time=dt.time(0, 0),
end_time=dt.time(23, 59),
)
def dt_at(day: int, hour: int, minute: int = 0) -> dt.datetime: def dt_at(day: int, hour: int, minute: int = 0) -> dt.datetime:
return dt.datetime(2026, 7, day, hour, minute) return dt.datetime(2026, 7, day, hour, minute)
+27 -28
View File
@@ -1,7 +1,6 @@
import datetime as dt import datetime as dt
from tests.factories import ( from tests.factories import (
declare_available_all_day,
dt_at, dt_at,
make_position, make_position,
make_shift, make_shift,
@@ -13,16 +12,12 @@ def _url(edition, suffix=""):
return f"/editions/{edition.id}/assignments{suffix}" return f"/editions/{edition.id}/assignments{suffix}"
def _setup(session, edition, *, available=True): def _setup(session, edition):
position = make_position(session, edition) position = make_position(session, edition)
shift = make_shift( shift = make_shift(
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18) session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18)
) )
volunteer = make_volunteer(session, edition, email="v@ex.fr") volunteer = make_volunteer(session, edition, email="v@ex.fr")
if available:
declare_available_all_day(
session, edition, volunteer, dt.date(2026, 7, 10)
)
return volunteer, shift return volunteer, shift
@@ -39,19 +34,28 @@ def test_create_assignment_clean(client, edition, session, day_parts):
assert body["assignment"]["locked"] is False assert body["assignment"]["locked"] is False
def test_create_assignment_returns_warnings(client, edition, session, day_parts): def test_long_day_without_rest_raises_no_warning(client, edition, session, day_parts):
# Bénévole non déclaré disponible sur le créneau. # Plus de limite d'heures par jour ni de repos minimal : 14h–18h puis
volunteer, shift = _setup(session, edition, available=False) # 18h–04h (14 h d'affilée) passe sans avertissement.
volunteer, shift = _setup(session, edition)
position = make_position(session, edition, name="Bar")
following = make_shift(
session, edition, position, start=dt_at(10, 18), end=dt_at(11, 4)
)
for s in (shift, following):
resp = client.post( resp = client.post(
_url(edition), _url(edition),
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)}, json={"volunteer_id": str(volunteer.id), "shift_id": str(s.id)},
) )
assert resp.status_code == 201 assert resp.status_code == 201
codes = {w["code"] for w in resp.json()["warnings"]} assert resp.json()["warnings"] == []
assert "OUTSIDE_AVAILABILITY" in codes
def test_create_assignment_blocked_by_overlap(client, edition, session, day_parts): def test_create_assignment_overlap_is_allowed_with_warning(
client, edition, session, day_parts
):
# Un bénévole peut tenir deux postes en même temps : l'affectation est
# enregistrée, le chevauchement remonte en avertissement.
volunteer, shift = _setup(session, edition) volunteer, shift = _setup(session, edition)
assert client.post( assert client.post(
_url(edition), _url(edition),
@@ -69,15 +73,14 @@ def test_create_assignment_blocked_by_overlap(client, edition, session, day_part
"shift_id": str(overlapping.id), "shift_id": str(overlapping.id),
}, },
) )
assert resp.status_code == 422 assert resp.status_code == 201
detail = resp.json()["detail"] warnings = resp.json()["warnings"]
assert detail["code"] == "error.validation_failed" assert [w["code"] for w in warnings] == ["SHIFT_OVERLAP"]
assert any(v["code"] == "SHIFT_OVERLAP" for v in detail["violations"]) assert warnings[0]["severity"] == "WARNING"
# Rien n'a été enregistré pour ce créneau.
listed = client.get( listed = client.get(
_url(edition), params={"shift_id": str(overlapping.id)} _url(edition), params={"shift_id": str(overlapping.id)}
).json() ).json()
assert listed == [] assert len(listed) == 1
def test_create_duplicate_assignment_conflicts(client, edition, session, day_parts): def test_create_duplicate_assignment_conflicts(client, edition, session, day_parts):
@@ -110,7 +113,7 @@ def test_assignment_wrong_edition_is_422(client, edition, session, day_parts):
def test_validate_endpoint_does_not_persist(client, edition, session, day_parts): def test_validate_endpoint_does_not_persist(client, edition, session, day_parts):
volunteer, shift = _setup(session, edition, available=False) volunteer, shift = _setup(session, edition)
resp = client.post( resp = client.post(
_url(edition, "/validate"), _url(edition, "/validate"),
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)}, json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
@@ -118,14 +121,12 @@ def test_validate_endpoint_does_not_persist(client, edition, session, day_parts)
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.json() body = resp.json()
assert body["blocking"] is False assert body["blocking"] is False
assert any(v["code"] == "OUTSIDE_AVAILABILITY" for v in body["violations"]) assert body["violations"] == []
assert client.get(_url(edition)).json() == [] assert client.get(_url(edition)).json() == []
def test_patch_locked_keeps_assignment_and_returns_warnings( def test_patch_locked_keeps_assignment(client, edition, session, day_parts):
client, edition, session, day_parts volunteer, shift = _setup(session, edition)
):
volunteer, shift = _setup(session, edition, available=False)
aid = client.post( aid = client.post(
_url(edition), _url(edition),
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)}, json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
@@ -134,9 +135,7 @@ def test_patch_locked_keeps_assignment_and_returns_warnings(
resp = client.patch(_url(edition, f"/{aid}"), json={"locked": True}) resp = client.patch(_url(edition, f"/{aid}"), json={"locked": True})
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["assignment"]["locked"] is True assert resp.json()["assignment"]["locked"] is True
assert any( assert resp.json()["warnings"] == []
w["code"] == "OUTSIDE_AVAILABILITY" for w in resp.json()["warnings"]
)
def test_delete_assignment(client, edition, session, day_parts): def test_delete_assignment(client, edition, session, day_parts):
+72
View File
@@ -0,0 +1,72 @@
"""Pointage présent / absent d'un bénévole sur son créneau
(`PUT .../assignments/{id}/attendance`) : enregistré en base, par le
référent du poste ou la coordination."""
from app.models import Assignment, Role
from tests.factories import dt_at, make_position, make_shift, make_volunteer
def _assignment(session, edition, lead=None):
position = make_position(session, edition)
if lead is not None:
position.lead_account_id = lead.id
session.add(position)
session.commit()
shift = make_shift(session, edition, position, start=dt_at(10, 18), end=dt_at(10, 22))
volunteer = make_volunteer(session, edition)
assignment = Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id)
session.add(assignment)
session.commit()
session.refresh(assignment)
return assignment
def _url(edition, assignment):
return f"/editions/{edition.id}/assignments/{assignment.id}/attendance"
def test_lead_marks_attendance_and_it_is_kept(edition, session, make_account, as_account):
lead = make_account(Role.LEAD)
assignment = _assignment(session, edition, lead)
client = as_account(lead)
resp = client.put(_url(edition, assignment), json={"attendance": "PRESENT"})
assert resp.status_code == 200
assert resp.json()["attendance"] == "PRESENT"
assert resp.json()["attendance_marked_at"] is not None
# Relu ensuite (rechargement de l'écran) : le pointage est toujours là.
(listed,) = client.get(f"/editions/{edition.id}/assignments").json()
assert listed["attendance"] == "PRESENT"
def test_attendance_can_be_switched_and_cleared(client, edition, session):
assignment = _assignment(session, edition)
assert client.put(_url(edition, assignment), json={"attendance": "ABSENT"}).json()[
"attendance"
] == "ABSENT"
cleared = client.put(_url(edition, assignment), json={"attendance": None}).json()
assert cleared["attendance"] is None
assert cleared["attendance_marked_at"] is None
def test_lead_of_another_position_cannot_mark(edition, session, make_account, as_account):
assignment = _assignment(session, edition, make_account(Role.LEAD))
resp = as_account(make_account(Role.LEAD)).put(
_url(edition, assignment), json={"attendance": "PRESENT"}
)
assert resp.status_code == 403
def test_volunteer_cannot_mark(edition, session, make_account, as_account):
assignment = _assignment(session, edition)
resp = as_account(make_account(Role.VOLUNTEER)).put(
_url(edition, assignment), json={"attendance": "PRESENT"}
)
assert resp.status_code == 403
def test_unknown_status_is_rejected(client, edition, session):
assignment = _assignment(session, edition)
resp = client.put(_url(edition, assignment), json={"attendance": "LATE"})
assert resp.status_code == 422
-114
View File
@@ -1,114 +0,0 @@
from app.models import Role
from tests.factories import make_volunteer
def _url(edition, volunteer, suffix=""):
return f"/editions/{edition.id}/volunteers/{volunteer.id}/availability{suffix}"
def test_put_then_get_returns_windows_sorted(client, edition, session):
v = make_volunteer(session, edition, email="v@e.fr")
resp = client.put(
_url(edition, v),
json={
"windows": [
{"date": "2026-07-11", "start_time": "14:00", "end_time": "19:00"},
{"date": "2026-07-10", "start_time": "08:00", "end_time": "13:00"},
{"date": "2026-07-10", "start_time": "19:00", "end_time": "02:00"},
]
},
)
assert resp.status_code == 200
got = [(w["date"], w["start_time"], w["end_time"]) for w in resp.json()]
assert got == [
("2026-07-10", "08:00:00", "13:00:00"),
("2026-07-10", "19:00:00", "02:00:00"),
("2026-07-11", "14:00:00", "19:00:00"),
]
assert client.get(_url(edition, v)).json() == resp.json()
def test_put_replaces_the_whole_set_and_dedupes(client, edition, session):
v = make_volunteer(session, edition, email="v@e.fr")
client.put(
_url(edition, v),
json={
"windows": [
{"date": "2026-07-10", "start_time": "08:00", "end_time": "12:00"}
]
},
)
resp = client.put(
_url(edition, v),
json={
"windows": [
{"date": "2026-07-11", "start_time": "09:00", "end_time": "17:00"},
{"date": "2026-07-11", "start_time": "09:00", "end_time": "17:00"},
]
},
)
body = resp.json()
assert len(body) == 1
assert body[0]["date"] == "2026-07-11"
def test_put_rejects_date_outside_edition(client, edition, session):
v = make_volunteer(session, edition, email="v@e.fr")
resp = client.put(
_url(edition, v),
json={
"windows": [
{"date": "2026-08-01", "start_time": "08:00", "end_time": "12:00"}
]
},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "availability.date_outside_edition"
def test_put_rejects_empty_window(client, edition, session):
v = make_volunteer(session, edition, email="v@e.fr")
resp = client.put(
_url(edition, v),
json={
"windows": [
{"date": "2026-07-10", "start_time": "09:00", "end_time": "09:00"}
]
},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "availability.empty_window"
def test_edition_wide_listing(client, edition, session):
v1 = make_volunteer(session, edition, first_name="A", email="a@e.fr")
v2 = make_volunteer(session, edition, first_name="B", email="b@e.fr")
for v in (v1, v2):
client.put(
_url(edition, v),
json={
"windows": [
{"date": "2026-07-10", "start_time": "08:00", "end_time": "13:00"}
]
},
)
body = client.get(f"/editions/{edition.id}/availability").json()
assert {w["volunteer_id"] for w in body} == {str(v1.id), str(v2.id)}
def test_lead_can_read_but_not_write(edition, session, make_account, as_account):
v = make_volunteer(session, edition, email="v@e.fr")
lead = make_account(Role.LEAD, email="lead@e.fr")
api = as_account(lead)
assert api.get(_url(edition, v)).status_code == 200
resp = api.put(_url(edition, v), json={"windows": []})
assert resp.status_code == 403
def test_volunteer_reads_only_their_own(edition, session, make_account, as_account):
me = make_volunteer(session, edition, email="me@e.fr")
other = make_volunteer(session, edition, email="other@e.fr")
account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="acc@e.fr")
api = as_account(account)
assert api.get(_url(edition, me)).status_code == 200
assert api.get(_url(edition, other)).status_code == 403
+88
View File
@@ -0,0 +1,88 @@
"""Messages aux bénévoles : celui de l'organisation (`Edition.
volunteer_message`, coordination) et celui de chaque poste (`Position.
message`, référent du poste ou coordination)."""
from app.models import Assignment, Role
from tests.factories import dt_at, make_position, make_shift, make_volunteer
def test_coordination_sets_the_organisation_message(
client, edition, session, make_account, as_account
):
resp = client.patch(f"/editions/{edition.id}", json={"volunteer_message": " Repas à 18h "})
assert resp.status_code == 200
assert resp.json()["volunteer_message"] == "Repas à 18h"
assert resp.json()["volunteer_message_updated_at"] is not None
volunteer = make_volunteer(session, edition)
reader = as_account(make_account(Role.VOLUNTEER, volunteer_id=volunteer.id))
resp = reader.get(f"/editions/{edition.id}")
assert resp.json()["volunteer_message"] == "Repas à 18h"
def test_blank_message_clears_it(client, edition):
client.patch(f"/editions/{edition.id}", json={"volunteer_message": "Bonjour"})
resp = client.patch(f"/editions/{edition.id}", json={"volunteer_message": " "})
assert resp.json()["volunteer_message"] is None
assert resp.json()["volunteer_message_updated_at"] is None
def test_unchanged_message_keeps_its_date(client, edition):
first = client.patch(f"/editions/{edition.id}", json={"volunteer_message": "Bonjour"}).json()
again = client.patch(
f"/editions/{edition.id}", json={"volunteer_message": "Bonjour", "name": "X"}
).json()
assert again["volunteer_message_updated_at"] == first["volunteer_message_updated_at"]
def test_volunteer_and_lead_cannot_set_the_organisation_message(edition, make_account, as_account):
for role in (Role.VOLUNTEER, Role.LEAD):
resp = as_account(make_account(role)).patch(
f"/editions/{edition.id}", json={"volunteer_message": "Hack"}
)
assert resp.status_code == 403
def test_message_too_long_is_rejected(client, edition):
resp = client.patch(f"/editions/{edition.id}", json={"volunteer_message": "x" * 2001})
assert resp.status_code == 422
def test_lead_sets_the_message_of_their_position(edition, session, make_account, as_account):
lead = make_account(Role.LEAD)
position = make_position(session, edition)
position.lead_account_id = lead.id
session.add(position)
session.commit()
resp = as_account(lead).patch(
f"/editions/{edition.id}/positions/{position.id}", json={"message": "Gilet fourni"}
)
assert resp.status_code == 200
assert resp.json()["message"] == "Gilet fourni"
assert resp.json()["message_updated_at"] is not None
def test_lead_cannot_set_the_message_of_another_position(
edition, session, make_account, as_account
):
position = make_position(session, edition)
resp = as_account(make_account(Role.LEAD)).patch(
f"/editions/{edition.id}/positions/{position.id}", json={"message": "Hack"}
)
assert resp.status_code == 403
def test_position_message_reaches_the_volunteer_planning(client, edition, session):
volunteer = make_volunteer(session, edition)
position = make_position(session, edition)
shift = make_shift(session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12))
session.add(Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id))
session.commit()
client.patch(
f"/editions/{edition.id}/positions/{position.id}", json={"message": "Gilet fourni"}
)
(entry,) = client.get(f"/editions/{edition.id}/volunteers/{volunteer.id}/planning").json()
assert entry["position_message"] == "Gilet fourni"
assert entry["position_message_updated_at"] is not None
+111 -2
View File
@@ -62,8 +62,9 @@ def test_magic_link_is_embedded_when_placeholder_present(
_url(edition), json={"body": "Connecte-toi : {lien_connexion}"} _url(edition), json={"body": "Connecte-toi : {lien_connexion}"}
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert "/connexion?token=" in plain_text(outbox[0]) assert "/connexion?token=pl." in plain_text(outbox[0])
assert len(session.exec(select(MagicLinkToken)).all()) == 1 # Lien permanent : rien de stocké, pas de jeton à usage unique.
assert session.exec(select(MagicLinkToken)).all() == []
def test_no_magic_link_token_without_placeholder( def test_no_magic_link_token_without_placeholder(
@@ -91,3 +92,111 @@ def test_planning_notification_is_coordinator_only(
lead = make_account(Role.LEAD, email="lead@e.fr") lead = make_account(Role.LEAD, email="lead@e.fr")
resp = as_account(lead).post(_url(edition), json={}) resp = as_account(lead).post(_url(edition), json={})
assert resp.status_code == 403 assert resp.status_code == 403
def test_shifts_placeholder_is_a_table_by_day_with_the_referent(
client, edition, session, outbox, make_account
):
rita = make_volunteer(session, edition, first_name="Rita", last_name="Ref", email="r@e.fr")
rita.phone = "06 11 22 33 44"
session.add(rita)
lead = make_account(Role.LEAD, volunteer_id=rita.id)
bar = make_position(session, edition, name="Bar central")
bar.lead_account_id = lead.id
session.add(bar)
parking = make_position(session, edition, name="Parking")
s1 = make_shift(session, edition, bar, start=dt_at(10, 18), end=dt_at(10, 22))
s2 = make_shift(session, edition, parking, start=dt_at(11, 9), end=dt_at(11, 12))
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
session.add_all(
[
Assignment(edition_id=edition.id, volunteer_id=ana.id, shift_id=s1.id),
Assignment(edition_id=edition.id, volunteer_id=ana.id, shift_id=s2.id),
]
)
session.commit()
client.post(
_url(edition), json={"volunteer_ids": [str(ana.id)], "body": "Ton planning :\n\n{creneaux}"}
)
(message,) = outbox
html = message.get_body(preferencelist=("html",)).get_content()
# Vrai tableau : en-têtes, une ligne de jour, horaire, poste, référent.
assert "<table" in html and "Référent" in html
assert "18:00–22:00" in html and "Bar central" in html
assert "Rita Ref · 06 11 22 33 44" in html
assert "à désigner" in html # Parking n'a pas de référent
assert "" not in html
text = plain_text(message)
assert "18:00–22:00 · Bar central · référent : Rita Ref · 06 11 22 33 44" in text
assert "09:00–12:00 · Parking · référent : à désigner" in text
def _link(message) -> str:
import re
return re.search(r"token=(\S+)", plain_text(message)).group(1)
def _verify(client, edition, token):
return client.post(f"/editions/{edition.id}/auth/verify", json={"token": token})
def test_login_link_is_permanent_and_reusable(client, edition, session, make_account, outbox):
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="ana@e.fr")
client.post(_url(edition), json={"body": "{lien_connexion}"})
client.post(_url(edition), json={"body": "{lien_connexion}"})
first, second = (_link(m) for m in outbox)
# Le même lien dans chaque email, utilisable plusieurs fois.
assert first == second
for _ in range(3):
resp = _verify(client, edition, first)
assert resp.status_code == 200
assert resp.json()["account"]["volunteer_id"] == str(ana.id)
def test_reset_revokes_the_links_already_sent(client, edition, session, make_account, outbox):
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
account = make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="ana@e.fr")
client.post(_url(edition), json={"body": "{lien_connexion}"})
old = _link(outbox[0])
resp = client.post(f"/editions/{edition.id}/accounts/{account.id}/reset-login-link")
assert resp.status_code == 200
assert _verify(client, edition, old).status_code == 401
client.post(_url(edition), json={"body": "{lien_connexion}"})
new = _link(outbox[1])
assert new != old
assert _verify(client, edition, new).status_code == 200
def test_sending_the_link_gives_an_account_to_a_volunteer_without_one(
client, edition, session, outbox
):
from app.models import Account
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
client.post(_url(edition), json={"body": "{lien_connexion}"})
(account,) = session.exec(select(Account).where(Account.volunteer_id == ana.id)).all()
assert account.role == Role.VOLUNTEER
assert _verify(client, edition, _link(outbox[0])).status_code == 200
def test_shared_address_gets_the_login_page_only(client, edition, session, make_account, outbox):
# Couple sur la même adresse : un seul compte possible pour cet email.
ana = make_volunteer(session, edition, first_name="Ana", email="foyer@e.fr")
make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="foyer@e.fr")
bob = make_volunteer(session, edition, first_name="Bob", email="foyer@e.fr")
client.post(_url(edition), json={"volunteer_ids": [str(bob.id)], "body": "{lien_connexion}"})
assert "token=" not in plain_text(outbox[0])
def test_forged_or_coordinator_links_are_refused(client, edition, coordinator):
from app.auth.permanent_link import permanent_login_token
assert _verify(client, edition, permanent_login_token(coordinator)).status_code == 401
assert _verify(client, edition, "pl.nimportequoi.abc").status_code == 401
assert _verify(client, edition, f"pl.{coordinator.id.hex}.faux").status_code == 401
+235
View File
@@ -0,0 +1,235 @@
"""Statistiques d'une édition (inscriptions, réponses, paiements, besoins)."""
import datetime as dt
from app.models import (
Assignment,
FeePaymentStatus,
PendingFormSubmission,
Role,
VolunteerArtist,
VolunteerCompanionWish,
VolunteerPositionWish,
)
from tests.factories import (
dt_at,
make_artist,
make_position,
make_shift,
make_volunteer,
)
def _url(edition):
return f"/editions/{edition.id}/stats"
def _registered_on(session, volunteer, day):
volunteer.created_at = dt.datetime(2026, 6, day, 10, 0)
session.add(volunteer)
session.commit()
def test_is_coordinator_only(edition, make_account, as_account):
lead = make_account(Role.LEAD, email="lead@e.fr")
assert as_account(lead).get(_url(edition)).status_code == 403
def test_empty_edition(client, edition):
resp = client.get(_url(edition))
assert resp.status_code == 200
body = resp.json()
assert body["registrations"]["total"] == 0
assert body["registrations"]["by_day"] == []
assert body["payments"]["total_collected"] == 0
assert body["needs"]["by_day"] == []
def test_registrations_evolution_is_cumulative_with_gap_days_filled(client, edition, session):
for day in (1, 1, 3):
_registered_on(session, make_volunteer(session, edition), day)
by_day = client.get(_url(edition)).json()["registrations"]["by_day"]
assert by_day == [
{"date": "2026-06-01", "count": 2, "cumulative": 2},
{"date": "2026-06-02", "count": 0, "cumulative": 2},
{"date": "2026-06-03", "count": 1, "cumulative": 3},
]
def test_answers_count_volunteers_per_filled_field(client, edition, session):
bar = make_position(session, edition, name="Bar")
artist = make_artist(session, edition)
full = make_volunteer(session, edition, first_name="Alex", email="alex@ex.fr")
full.phone = "0600000000"
full.comment = "Dispo le soir"
full.companion_wish_note = "Sam"
full.self_declared_available = True
other = make_volunteer(session, edition, first_name="Sam")
session.add_all([
full,
VolunteerPositionWish(volunteer_id=full.id, position_id=bar.id),
VolunteerArtist(volunteer_id=full.id, artist_id=artist.id),
VolunteerCompanionWish(volunteer_id=full.id, companion_id=other.id),
])
session.commit()
body = client.get(_url(edition)).json()
answers = body["answers"]
assert answers["total"] == 2
for key in (
"email", "phone", "comment", "companion_note", "available",
"position_wishes", "artist_wishes", "companion_wishes",
):
assert answers[key] == 1, key
assert answers["photo"] == 0
assert body["artist_wishes"] == [
{"id": str(artist.id), "name": "Chilla", "date": "2026-07-10", "count": 1}
]
def test_payments_split_online_on_site_and_unfinished(client, edition, session):
donor = make_volunteer(session, edition, first_name="Alex")
donor.willing_to_pay_fee = True
donor.fee_payment_label = "Mécénat"
donor.fee_amount = 50.0
donor.fee_payment_status = FeePaymentStatus.PAID
donor.mecenat_address = "1 rue du Festival"
classic = make_volunteer(session, edition, first_name="Sam")
classic.willing_to_pay_fee = True
classic.fee_payment_label = "Cotisation classique"
classic.fee_amount = 12.5
classic.fee_payment_status = FeePaymentStatus.PAID
on_site = make_volunteer(session, edition, first_name="Jo")
on_site.willing_to_pay_fee = True
make_volunteer(session, edition, first_name="Lou")
session.add_all([
donor,
classic,
on_site,
# Formulaire rempli, paiement jamais abouti : pas de bénévole créé.
PendingFormSubmission(
edition_id=edition.id, payment_label="Mécénat", payment_amount=30.0,
checkout_status=FeePaymentStatus.FAILED,
),
])
session.commit()
payments = client.get(_url(edition)).json()["payments"]
assert payments["online"] == 2
assert payments["on_site"] == 1
assert payments["none"] == 1
assert payments["pending_unfinished"] == 1
assert payments["total_collected"] == 62.5
assert payments["mecenat_count"] == 1
assert payments["mecenat_total"] == 50.0
assert payments["by_option"] == [
{"label": "Cotisation classique", "mecenat": False, "count": 1, "amount_total": 12.5},
{"label": "Mécénat", "mecenat": True, "count": 1, "amount_total": 50.0},
]
def test_needs_are_grouped_by_festival_day(client, edition, session):
# Début de journée = 06:00 (fixture) : la soirée du 9 déborde après minuit.
bar = make_position(session, edition, name="Bar")
late = make_shift(
session, edition, bar, start=dt_at(9, 22), end=dt_at(10, 0), min_staff=1, ideal_staff=2,
)
make_shift(
session, edition, bar, start=dt_at(10, 0), end=dt_at(10, 1), min_staff=1, ideal_staff=1,
)
alex = make_volunteer(session, edition, first_name="Alex")
sam = make_volunteer(session, edition, first_name="Sam")
session.add_all([
Assignment(edition_id=edition.id, volunteer_id=alex.id, shift_id=late.id),
VolunteerPositionWish(volunteer_id=alex.id, position_id=bar.id),
VolunteerPositionWish(volunteer_id=sam.id, position_id=bar.id),
])
session.commit()
needs = client.get(_url(edition)).json()["needs"]
assert needs["by_day"] == [
{
"date": "2026-07-09",
"shifts": 2,
"min_hours": 3.0,
"ideal_hours": 5.0,
"assigned_hours": 2.0,
}
]
assert needs["by_position"] == [
{
"position_id": str(bar.id),
"name": "Bar",
"shifts": 2,
"min_slots": 2,
"ideal_slots": 3,
"assigned": 1,
"filled": 1,
"wishes": 2,
}
]
assert needs["volunteers_with_assignment"] == 1
def test_filled_slots_never_count_overstaffing(client, edition, session):
# 3 affectés sur un créneau qui n'en demande que 2 : 2 places pourvues,
# pas 3 — sinon un sureffectif masquerait un trou ailleurs.
bar = make_position(session, edition, name="Bar")
full = make_shift(session, edition, bar, start=dt_at(9, 18), end=dt_at(9, 20), ideal_staff=2)
make_shift(session, edition, bar, start=dt_at(9, 20), end=dt_at(9, 22), ideal_staff=2)
for name in ("A", "B", "C"):
v = make_volunteer(session, edition, first_name=name)
session.add(Assignment(edition_id=edition.id, volunteer_id=v.id, shift_id=full.id))
session.commit()
position = client.get(_url(edition)).json()["needs"]["by_position"][0]
assert position["assigned"] == 3
assert position["filled"] == 2
assert position["ideal_slots"] == 4
def test_attendance_splits_past_and_live_shifts_per_position(
client, edition, session, monkeypatch
):
import app.api.stats as stats_api
from app.models import AttendanceStatus
# Maintenant : le 10 à 20:00 — 14h–18h terminé, 18h–22h en cours,
# 22h–02h à venir (ignoré).
monkeypatch.setattr(stats_api, "local_now", lambda: dt_at(10, 20))
bar = make_position(session, edition, name="Bar")
parking = make_position(session, edition, name="Parking")
past = make_shift(session, edition, bar, start=dt_at(10, 14), end=dt_at(10, 18))
live = make_shift(session, edition, bar, start=dt_at(10, 18), end=dt_at(10, 22))
later = make_shift(session, edition, parking, start=dt_at(10, 22), end=dt_at(11, 2))
def assign(shift, name, attendance=None):
volunteer = make_volunteer(session, edition, first_name=name)
session.add(
Assignment(
edition_id=edition.id,
volunteer_id=volunteer.id,
shift_id=shift.id,
attendance=attendance,
)
)
assign(past, "A", AttendanceStatus.PRESENT)
assign(past, "B", AttendanceStatus.ABSENT)
assign(past, "C")
assign(live, "D", AttendanceStatus.PRESENT)
assign(live, "E")
assign(later, "F", AttendanceStatus.PRESENT)
session.commit()
att = client.get(_url(edition)).json()["attendance"]
assert att["past"] == {"assigned": 3, "present": 1, "absent": 1, "unmarked": 1}
assert att["live"] == {"assigned": 2, "present": 1, "absent": 0, "unmarked": 1}
assert att["live_shifts"] == 1
# Parking n'a encore aucun créneau commencé : absent du détail.
(row,) = att["by_position"]
assert row["name"] == "Bar"
assert row["past"]["assigned"] == 3
assert row["live"]["present"] == 1
assert row["live_shifts"] == 1
+144
View File
@@ -0,0 +1,144 @@
"""Email de test depuis l'écran « Messages » (`POST .../notifications/test`) :
envoi synchrone, résultat réel du serveur SMTP renvoyé à la coordination."""
import smtplib
import pytest
from app.config import get_settings
from app.models import Role
def _url(edition):
return f"/editions/{edition.id}/notifications/test"
class _FakeSmtp:
def __init__(self, error=None):
self.sent = []
self.error = error
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def send_message(self, message):
if self.error:
raise self.error
self.sent.append(message)
@pytest.fixture
def smtp_enabled(monkeypatch):
monkeypatch.setenv("EMAIL_ENABLED", "true")
get_settings.cache_clear()
def test_disabled_email_is_reported(client, edition):
resp = client.post(_url(edition), json={"email": "moi@exemple.fr"})
assert resp.status_code == 200
assert resp.json()["status"] == "disabled"
def test_sends_the_test_email(client, edition, smtp_enabled, monkeypatch):
fake = _FakeSmtp()
monkeypatch.setattr("app.email.sender.open_smtp", lambda settings: fake)
resp = client.post(_url(edition), json={"email": " moi@exemple.fr "})
assert resp.json()["status"] == "sent"
(message,) = fake.sent
assert message["To"] == "moi@exemple.fr"
def test_connection_failure_is_reported_with_the_server_message(
client, edition, smtp_enabled, monkeypatch
):
def refuse(settings):
raise smtplib.SMTPAuthenticationError(535, b"authentication failed")
monkeypatch.setattr("app.email.sender.open_smtp", refuse)
body = client.post(_url(edition), json={"email": "moi@exemple.fr"}).json()
assert body["status"] == "connection_failed"
assert "535" in body["detail"]
def test_rejected_message_never_echoes_the_address(client, edition, smtp_enabled, monkeypatch):
refused = smtplib.SMTPRecipientsRefused({"moi@exemple.fr": (550, b"no such user")})
monkeypatch.setattr("app.email.sender.open_smtp", lambda settings: _FakeSmtp(refused))
body = client.post(_url(edition), json={"email": "moi@exemple.fr"}).json()
assert body["status"] == "rejected"
assert "550" in body["detail"]
assert "moi@exemple.fr" not in body["detail"]
@pytest.mark.parametrize("email", ["", "pas-un-email", "@exemple.fr", "moi@", "a b@c.fr"])
def test_invalid_address_is_rejected(client, edition, email):
resp = client.post(_url(edition), json={"email": email})
assert resp.status_code == 422
def test_is_coordinator_only(edition, make_account, as_account):
lead = as_account(make_account(Role.LEAD))
assert lead.post(_url(edition), json={"email": "moi@exemple.fr"}).status_code == 403
def _send(client, edition, monkeypatch, **payload):
captured = []
monkeypatch.setattr(
"app.api.notifications.deliver_test",
lambda message: captured.append(message) or ("sent", None),
)
body = client.post(_url(edition), json={"email": "moi@exemple.fr", **payload}).json()
return body, captured[0]
def test_test_email_is_the_compiled_planning_email(client, edition, session, monkeypatch):
from app.models import Assignment
from tests.factories import dt_at, make_position, make_shift, make_volunteer
from tests.mail import plain_text
make_volunteer(session, edition, first_name="Zoé", last_name="Zébu") # sans créneau
alex = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
shift = make_shift(session, edition, make_position(session, edition, name="Bar"),
start=dt_at(10, 18), end=dt_at(10, 22))
session.add(Assignment(edition_id=edition.id, volunteer_id=alex.id, shift_id=shift.id))
session.commit()
body, message = _send(
client, edition, monkeypatch,
subject="Planning de {prenom}",
body="Salut {prenom} {nom} : {nb_creneaux} créneau(x), {nb_heures} h\n{creneaux}",
)
# Premier bénévole (par nom) qui a un créneau, pas Zoé.
assert body["example_volunteer_name"] == "Alex Martin"
assert message["Subject"] == "[Test] Planning de Alex"
text = plain_text(message)
assert "Salut Alex Martin : 1 créneau(x), 4.0 h" in text
assert "Bar" in text
assert "{" not in text
def test_login_link_in_test_email_carries_no_token(client, edition, session, monkeypatch):
from sqlmodel import select
from app.models import MagicLinkToken
from tests.factories import make_volunteer
from tests.mail import plain_text
make_volunteer(session, edition)
_, message = _send(client, edition, monkeypatch, body="Connexion : {lien_connexion}")
assert "token=" not in plain_text(message)
assert session.exec(select(MagicLinkToken)).all() == []
def test_chosen_volunteer_and_fictitious_example(client, edition, session, monkeypatch):
from tests.factories import make_volunteer
body, message = _send(client, edition, monkeypatch, subject="Pour {prenom}")
assert body["example_volunteer_id"] is None
assert message["Subject"] == "[Test] Pour Camille"
sam = make_volunteer(session, edition, first_name="Sam", last_name="Nuit")
body, _ = _send(client, edition, monkeypatch, volunteer_id=str(sam.id))
assert body["example_volunteer_id"] == str(sam.id)
+96 -13
View File
@@ -6,11 +6,10 @@ from openpyxl import load_workbook
from pypdf import PdfReader from pypdf import PdfReader
from sqlmodel import select from sqlmodel import select
from app.models import Assignment, Availability, FeePaymentStatus, Role, Volunteer from app.models import Assignment, FeePaymentStatus, Role, Volunteer
from tests.factories import ( from tests.factories import (
dt_at, dt_at,
make_artist, make_artist,
make_availability,
make_position, make_position,
make_shift, make_shift,
make_volunteer, make_volunteer,
@@ -208,14 +207,6 @@ def test_delete_is_a_full_cascade(client, edition, session):
shift = make_shift( shift = make_shift(
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12) session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
) )
make_availability(
session,
edition,
volunteer,
date=dt.date(2026, 7, 10),
start_time=dt.time(8, 0),
end_time=dt.time(13, 0),
)
session.add( session.add(
Assignment( Assignment(
edition_id=edition.id, edition_id=edition.id,
@@ -230,7 +221,6 @@ def test_delete_is_a_full_cascade(client, edition, session):
assert client.get(_url(edition, f"/{volunteer.id}")).status_code == 404 assert client.get(_url(edition, f"/{volunteer.id}")).status_code == 404
assert session.exec(select(Volunteer)).all() == [] assert session.exec(select(Volunteer)).all() == []
assert session.exec(select(Assignment)).all() == [] assert session.exec(select(Assignment)).all() == []
assert session.exec(select(Availability)).all() == []
# Le créneau subsiste. # Le créneau subsiste.
from app.models import Shift from app.models import Shift
@@ -427,6 +417,22 @@ def test_export_volunteers_as_excel(client, edition, session):
assert row[6] == "Chilla" assert row[6] == "Chilla"
def test_export_says_how_each_volunteer_pays(client, edition, session):
on_site = make_volunteer(session, edition, first_name="Alex", last_name="A")
on_site.willing_to_pay_fee = True
online = make_volunteer(session, edition, first_name="Sam", last_name="B")
_mark_mecenat_paid(session, online)
make_volunteer(session, edition, first_name="Jo", last_name="C")
session.add(on_site)
session.commit()
resp = client.get(_url(edition, "/export"))
rows = list(load_workbook(io.BytesIO(resp.content)).active.iter_rows(values_only=True))
column = rows[0].index("Mode de paiement")
by_first_name = {row[0]: row[column] for row in rows[1:]}
assert by_first_name == {"Alex": "Sur place", "Sam": "En ligne", "Jo": None}
def test_export_neutralizes_formula_injection(client, edition, session): def test_export_neutralizes_formula_injection(client, edition, session):
# CWE-1236 : un bénévole inscrit via le formulaire public (sans # CWE-1236 : un bénévole inscrit via le formulaire public (sans
# authentification) contrôle son nom, son commentaire, etc. — ouverts # authentification) contrôle son nom, son commentaire, etc. — ouverts
@@ -443,9 +449,9 @@ def test_export_neutralizes_formula_injection(client, edition, session):
resp = client.get(_url(edition, "/export")) resp = client.get(_url(edition, "/export"))
workbook = load_workbook(io.BytesIO(resp.content)) workbook = load_workbook(io.BytesIO(resp.content))
row = list(workbook.active.iter_rows(values_only=True))[1] header, row = list(workbook.active.iter_rows(values_only=True))[:2]
assert row[0] == "'=cmd|'/c calc'!A0" assert row[0] == "'=cmd|'/c calc'!A0"
assert row[17] == "'@SUM(1+1)*cmd|' /C calc'!A0" assert row[header.index("Commentaire")] == "'@SUM(1+1)*cmd|' /C calc'!A0"
def test_export_volunteers_is_coordinator_only(edition, make_account, as_account): def test_export_volunteers_is_coordinator_only(edition, make_account, as_account):
@@ -582,3 +588,80 @@ def test_mecenat_receipts_is_coordinator_only(
lead = make_account(Role.LEAD, email="lead@e.fr") lead = make_account(Role.LEAD, email="lead@e.fr")
resp = as_account(lead).get(_url(edition, "/mecenat-receipts")) resp = as_account(lead).get(_url(edition, "/mecenat-receipts"))
assert resp.status_code == 403 assert resp.status_code == 403
def test_export_gives_the_sumup_transaction(client, edition, session):
volunteer = make_volunteer(session, edition, first_name="Sam", last_name="B")
_mark_mecenat_paid(session, volunteer)
volunteer.fee_transaction_code = "TEENSK4W2K"
volunteer.fee_transaction_id = "4ba8a7ba-0f61-4c8a-9b5c-2f3e0b4a8a11"
session.add(volunteer)
session.commit()
resp = client.get(_url(edition, "/export"))
rows = list(load_workbook(io.BytesIO(resp.content)).active.iter_rows(values_only=True))
code = rows[0].index("Transaction SumUp (code)")
ident = rows[0].index("Transaction SumUp (identifiant)")
assert rows[1][code] == "TEENSK4W2K"
assert rows[1][ident] == "4ba8a7ba-0f61-4c8a-9b5c-2f3e0b4a8a11"
fiche = client.get(_url(edition, f"/{volunteer.id}")).json()
assert fiche["fee_transaction_code"] == "TEENSK4W2K"
# L'identifiant du checkout, lui, reste interne.
assert "fee_checkout_id" not in fiche
def _png() -> bytes:
from PIL import Image
out = io.BytesIO()
Image.new("RGB", (4, 4), "red").save(out, format="PNG")
return out.getvalue()
def test_coordinator_adds_then_removes_a_volunteer_photo(client, edition, session):
volunteer = make_volunteer(session, edition)
url = _url(edition, f"/{volunteer.id}/photo")
assert client.get(url).status_code == 404
resp = client.put(url, files={"file": ("photo.png", _png(), "image/png")})
assert resp.status_code == 200
assert resp.json()["has_photo"] is True
photo = client.get(url)
assert photo.status_code == 200
assert photo.headers["content-type"] == "image/png"
resp = client.delete(url)
assert resp.status_code == 200
assert resp.json()["has_photo"] is False
assert client.get(url).status_code == 404
def test_adding_a_fake_photo_is_rejected(client, edition, session):
volunteer = make_volunteer(session, edition)
resp = client.put(
_url(edition, f"/{volunteer.id}/photo"),
files={"file": ("photo.png", b"pas une image", "image/png")},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "form.photo_type_not_supported"
def test_only_the_coordination_can_change_a_photo(
session, edition, make_account, as_account
):
volunteer = make_volunteer(session, edition)
lead = as_account(make_account(Role.LEAD, email="lead@e.fr"))
url = _url(edition, f"/{volunteer.id}/photo")
assert lead.put(url, files={"file": ("p.png", _png(), "image/png")}).status_code == 403
assert lead.delete(url).status_code == 403
def test_manual_add_requires_no_field(client, edition):
resp = client.post(_url(edition), json={})
assert resp.status_code == 201
body = resp.json()
assert body["first_name"] == ""
assert body["last_name"] == ""
assert client.get(_url(edition)).status_code == 200
+233
View File
@@ -207,3 +207,236 @@ def test_generating_produces_a_pdf(client, edition, session):
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.headers["content-type"] == "application/pdf" assert resp.headers["content-type"] == "application/pdf"
assert resp.content.startswith(b"%PDF") assert resp.content.startswith(b"%PDF")
def test_generating_with_photo_and_free_text_fields(client, edition, session):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
{
"field_type": "PHOTO", "x_mm": 60, "y_mm": 5,
"width_mm": 20, "height_mm": 25, "font_size": 8,
},
{
"field_type": "FREE_TEXT", "x_mm": 5, "y_mm": 40,
"width_mm": 50, "height_mm": 8, "font_size": 9,
"text": "Staff 2026",
},
]
},
)
assert resp.status_code == 200
by_type = {f["field_type"]: f for f in resp.json()}
assert set(by_type) == {"PHOTO", "FREE_TEXT"}
assert by_type["FREE_TEXT"]["text"] == "Staff 2026"
with_photo = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
with_photo.photo = _PNG
with_photo.photo_content_type = "image/png"
without_photo = make_volunteer(session, edition, first_name="Sam", last_name="Nuit")
session.add(with_photo)
session.commit()
resp = client.patch(
f"/editions/{edition.id}/volunteers/{without_photo.id}",
json={"badge_text": "Accès backstage"},
)
assert resp.status_code == 200
assert resp.json()["badge_text"] == "Accès backstage"
resp = client.post(f"{_badges_url(edition)}/generate", json={})
assert resp.status_code == 200
assert resp.content.startswith(b"%PDF")
def _field(**kw):
return {"x_mm": 0, "y_mm": 0, "width_mm": 10, "height_mm": 10, **kw}
def test_new_field_attributes_default_and_round_trip(client, edition):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
_field(field_type="FULL_NAME"),
_field(
field_type="PHOTO", face="BACK", photo_shape="OVAL",
bg_color="#FFD23F", auto_fit=True,
),
]
},
)
assert resp.status_code == 200
by_face = {f["face"]: f for f in resp.json()}
front, back = by_face["FRONT"], by_face["BACK"]
assert front["field_type"] == "FULL_NAME"
assert front["bg_color"] is None
assert front["auto_fit"] is False
assert front["photo_shape"] == "RECT"
assert back["field_type"] == "PHOTO"
assert back["photo_shape"] == "OVAL"
assert back["bg_color"] == "#FFD23F"
assert back["auto_fit"] is True
again = client.get(f"{_badges_url(edition)}/template").json()["fields"]
assert {(f["face"], f["field_type"]) for f in again} == {
("FRONT", "FULL_NAME"), ("BACK", "PHOTO"),
}
def test_same_field_type_on_front_and_back_is_allowed(client, edition):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
_field(field_type="FREE_TEXT", face="FRONT", text="Recto"),
_field(field_type="FREE_TEXT", face="BACK", text="Verso"),
]
},
)
assert resp.status_code == 200
assert {(f["face"], f["text"]) for f in resp.json()} == {
("FRONT", "Recto"), ("BACK", "Verso"),
}
def test_same_field_type_twice_on_the_back_is_rejected(client, edition):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
_field(field_type="ROLE", face="BACK"),
_field(field_type="ROLE", face="BACK"),
]
},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "badge.duplicate_field_type"
@pytest.mark.parametrize("color", ["red", "#FFF", "#GGGGGG", "FFD23F", "#FFD23F0"])
def test_invalid_background_color_is_rejected(client, edition, color):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={"fields": [_field(field_type="ROLE", bg_color=color)]},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "badge.invalid_color"
def test_generating_with_back_fields(client, edition, session, monkeypatch):
import app.api.badges as badges_api
captured = {}
original = badges_api.generate_badge_sheets_pdf
def spy(badges, *args, **kwargs):
captured["badges"] = badges
return original(badges, *args, **kwargs)
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
_field(field_type="FULL_NAME", width_mm=60, font_size=12),
_field(field_type="ROLE", face="BACK", bg_color="#FFD23F", auto_fit=True),
_field(field_type="PHOTO", face="BACK", photo_shape="OVAL"),
]
},
)
assert resp.status_code == 200
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
resp = client.post(f"{_badges_url(edition)}/generate", json={})
assert resp.status_code == 200
assert resp.content.startswith(b"%PDF")
(badge,) = captured["badges"]
assert [f.text for f in badge.fields] == ["Alex\nMartin"]
back = {f.is_photo: f for f in badge.back_fields}
assert back[False].text == "Bénévole"
assert back[False].valign == "MIDDLE"
assert back[False].bg_color == "#FFD23F"
assert back[False].auto_fit is True
assert back[True].photo_shape == "OVAL"
assert back[True].text == "" # pas de photo : rien d'imprimé
def test_generating_prints_as_many_badges_as_requested(client, edition, session, monkeypatch):
import app.api.badges as badges_api
captured = {}
original = badges_api.generate_badge_sheets_pdf
def spy(badges, *args, **kwargs):
captured["badges"] = badges
return original(badges, *args, **kwargs)
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
client.put(
f"{_badges_url(edition)}/template/fields",
json={"fields": [_field(field_type="FULL_NAME", width_mm=60, font_size=12)]},
)
many = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
make_volunteer(session, edition, first_name="Sam", last_name="Nuit")
resp = client.patch(f"/editions/{edition.id}/volunteers/{many.id}", json={"badge_count": 3})
assert resp.status_code == 200
assert resp.json()["badge_count"] == 3
resp = client.post(f"{_badges_url(edition)}/generate", json={})
assert resp.status_code == 200
names = [badge.fields[0].text for badge in captured["badges"]]
assert names == ["Alex\nMartin"] * 3 + ["Sam\nNuit"]
@pytest.mark.parametrize("count", [0, -1, 101])
def test_badge_count_out_of_range_is_rejected(client, edition, session, count):
volunteer = make_volunteer(session, edition)
resp = client.patch(
f"/editions/{edition.id}/volunteers/{volunteer.id}", json={"badge_count": count}
)
assert resp.status_code == 422
def test_fields_are_drawn_in_stacking_order(client, edition, session, monkeypatch):
import app.api.badges as badges_api
captured = {}
original = badges_api.generate_badge_sheets_pdf
def spy(badges, *args, **kwargs):
captured["badges"] = badges
return original(badges, *args, **kwargs)
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
_field(field_type="FREE_TEXT", text="Staff", z_order=2),
_field(field_type="FULL_NAME", z_order=0),
_field(field_type="PHOTO", z_order=1),
]
},
)
assert resp.status_code == 200
assert [f["field_type"] for f in resp.json()] == ["FULL_NAME", "PHOTO", "FREE_TEXT"]
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
assert client.post(f"{_badges_url(edition)}/generate", json={}).status_code == 200
(badge,) = captured["badges"]
assert [(f.is_photo, f.text) for f in badge.fields] == [
(False, "Alex\nMartin"),
(True, ""),
(False, "Staff"),
]
def test_photo_is_drawn_below_text_at_equal_stacking_order(client, edition):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={"fields": [_field(field_type="FULL_NAME"), _field(field_type="PHOTO")]},
)
assert [f["field_type"] for f in resp.json()] == ["PHOTO", "FULL_NAME"]
+75 -51
View File
@@ -3,7 +3,7 @@
import datetime as dt import datetime as dt
import uuid import uuid
from app.api.badges import _field_text, _position_line from app.api.badges import _field_text, _positions_table
from app.models import ( from app.models import (
BadgeField, BadgeField,
BadgeFieldType, BadgeFieldType,
@@ -29,65 +29,45 @@ def _shift(start: dt.datetime, end: dt.datetime) -> Shift:
) )
def test_position_line_shows_the_schedule(): _BOUNDARY = dt.time(2, 0) # journée de festival de 02:00 à 02:00
volunteer = _volunteer(first_name="Alex", last_name="Martin")
position = _position(name="Bar central")
shift = _shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0))
line = _position_line(position, shift, referent=None, volunteer_id=volunteer.id)
assert line == "Bar central 19:00–22:00"
def test_position_line_shows_the_referent_when_there_is_one(): def test_positions_table_groups_shifts_under_their_day():
volunteer = _volunteer(first_name="Alex", last_name="Martin") accueil = (
position = _position(name="Bar central")
shift = _shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0))
referent = _volunteer(first_name="Sam", last_name="Nuit")
line = _position_line(position, shift, referent=referent, volunteer_id=volunteer.id)
assert line == "Bar central 19:00–22:00 (Référent : Sam Nuit)"
def test_position_line_hides_the_referent_when_it_is_the_badged_volunteer():
# Pas besoin de se dire à soi-même qu'on est referent de son propre poste.
volunteer = _volunteer(first_name="Sam", last_name="Nuit")
line = _position_line(
_position(name="Bar central"),
_shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0)),
referent=volunteer,
volunteer_id=volunteer.id,
)
assert "Référent" not in line
def test_field_text_positions_lists_one_line_per_shift_in_chronological_order():
volunteer = _volunteer(first_name="Alex", last_name="Martin")
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.POSITIONS,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
)
morning = (
_position(name="Accueil"), _position(name="Accueil"),
_shift(dt.datetime(2026, 7, 9, 9, 0), dt.datetime(2026, 7, 9, 13, 0)), _shift(dt.datetime(2026, 7, 11, 9, 0), dt.datetime(2026, 7, 11, 13, 0)),
None,
) )
evening = ( bar = (
_position(name="Bar central"), _position(name="Bar central"),
_shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0)), _shift(dt.datetime(2026, 7, 11, 19, 0), dt.datetime(2026, 7, 11, 22, 0)),
None, )
demontage = (
_position(name="Démontage"),
_shift(dt.datetime(2026, 7, 12, 14, 0), dt.datetime(2026, 7, 12, 18, 0)),
) )
text = _field_text( table = _positions_table([accueil, bar, demontage], _BOUNDARY)
field, volunteer,
assignments_by_volunteer={volunteer.id: [morning, evening]}, assert [(s.title, s.rows) for s in table.sections] == [
lead_volunteer_ids=set(), ("Samedi 11", (("09:00–13:00", "Accueil"), ("19:00–22:00", "Bar central"))),
("Dimanche 12", (("14:00–18:00", "Démontage"),)),
]
def test_positions_table_keeps_a_night_shift_on_the_day_it_started():
night = (
_position(name="Bar central"),
_shift(dt.datetime(2026, 7, 12, 0, 0), dt.datetime(2026, 7, 12, 1, 0)),
) )
assert text == "Accueil 09:00–13:00\nBar central 19:00–22:00" table = _positions_table([night], _BOUNDARY)
assert table.sections[0].title == "Samedi 11"
assert table.sections[0].rows == (("00:00–01:00", "Bar central"),)
def test_positions_table_is_empty_without_any_shift():
assert _positions_table([], _BOUNDARY) is None
def test_field_text_role_is_referent_only_for_lead_accounts(): def test_field_text_role_is_referent_only_for_lead_accounts():
@@ -99,3 +79,47 @@ def test_field_text_role_is_referent_only_for_lead_accounts():
assert _field_text(field, volunteer, {}, lead_volunteer_ids=set()) == "Bénévole" assert _field_text(field, volunteer, {}, lead_volunteer_ids=set()) == "Bénévole"
assert _field_text(field, volunteer, {}, lead_volunteer_ids={volunteer.id}) == "Référent" assert _field_text(field, volunteer, {}, lead_volunteer_ids={volunteer.id}) == "Référent"
def test_field_text_free_text_is_the_volunteer_badge_text():
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.FREE_TEXT,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=8, color="#000000",
)
with_text = _volunteer(first_name="Alex", last_name="Martin", badge_text="Accès backstage")
without = _volunteer(first_name="Sam", last_name="Nuit")
assert _field_text(field, with_text, {}, set()) == "Accès backstage"
assert _field_text(field, without, {}, set()) == ""
def test_field_text_free_text_falls_back_to_the_common_editor_text():
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.FREE_TEXT,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=8, color="#000000",
text="Staff 2026",
)
own = _volunteer(first_name="Alex", last_name="Martin", badge_text="Accès backstage")
other = _volunteer(first_name="Sam", last_name="Nuit")
assert _field_text(field, own, {}, set()) == "Accès backstage"
assert _field_text(field, other, {}, set()) == "Staff 2026"
def test_field_text_full_name_is_first_name_then_last_name_on_two_lines():
volunteer = _volunteer(first_name="Alex", last_name="Martin")
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.FULL_NAME,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
)
assert _field_text(field, volunteer, {}, set()) == "Alex\nMartin"
def test_field_text_photo_is_empty():
volunteer = _volunteer(first_name="alex", last_name="martin")
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.PHOTO,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
)
# Une image, pas un texte : rien n'est imprimé sans photo.
assert _field_text(field, volunteer, {}, set()) == ""
+337
View File
@@ -1,14 +1,28 @@
"""Génération des planches de badges (module pur, sans base).""" """Génération des planches de badges (module pur, sans base)."""
import io
import pytest
from PIL import Image
from reportlab.lib.units import mm
from reportlab.pdfbase.pdfmetrics import stringWidth
from app.badges.pdf import ( from app.badges.pdf import (
ASSUMED_DPI,
DEFAULT_BADGE_HEIGHT_MM, DEFAULT_BADGE_HEIGHT_MM,
DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_WIDTH_MM,
BadgeFieldSpec, BadgeFieldSpec,
BadgeSpec, BadgeSpec,
BadgeTable,
BadgeTableSection,
_cell_origin, _cell_origin,
_column_widths,
_fit_photo,
_grid_size, _grid_size,
_spacing,
generate_badge_sheets_pdf, generate_badge_sheets_pdf,
image_size_mm, image_size_mm,
wrap_text,
) )
@@ -97,3 +111,326 @@ def test_more_badges_than_one_sheet_produces_a_larger_pdf():
[_badge() for _ in range(per_page + 1)], front_image=None, back_image=None [_badge() for _ in range(per_page + 1)], front_image=None, back_image=None
) )
assert len(two_pages) > len(one_page) assert len(two_pages) > len(one_page)
def _jpeg(width_px: int, height_px: int) -> bytes:
out = io.BytesIO()
Image.new("RGB", (width_px, height_px), "red").save(out, format="JPEG")
return out.getvalue()
def _photo_badge(image: bytes | None) -> BadgeSpec:
return BadgeSpec(
fields=(
BadgeFieldSpec(
text="", x_mm=60, y_mm=5, width_mm=20, height_mm=25,
font_size=8, align="LEFT", bold=False, color="#000000",
is_photo=True, image=image,
),
)
)
def test_fit_photo_crops_to_the_frame_ratio_at_print_resolution():
# Photo paysage 4000×3000 dans un cadre portrait 20×25 mm : recadrée
# (pas déformée) et réduite à 300 dpi.
fitted = _fit_photo(_jpeg(4000, 3000), width_mm=20, height_mm=25)
with Image.open(io.BytesIO(fitted)) as img:
assert img.size == (round(20 / 25.4 * ASSUMED_DPI), round(25 / 25.4 * ASSUMED_DPI))
def test_fit_photo_returns_none_for_an_unreadable_image():
assert _fit_photo(b"pas une image", width_mm=20, height_mm=25) is None
def test_photo_field_embeds_the_volunteer_photo():
without = generate_badge_sheets_pdf([_photo_badge(None)], front_image=None, back_image=None)
with_photo = generate_badge_sheets_pdf(
[_photo_badge(_jpeg(400, 500))], front_image=None, back_image=None
)
assert without.startswith(b"%PDF")
assert b"/Subtype /Image" in with_photo
assert b"/Subtype /Image" not in without
def test_unreadable_photo_does_not_crash_generation():
pdf = generate_badge_sheets_pdf(
[_photo_badge(b"pas une image")], front_image=None, back_image=None
)
assert pdf.startswith(b"%PDF")
def test_wrap_text_keeps_a_short_text_on_one_line():
assert wrap_text("Alex Martin", "Helvetica", 12, 60 * mm) == ["Alex Martin"]
def test_wrap_text_breaks_between_words_to_fit_the_width():
# Assez large pour « Marie-Christine » seul, trop étroit pour le tout.
width = 40 * mm
lines = wrap_text("Marie-Christine de La Fontaine", "Helvetica-Bold", 12, width)
assert len(lines) > 1
assert " ".join(lines) == "Marie-Christine de La Fontaine"
assert all(stringWidth(line, "Helvetica-Bold", 12) <= width for line in lines)
def test_wrap_text_keeps_explicit_line_breaks_and_wraps_each_line():
text = "Bar central 19:00–22:00 (Référent : Sam Nuit)\nAccueil 09:00–13:00"
width = 40 * mm
lines = wrap_text(text, "Helvetica", 8, width)
assert lines[0].startswith("Bar central 19:00–22:00")
assert "Accueil 09:00–13:00" in lines
assert all(stringWidth(line, "Helvetica", 8) <= width for line in lines)
def test_wrap_text_cuts_a_single_word_too_long_for_the_frame():
width = 10 * mm
lines = wrap_text("Anticonstitutionnellement", "Helvetica", 12, width)
assert "".join(lines) == "Anticonstitutionnellement"
assert all(stringWidth(line, "Helvetica", 12) <= width for line in lines)
def _table_badge(rows: int, height_mm: float = 30) -> BadgeSpec:
return BadgeSpec(
fields=(
BadgeFieldSpec(
text="", x_mm=5, y_mm=5, width_mm=60, height_mm=height_mm,
font_size=7, align="LEFT", bold=False, color="#000000",
table=BadgeTable(
sections=(
BadgeTableSection(
title="Samedi 11",
rows=tuple(
("19:00–22:00", "Bar central très très long")
for _ in range(rows)
),
),
)
),
),
)
)
def test_column_widths_keep_short_columns_and_share_the_rest():
widths = _column_widths([20, 30, 200, 100], available=200)
assert widths[:2] == [20, 30]
assert widths[2] == widths[3] == 75
assert sum(widths) == 200
def test_column_widths_give_the_spare_room_to_the_last_column():
assert _column_widths([20, 30, 40], available=150) == [20, 30, 100]
def test_table_field_generates_a_valid_pdf():
pdf = generate_badge_sheets_pdf([_table_badge(3)], front_image=None, back_image=None)
assert pdf.startswith(b"%PDF")
def test_table_rows_beyond_the_frame_are_not_drawn():
# Cadre trop petit pour 50 lignes : celles qui débordent sont omises,
# la planche fait la même taille qu'avec quelques lignes seulement.
few = generate_badge_sheets_pdf([_table_badge(4, height_mm=12)], None, None)
many = generate_badge_sheets_pdf([_table_badge(50, height_mm=12)], None, None)
assert abs(len(many) - len(few)) < 50
# --------------------------------------------------------------------------- #
# Éditeur v2 : verso, fond de zone, réduction automatique, photo #
# --------------------------------------------------------------------------- #
from app.badges import pdf as pdf_module # noqa: E402
from app.badges.pdf import fit_text # noqa: E402
def _text_field(**kw) -> BadgeFieldSpec:
base = dict(
text="Alex", x_mm=5, y_mm=5, width_mm=60, height_mm=10,
font_size=12, align="LEFT", bold=False, color="#000000",
)
base.update(kw)
return BadgeFieldSpec(**base)
def test_cell_origin_mirrors_the_column_on_the_back_page():
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
w, h = DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM
# Retournement bord long : colonne 0 au recto ⇒ dernière colonne au
# verso, même rangée.
assert _cell_origin(0, cols, w, h, mirrored=True) == _cell_origin(cols - 1, cols, w, h)
assert _cell_origin(cols, cols, w, h, mirrored=True) == _cell_origin(
2 * cols - 1, cols, w, h
)
def test_back_page_draws_each_volunteer_back_fields_at_the_mirrored_slot(monkeypatch):
calls = []
original = pdf_module._draw_fields
def spy(c, fields, x0, y0, badge_height_mm, photos):
calls.append((fields, x0, y0))
original(c, fields, x0, y0, badge_height_mm, photos)
monkeypatch.setattr(pdf_module, "_draw_fields", spy)
back = (_text_field(text="Verso Alex"),)
# Un seul badge : planche incomplète, le verso doit quand même être en
# miroir (colonne de droite).
generate_badge_sheets_pdf(
[BadgeSpec(fields=(_text_field(),), back_fields=back)], None, None
)
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
w, h = DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM
(front_fields, fx, fy), (back_fields, bx, by) = calls
assert (fx, fy) == _cell_origin(0, cols, w, h)
assert back_fields == back
assert (bx, by) == _cell_origin(cols - 1, cols, w, h)
def test_fit_text_without_auto_fit_drops_the_lines_that_overflow():
text = "\n".join(["ligne"] * 10)
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=False)
assert size == 12
assert 0 < len(lines) < 10
def test_fit_text_with_auto_fit_shrinks_until_every_line_fits():
text = "\n".join(["ligne"] * 4)
assert len(fit_text(text, "Helvetica", 12, 60, 10, auto_fit=False)[1]) < 4
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=True)
assert 4 < size < 12
assert (size * 2) == int(size * 2) # pas de 0,5 pt
assert len(lines) == 4
def test_fit_text_auto_fit_stops_at_the_minimum_size():
text = "\n".join(["ligne"] * 200)
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=True)
assert size == 4.0
assert len(lines) < 200
def test_fit_text_auto_fit_keeps_the_size_when_the_text_already_fits():
assert fit_text("Alex", "Helvetica", 12, 60, 10, auto_fit=True) == (12, ["Alex"])
def _table_lines_drawn(monkeypatch, auto_fit: bool) -> int:
drawn = []
monkeypatch.setattr(
pdf_module.canvas.Canvas, "drawString",
lambda self, x, y, text, *a, **k: drawn.append(text),
)
badge = _table_badge(4, height_mm=20)
field = badge.fields[0]
badge = BadgeSpec(fields=(BadgeFieldSpec(**{**field.__dict__, "auto_fit": auto_fit}),))
generate_badge_sheets_pdf([badge], None, None)
return sum(1 for text in drawn if text == "19:00–22:00")
def test_table_auto_fit_draws_every_row(monkeypatch):
# En-tête + 4 lignes : la dernière ne tient pas à 7 pt dans 20 mm.
assert _table_lines_drawn(monkeypatch, auto_fit=False) < 4
assert _table_lines_drawn(monkeypatch, auto_fit=True) == 4
def _filled_rects(monkeypatch) -> list:
fills = []
original = pdf_module.canvas.Canvas.rect
def spy(self, x, y, w, h, stroke=1, fill=0):
if fill:
fills.append((round(w / mm, 3), round(h / mm, 3)))
return original(self, x, y, w, h, stroke=stroke, fill=fill)
monkeypatch.setattr(pdf_module.canvas.Canvas, "rect", spy)
return fills
def test_zone_background_fills_the_field_frame(monkeypatch):
fills = _filled_rects(monkeypatch)
generate_badge_sheets_pdf(
[BadgeSpec(fields=(_text_field(bg_color="#FFD23F"),))], None, None
)
assert fills == [(60, 10)]
def test_no_zone_background_by_default(monkeypatch):
fills = _filled_rects(monkeypatch)
generate_badge_sheets_pdf([BadgeSpec(fields=(_text_field(),))], None, None)
assert fills == []
def test_middle_aligned_text_is_lower_than_top_aligned_text(monkeypatch):
baselines = []
monkeypatch.setattr(
pdf_module.canvas.Canvas, "drawString",
lambda self, x, y, text, *a, **k: baselines.append(y),
)
for valign in ("TOP", "MIDDLE"):
generate_badge_sheets_pdf(
[BadgeSpec(fields=(_text_field(height_mm=30, valign=valign),))], None, None
)
top, middle = baselines
assert middle < top
def test_missing_photo_prints_nothing(monkeypatch):
calls = []
for name in ("drawImage", "drawPath", "drawCentredString", "drawString"):
monkeypatch.setattr(
pdf_module.canvas.Canvas, name,
lambda self, *a, _name=name, **k: calls.append(_name),
)
for image in (None, b"pas une image"):
generate_badge_sheets_pdf([_photo_badge(image)], None, None)
assert calls == []
def test_oval_photo_and_missing_oval_photo_generate_a_valid_pdf():
for image in (_jpeg(400, 500), None):
field = _photo_badge(image).fields[0]
badge = BadgeSpec(
fields=(BadgeFieldSpec(**{**field.__dict__, "photo_shape": "OVAL"}),)
)
pdf = generate_badge_sheets_pdf([badge], None, None)
assert pdf.startswith(b"%PDF")
@pytest.mark.parametrize(
"size", [(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM), (54, 85.6), (50, 50), (74, 105)]
)
def test_grid_is_centred_with_equal_gaps_and_back_falls_behind_front(size):
from app.badges.pdf import _PAGE_HEIGHT_MM, _PAGE_WIDTH_MM
w, h = size
cols, rows = _grid_size(w, h)
xs = sorted({_cell_origin(i, cols, w, h)[0] for i in range(cols * rows)})
ys = sorted({_cell_origin(i, cols, w, h)[1] for i in range(cols * rows)})
left, right = xs[0], _PAGE_WIDTH_MM - (xs[-1] + w)
bottom, top = ys[0], _PAGE_HEIGHT_MM - (ys[-1] + h)
# Symétrique : même marge à gauche et à droite, en haut et en bas.
assert left == pytest.approx(right)
assert top == pytest.approx(bottom)
# Écarts entre badges tous égaux, et égaux aux marges quand c'est possible.
gaps_x = {round(b - a - w, 6) for a, b in zip(xs, xs[1:], strict=False)}
gaps_y = {round(b - a - h, 6) for a, b in zip(ys, ys[1:], strict=False)}
assert len(gaps_x) <= 1 and len(gaps_y) <= 1
_, gap_x = _spacing(cols, _PAGE_WIDTH_MM, w)
if gap_x >= 10 and cols > 1:
assert left == pytest.approx(gap_x)
# Verso : exactement derrière le recto une fois la feuille retournée
# sur son bord long (miroir horizontal).
for i in range(cols * rows):
fx, fy = _cell_origin(i, cols, w, h)
bx, by = _cell_origin(i, cols, w, h, mirrored=True)
assert bx == pytest.approx(_PAGE_WIDTH_MM - fx - w)
assert by == pytest.approx(fy)
def test_cr80_gaps_are_even():
# 2 × 85,6 mm sur 210 mm : 12,9 mm à gauche, entre les deux, à droite.
assert _spacing(2, 210, 85.6) == pytest.approx((12.9333, 12.9333), abs=1e-3)
+50
View File
@@ -716,6 +716,56 @@ def test_paying_promotes_the_pending_submission_to_a_volunteer(
assert fiche["willing_to_pay_fee"] is True assert fiche["willing_to_pay_fee"] is True
def test_checkout_description_names_the_volunteer(raw_client, client, edition, monkeypatch):
# Pas de champ de métadonnées libre à la création d'un checkout SumUp :
# c'est la description (visible dans le tableau de bord SumUp) qui porte
# le nom, pour rapprocher la transaction du bénévole.
option_id = _configure_sumup(client, edition)
calls = []
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: calls.append(kw)
or SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
)
_submit(
raw_client, edition,
first_name="alex", last_name="martin", hcaptcha_token="x",
payment_option_id=option_id,
)
assert calls[0]["description"] == (
f"Alex Martin — Cotisation classique — édition {edition.year}"
)
def test_promotion_records_the_sumup_transaction(raw_client, client, edition, monkeypatch):
option_id = _configure_sumup(client, edition)
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
)
token = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
).json()["pending_token"]
monkeypatch.setattr(
"app.api.forms.get_checkout",
lambda **kw: SumUpCheckout(
id="co_1",
status="PAID",
hosted_checkout_url=None,
transaction_code="TEENSK4W2K",
transaction_id="tx-uuid-1",
),
)
volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"]
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
assert fiche["fee_transaction_code"] == "TEENSK4W2K"
assert fiche["fee_transaction_id"] == "tx-uuid-1"
def test_payment_confirmation_sends_an_email_on_promotion( def test_payment_confirmation_sends_an_email_on_promotion(
raw_client, client, edition, monkeypatch, outbox raw_client, client, edition, monkeypatch, outbox
): ):
-27
View File
@@ -5,7 +5,6 @@ from sqlalchemy import text
from app.models import ( from app.models import (
Assignment, Assignment,
AssignmentSource, AssignmentSource,
Availability,
Edition, Edition,
Position, Position,
PositionCategory, PositionCategory,
@@ -25,7 +24,6 @@ EXPECTED_TABLES = {
"magic_link_token", "magic_link_token",
"position", "position",
"shift", "shift",
"availability",
"assignment", "assignment",
"badge_template", "badge_template",
"badge_field", "badge_field",
@@ -101,31 +99,6 @@ def test_create_core_graph(session):
assert volunteer.assignments[0].id == assignment.id assert volunteer.assignments[0].id == assignment.id
def test_availability_window_round_trip(session):
edition = _edition(session)
volunteer = Volunteer(
edition_id=edition.id, first_name="Sam", last_name="Nuit"
)
session.add(volunteer)
session.commit()
session.refresh(volunteer)
avail = Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=dt.date(2026, 7, 9),
start_time=dt.time(19, 0),
end_time=dt.time(2, 0), # passe minuit
)
session.add(avail)
session.commit()
session.expire_all()
reloaded = session.get(Availability, avail.id)
assert reloaded.start_time == dt.time(19, 0)
assert reloaded.end_time == dt.time(2, 0)
def test_volunteer_email_can_be_shared(session): def test_volunteer_email_can_be_shared(session):
# Un couple, une famille : même boîte mail, deux bénévoles distincts — # Un couple, une famille : même boîte mail, deux bénévoles distincts —
# ce n'est plus une contrainte d'unicité en base (voir migration 0009). # ce n'est plus une contrainte d'unicité en base (voir migration 0009).
+47
View File
@@ -149,3 +149,50 @@ def test_position_ids_filter_scopes_the_sheets(client, edition, session):
resp = client.post(_url(edition), json={"position_ids": [str(welcome.id)]}) resp = client.post(_url(edition), json={"position_ids": [str(welcome.id)]})
assert resp.status_code == 200 assert resp.status_code == 200
def test_a_night_that_passes_midnight_stays_on_its_festival_day(client, edition, session):
# Début de journée = 06:00 (premier créneau type de la fixture) : 00h–02h
# le 10 appartient encore à la soirée du 9 — une seule page, pas deux.
from io import BytesIO
from pypdf import PdfReader
position = make_position(session, edition, name="Bar de nuit")
for start, end in [
(dt_at(9, 22), dt_at(9, 23)),
(dt_at(9, 23), dt_at(10, 0)),
(dt_at(10, 0), dt_at(10, 1)),
(dt_at(10, 1), dt_at(10, 2)),
]:
make_shift(session, edition, position, start=start, end=end)
resp = client.post(_url(edition), json={})
assert resp.status_code == 200
reader = PdfReader(BytesIO(resp.content))
assert len(reader.pages) == 1
text = reader.pages[0].extract_text()
assert "jeudi 9 juillet" in text.lower()
assert "01:00" in text
def test_organisation_message_is_passed_as_the_page_footer(client, edition, session, monkeypatch):
import app.api.position_sheets as sheets_api
captured = {}
original = sheets_api.generate_position_sheets_pdf
def spy(sheets, footer=None):
captured["footer"] = footer
return original(sheets, footer)
monkeypatch.setattr(sheets_api, "generate_position_sheets_pdf", spy)
position = make_position(session, edition)
make_shift(session, edition, position, start=dt_at(9, 19), end=dt_at(9, 22))
assert client.post(_url(edition), json={}).status_code == 200
assert captured["footer"] is None
client.patch(f"/editions/{edition.id}", json={"volunteer_message": "Repas à 18h30"})
assert client.post(_url(edition), json={}).status_code == 200
assert captured["footer"].message == "Repas à 18h30"
+84
View File
@@ -2,6 +2,7 @@
import base64 import base64
import datetime as dt import datetime as dt
import io
from app.sheets.pdf import ( from app.sheets.pdf import (
PositionDaySheet, PositionDaySheet,
@@ -142,3 +143,86 @@ def test_generates_a_valid_pdf_without_a_lineup():
assert sheet.artists == () assert sheet.artists == ()
pdf = generate_position_sheets_pdf([sheet]) pdf = generate_position_sheets_pdf([sheet])
assert pdf.startswith(b"%PDF") assert pdf.startswith(b"%PDF")
def _phone_photo(seed: int) -> bytes:
"""Photo de téléphone : 3000 × 4000 px, plusieurs Mo."""
from PIL import Image as PILImage
img = PILImage.new("RGB", (3000, 4000), (seed * 40 % 255, 120, 60))
img.paste(PILImage.effect_noise((800, 800), 80).convert("RGB"), (seed * 100, 200))
out = io.BytesIO()
img.save(out, format="JPEG", quality=92)
return out.getvalue()
def test_large_photos_are_shrunk_to_the_thumbnail():
# Régression : la photo d'origine était intégrée telle quelle pour
# chaque bénévole de chaque créneau — génération interminable, PDF
# énorme, puis erreur.
photos = [_phone_photo(i) for i in range(5)]
volunteers = tuple(
SheetVolunteer(name=f"Bénévole {i}", phone=None, photo=p) for i, p in enumerate(photos)
)
pdf = generate_position_sheets_pdf([_sheet(volunteers=volunteers)])
assert sum(len(p) for p in photos) > 2_000_000
assert len(pdf) < 200_000
def test_unreadable_photo_does_not_break_the_sheet():
pdf = generate_position_sheets_pdf(
[_sheet(volunteers=(SheetVolunteer(name="Alex", phone=None, photo=b"pas une image"),))]
)
assert pdf.startswith(b"%PDF")
def _page_texts(pdf: bytes) -> list[str]:
from pypdf import PdfReader
return [page.extract_text() for page in PdfReader(io.BytesIO(pdf)).pages]
def test_footer_message_is_printed_at_the_bottom_of_every_page():
from app.sheets.pdf import SheetFooter
# Deux feuilles, dont une assez longue pour déborder sur une 2e page.
many = tuple(SheetVolunteer(name=f"Bénévole {i}", phone=None, photo=None) for i in range(120))
pdf = generate_position_sheets_pdf(
[_sheet(volunteers=many), _sheet()],
SheetFooter(title="Message de l'organisation", message="Repas à 18h30\nBadges à l'accueil"),
)
pages = _page_texts(pdf)
assert len(pages) >= 3
for text in pages:
assert "Message de l'organisation" in text
assert "Repas à 18h30" in text
assert "Badges à l'accueil" in text
def test_no_footer_without_message():
pages = _page_texts(generate_position_sheets_pdf([_sheet()]))
assert "Message de l'organisation" not in pages[0]
def test_volunteers_are_listed_two_per_row():
from app.sheets.pdf import _shift_table, _styles
shift = _sheet(
volunteers=tuple(SheetVolunteer(name=f"B{i}", phone=None, photo=None) for i in range(5))
).shifts[0]
table = _shift_table(shift, {}, _styles(), 180)
# 5 bénévoles, deux par ligne : 3 lignes (la dernière à moitié vide).
assert len(table._cellvalues) == 3
def test_a_typical_sheet_fits_on_one_page():
volunteers = tuple(
SheetVolunteer(name=f"Prénom{i} Nom{i}", phone="06 12 34 56 78", photo=_PNG)
for i in range(8)
)
shift = _sheet(volunteers=volunteers).shifts[0]
sheet = _sheet(volunteers=volunteers)
many_shifts = PositionDaySheet(
**{**sheet.__dict__, "shifts": (shift, shift, shift, shift)}
)
assert len(_page_texts(generate_position_sheets_pdf([many_shifts]))) == 1
+78
View File
@@ -0,0 +1,78 @@
"""Journal des requêtes (`app/request_logging.py`) : référence de requête,
route modèle sans jeton ni chaîne de requête, erreurs détaillées sans
valeur saisie."""
import logging
import pytest
from fastapi.testclient import TestClient
from app.main import app
def test_each_response_carries_a_request_id(client):
resp = client.get("/health")
assert len(resp.headers["X-Request-ID"]) >= 8
def test_a_well_formed_incoming_request_id_is_kept(client):
resp = client.get("/health", headers={"X-Request-ID": "caddy-1234abcd"})
assert resp.headers["X-Request-ID"] == "caddy-1234abcd"
def test_a_malformed_incoming_request_id_is_replaced(client):
resp = client.get("/health", headers={"X-Request-ID": "x\nforged log line"})
assert resp.headers["X-Request-ID"] != "x\nforged log line"
def test_access_line_uses_the_route_template_and_hides_tokens(raw_client, edition, caplog):
with caplog.at_level(logging.INFO, logger="app.request"):
raw_client.get(f"/editions/{edition.id}/form/public?token=secret-access-token")
line = next(r.getMessage() for r in caplog.records if r.name == "app.request")
assert "/editions/{edition_id}/form/public" in line
assert f"edition_id={edition.id}" in line
assert "secret-access-token" not in caplog.text
def test_business_error_code_is_logged(client, edition, caplog):
with caplog.at_level(logging.INFO, logger="app.request"):
resp = client.get(f"/editions/{edition.id}/volunteers/00000000-0000-0000-0000-000000000000")
assert resp.status_code == 404
record = next(r for r in caplog.records if r.name == "app.request")
assert record.levelno == logging.WARNING
assert "code=error.not_found" in record.getMessage()
def test_validation_error_logs_the_field_but_not_the_value(client, edition, caplog):
with caplog.at_level(logging.INFO, logger="app.request"):
resp = client.post(
f"/editions/{edition.id}/volunteers",
json={"first_name": "Alexandra-Secret", "last_name": 12345678},
)
assert resp.status_code == 422
assert "last_name" in caplog.text
assert "Alexandra-Secret" not in caplog.text
@pytest.fixture
def boom_route():
def boom():
raise RuntimeError("panne")
app.add_api_route("/__boom", boom)
yield
app.router.routes = [r for r in app.router.routes if getattr(r, "path", None) != "/__boom"]
def test_unhandled_exception_returns_a_reference(boom_route, caplog):
with caplog.at_level(logging.ERROR, logger="app.request"):
resp = TestClient(app).get("/__boom")
assert resp.status_code == 500
body = resp.json()
assert body["detail"]["code"] == "error.internal"
assert body["detail"]["request_id"] == resp.headers["X-Request-ID"]
assert "RuntimeError" in caplog.text
-4
View File
@@ -13,9 +13,6 @@ def test_build_creates_a_realistic_dataset(session):
assert summary.assignments > 100 assert summary.assignments > 100
assert summary.understaffed_shifts >= 3 # « quelques trous d'effectif » assert summary.understaffed_shifts >= 3 # « quelques trous d'effectif »
assert summary.blocking == 0 # aucune violation bloquante assert summary.blocking == 0 # aucune violation bloquante
# Au moins deux règles d'avertissement représentées, quelques alertes.
assert len(summary.warnings) >= 2
assert sum(summary.warnings.values()) >= 3
# Programmation et vœux des bénévoles : le mode démo doit les illustrer. # Programmation et vœux des bénévoles : le mode démo doit les illustrer.
assert summary.artists > 0 assert summary.artists > 0
assert summary.position_wishes > 0 assert summary.position_wishes > 0
@@ -58,7 +55,6 @@ def test_build_is_deterministic_and_idempotent(session):
) )
assert second.assignments == first.assignments assert second.assignments == first.assignments
assert second.understaffed_shifts == first.understaffed_shifts assert second.understaffed_shifts == first.understaffed_shifts
assert dict(second.warnings) == dict(first.warnings)
assert second.artists == first.artists assert second.artists == first.artists
assert second.position_wishes == first.position_wishes assert second.position_wishes == first.position_wishes
assert second.artist_wishes == first.artist_wishes assert second.artist_wishes == first.artist_wishes
+66 -68
View File
@@ -4,7 +4,7 @@ import datetime as dt
from app.solver.engine import solve from app.solver.engine import solve
from app.solver.models import SolverInput, SolverShift, SolverVolunteer from app.solver.models import SolverInput, SolverShift, SolverVolunteer
from app.validation.models import AvailabilityWindow, ShiftInfo from app.validation.models import ShiftInfo
def _dt(day: int, hour: int, minute: int = 0) -> dt.datetime: def _dt(day: int, hour: int, minute: int = 0) -> dt.datetime:
@@ -39,7 +39,6 @@ def _shift(
def _volunteer( def _volunteer(
vid: str, vid: str,
*, *,
availability=(),
wished_position_ids=frozenset(), wished_position_ids=frozenset(),
wished_companion_ids=frozenset(), wished_companion_ids=frozenset(),
wished_artist_windows=(), wished_artist_windows=(),
@@ -48,7 +47,6 @@ def _volunteer(
) -> SolverVolunteer: ) -> SolverVolunteer:
return SolverVolunteer( return SolverVolunteer(
id=vid, id=vid,
availability=tuple(availability),
wished_position_ids=frozenset(wished_position_ids), wished_position_ids=frozenset(wished_position_ids),
wished_companion_ids=frozenset(wished_companion_ids), wished_companion_ids=frozenset(wished_companion_ids),
wished_artist_windows=tuple(wished_artist_windows), wished_artist_windows=tuple(wished_artist_windows),
@@ -57,10 +55,6 @@ def _volunteer(
) )
def _full_day(day: int) -> AvailabilityWindow:
return AvailabilityWindow(dt.date(2026, 7, day), dt.time(6, 0), dt.time(4, 0))
def _by_shift(solution, shift_id: str) -> tuple[str, ...]: def _by_shift(solution, shift_id: str) -> tuple[str, ...]:
for s in solution.suggestions: for s in solution.suggestions:
if s.shift_id == shift_id: if s.shift_id == shift_id:
@@ -75,114 +69,120 @@ def test_no_variables_returns_empty_solution():
def test_fully_staffed_shift_gets_no_suggestion(): def test_fully_staffed_shift_gets_no_suggestion():
shift = _shift(ideal_staff=1, already_assigned=1) shift = _shift(ideal_staff=1, already_assigned=1)
volunteer = _volunteer("v1", availability=[_full_day(10)]) volunteer = _volunteer("v1")
solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,))) solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,)))
assert solution.suggestions == () assert solution.suggestions == ()
def test_busy_volunteer_is_still_suggested_for_an_urgent_gap_as_last_resort():
# Déjà affecté ailleurs au même moment : malus, mais seul candidat pour un
# créneau sous son minimum ⇒ proposé quand même.
shift = _shift(min_staff=1, ideal_staff=1)
busy = _volunteer(
"busy",
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 15), ends_at=_dt(10, 19))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(busy,)))
assert _by_shift(solution, "s1") == ("busy",)
def test_volunteer_already_on_the_shift_is_never_suggested_again():
shift = _shift(min_staff=2, ideal_staff=2, already_assigned=1)
on_it = _volunteer(
"on_it",
existing_shifts=[ShiftInfo(id="s1", starts_at=_dt(10, 14), ends_at=_dt(10, 18))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(on_it,)))
assert _by_shift(solution, "s1") == ()
def test_busy_volunteer_is_not_suggested_above_the_minimum():
# Minimum déjà atteint : le malus l'emporte, personne n'est proposé.
shift = _shift(min_staff=1, ideal_staff=2, already_assigned=1)
busy = _volunteer(
"busy",
wished_position_ids={"p1"},
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 15), ends_at=_dt(10, 19))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(busy,)))
assert _by_shift(solution, "s1") == ()
def test_picks_the_only_free_volunteer_over_a_busy_one(): def test_picks_the_only_free_volunteer_over_a_busy_one():
shift = _shift() shift = _shift()
busy = _volunteer( busy = _volunteer(
"busy", "busy",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 15), ends_at=_dt(10, 19))], existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 15), ends_at=_dt(10, 19))],
) )
free = _volunteer("free", availability=[_full_day(10)]) free = _volunteer("free")
solution = solve(SolverInput(shifts=(shift,), volunteers=(busy, free))) solution = solve(SolverInput(shifts=(shift,), volunteers=(busy, free)))
assert _by_shift(solution, "s1") == ("free",) assert _by_shift(solution, "s1") == ("free",)
def test_does_not_exceed_ideal_staff_cap(): def test_does_not_exceed_ideal_staff_cap():
shift = _shift(ideal_staff=2, already_assigned=1) # il ne manque qu'une place shift = _shift(ideal_staff=2, already_assigned=1) # il ne manque qu'une place
a = _volunteer("a", availability=[_full_day(10)]) a = _volunteer("a")
b = _volunteer("b", availability=[_full_day(10)]) b = _volunteer("b")
solution = solve(SolverInput(shifts=(shift,), volunteers=(a, b))) solution = solve(SolverInput(shifts=(shift,), volunteers=(a, b)))
assert len(_by_shift(solution, "s1")) == 1 assert len(_by_shift(solution, "s1")) == 1
def test_prefers_volunteer_with_matching_position_wish(): def test_prefers_volunteer_with_matching_position_wish():
shift = _shift(position_id="regie") shift = _shift(position_id="regie")
plain = _volunteer("plain", availability=[_full_day(10)]) plain = _volunteer("plain")
wisher = _volunteer( wisher = _volunteer(
"wisher", availability=[_full_day(10)], wished_position_ids={"regie"} "wisher", wished_position_ids={"regie"}
) )
solution = solve(SolverInput(shifts=(shift,), volunteers=(plain, wisher))) solution = solve(SolverInput(shifts=(shift,), volunteers=(plain, wisher)))
assert _by_shift(solution, "s1") == ("wisher",) assert _by_shift(solution, "s1") == ("wisher",)
def test_prefers_fully_available_volunteer_over_partially_available():
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
full = _volunteer("full", availability=[_full_day(10)])
partial = _volunteer(
"partial",
availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial)))
assert _by_shift(solution, "s1") == ("full",)
def test_ranks_both_candidates_when_two_slots_are_open():
shift = _shift(ideal_staff=2)
full = _volunteer("full", availability=[_full_day(10)])
partial = _volunteer(
"partial",
availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial)))
assert _by_shift(solution, "s1") == ("full", "partial")
def test_never_double_books_a_volunteer_across_two_new_suggestions(): def test_never_double_books_a_volunteer_across_two_new_suggestions():
# Deux créneaux qui se chevauchent, un seul bénévole disponible pour les # Deux créneaux qui se chevauchent, un seul bénévole disponible pour les
# deux : le solveur ne le propose qu'une fois. # deux : le solveur ne le propose qu'une fois.
shift_a = _shift("a", start=_dt(10, 14), end=_dt(10, 18)) shift_a = _shift("a", start=_dt(10, 14), end=_dt(10, 18))
shift_b = _shift("b", start=_dt(10, 16), end=_dt(10, 20)) shift_b = _shift("b", start=_dt(10, 16), end=_dt(10, 20))
only = _volunteer("only", availability=[_full_day(10)]) only = _volunteer("only")
solution = solve(SolverInput(shifts=(shift_a, shift_b), volunteers=(only,))) solution = solve(SolverInput(shifts=(shift_a, shift_b), volunteers=(only,)))
picked_a = _by_shift(solution, "a") picked_a = _by_shift(solution, "a")
picked_b = _by_shift(solution, "b") picked_b = _by_shift(solution, "b")
assert len(picked_a) + len(picked_b) == 1 assert len(picked_a) + len(picked_b) == 1
def test_avoids_volunteer_with_insufficient_rest_since_last_shift(): def test_short_rest_is_not_penalized():
# < 8 h entre la fin d'un créneau déjà décidé et le début de celui-ci. # Plus de repos minimal (8 h) : 4 h après un créneau déjà décidé, le
shift = _shift(start=_dt(10, 14), end=_dt(10, 18)) # bénévole reste à égalité avec un autre.
shift = _shift(ideal_staff=2, start=_dt(10, 14), end=_dt(10, 18))
tired = _volunteer( tired = _volunteer(
"tired", "tired",
availability=[_full_day(10)], existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 8), ends_at=_dt(10, 10))],
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 8), ends_at=_dt(10, 10))], # repos 4 h
) )
fresh = _volunteer("fresh", availability=[_full_day(10)]) fresh = _volunteer("fresh")
solution = solve(SolverInput(shifts=(shift,), volunteers=(tired, fresh))) solution = solve(SolverInput(shifts=(shift,), volunteers=(tired, fresh)))
assert _by_shift(solution, "s1") == ("fresh",) assert set(_by_shift(solution, "s1")) == {"tired", "fresh"}
def test_daily_cumulative_hours_no_longer_penalized(): def test_daily_cumulative_hours_no_longer_penalized():
# Le solveur ne plafonne plus le cumul de 8 h/jour (retiré) : seule la # Aucun plafond de cumul journalier : 7 h + 2 h = 9 h sur la journée ne
# validation, à la confirmation, le signale désormais # pénalise pas le bénévole.
# (`DAILY_HOURS_EXCEEDED`). Repos respecté (8 h pile) ; cumul du jour
# (7 h + 2 h = 9 h) aurait auparavant été pénalisé.
shift = _shift(ideal_staff=2, start=_dt(10, 21), end=_dt(10, 23)) shift = _shift(ideal_staff=2, start=_dt(10, 21), end=_dt(10, 23))
long_day = _volunteer( long_day = _volunteer(
"long_day", "long_day",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 6), ends_at=_dt(10, 13))], existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 6), ends_at=_dt(10, 13))],
) )
fresh = _volunteer("fresh", availability=[_full_day(10)]) fresh = _volunteer("fresh")
solution = solve(SolverInput(shifts=(shift,), volunteers=(long_day, fresh))) solution = solve(SolverInput(shifts=(shift,), volunteers=(long_day, fresh)))
assert set(_by_shift(solution, "s1")) == {"long_day", "fresh"} assert set(_by_shift(solution, "s1")) == {"long_day", "fresh"}
def test_prefers_volunteer_without_the_immediately_preceding_shift(): def test_prefers_volunteer_without_the_immediately_preceding_shift():
# Repos suffisant (8 h pile) entre les deux créneaux : seul le chaînage # Seul le chaînage doit différencier les deux bénévoles.
# doit différencier les deux bénévoles.
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"}) shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"})
chained = _volunteer( chained = _volunteer(
"chained", "chained",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))], existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
) )
fresh = _volunteer("fresh", availability=[_full_day(10)]) fresh = _volunteer("fresh")
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained, fresh))) solution = solve(SolverInput(shifts=(shift,), volunteers=(chained, fresh)))
assert _by_shift(solution, "s1") == ("fresh",) assert _by_shift(solution, "s1") == ("fresh",)
@@ -196,10 +196,9 @@ def test_chain_penalty_applies_across_different_positions():
) )
chained = _volunteer( chained = _volunteer(
"chained", "chained",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))], existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
) )
fresh = _volunteer("fresh", availability=[_full_day(10)]) fresh = _volunteer("fresh")
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained, fresh))) solution = solve(SolverInput(shifts=(shift,), volunteers=(chained, fresh)))
assert _by_shift(solution, "s1") == ("fresh",) assert _by_shift(solution, "s1") == ("fresh",)
@@ -210,7 +209,6 @@ def test_still_suggests_the_only_candidate_despite_chaining():
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"}) shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"})
chained = _volunteer( chained = _volunteer(
"chained", "chained",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))], existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
) )
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained,))) solution = solve(SolverInput(shifts=(shift,), volunteers=(chained,)))
@@ -227,9 +225,9 @@ def test_an_unrelated_existing_shift_does_not_trigger_the_chain_penalty():
) )
unrelated = ShiftInfo(id="not-the-predecessor", starts_at=_dt(10, 8), ends_at=_dt(10, 12)) unrelated = ShiftInfo(id="not-the-predecessor", starts_at=_dt(10, 8), ends_at=_dt(10, 12))
almost_chained = _volunteer( almost_chained = _volunteer(
"almost_chained", availability=[_full_day(10)], existing_shifts=[unrelated] "almost_chained", existing_shifts=[unrelated]
) )
truly_fresh = _volunteer("truly_fresh", availability=[_full_day(10)]) truly_fresh = _volunteer("truly_fresh")
solution = solve(SolverInput(shifts=(shift,), volunteers=(almost_chained, truly_fresh))) solution = solve(SolverInput(shifts=(shift,), volunteers=(almost_chained, truly_fresh)))
assert set(_by_shift(solution, "s1")) == {"almost_chained", "truly_fresh"} assert set(_by_shift(solution, "s1")) == {"almost_chained", "truly_fresh"}
@@ -241,9 +239,9 @@ def test_avoids_volunteer_whose_wished_artist_plays_during_the_shift():
shift = _shift(start=_dt(10, 14), end=_dt(10, 18)) shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
passage = ShiftInfo(id="art1", starts_at=_dt(10, 16), ends_at=_dt(10, 17)) passage = ShiftInfo(id="art1", starts_at=_dt(10, 16), ends_at=_dt(10, 17))
fan = _volunteer( fan = _volunteer(
"fan", availability=[_full_day(10)], wished_artist_windows=[passage] "fan", wished_artist_windows=[passage]
) )
indiff = _volunteer("indiff", availability=[_full_day(10)]) indiff = _volunteer("indiff")
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan, indiff))) solution = solve(SolverInput(shifts=(shift,), volunteers=(fan, indiff)))
assert _by_shift(solution, "s1") == ("indiff",) assert _by_shift(solution, "s1") == ("indiff",)
@@ -254,7 +252,7 @@ def test_still_suggests_the_only_candidate_despite_an_artist_conflict():
shift = _shift(start=_dt(10, 14), end=_dt(10, 18)) shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
passage = ShiftInfo(id="art1", starts_at=_dt(10, 16), ends_at=_dt(10, 17)) passage = ShiftInfo(id="art1", starts_at=_dt(10, 16), ends_at=_dt(10, 17))
fan = _volunteer( fan = _volunteer(
"fan", availability=[_full_day(10)], wished_artist_windows=[passage] "fan", wished_artist_windows=[passage]
) )
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,))) solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,)))
assert _by_shift(solution, "s1") == ("fan",) assert _by_shift(solution, "s1") == ("fan",)
@@ -266,7 +264,7 @@ def test_artist_conflict_outside_shift_hours_has_no_effect():
shift = _shift(start=_dt(10, 14), end=_dt(10, 18)) shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
passage = ShiftInfo(id="art1", starts_at=_dt(10, 20), ends_at=_dt(10, 21)) passage = ShiftInfo(id="art1", starts_at=_dt(10, 20), ends_at=_dt(10, 21))
fan = _volunteer( fan = _volunteer(
"fan", availability=[_full_day(10)], wished_artist_windows=[passage] "fan", wished_artist_windows=[passage]
) )
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,))) solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,)))
assert _by_shift(solution, "s1") == ("fan",) assert _by_shift(solution, "s1") == ("fan",)
@@ -277,9 +275,9 @@ def test_prefers_volunteer_whose_wished_companion_is_already_on_the_shift():
# préféré à « stranger », qui n'a pas ce lien. # préféré à « stranger », qui n'a pas ce lien.
shift = _shift(ideal_staff=2, already_assigned=1, assigned_volunteer_ids={"copain"}) shift = _shift(ideal_staff=2, already_assigned=1, assigned_volunteer_ids={"copain"})
buddy = _volunteer( buddy = _volunteer(
"buddy", availability=[_full_day(10)], wished_companion_ids={"copain"} "buddy", wished_companion_ids={"copain"}
) )
stranger = _volunteer("stranger", availability=[_full_day(10)]) stranger = _volunteer("stranger")
solution = solve(SolverInput(shifts=(shift,), volunteers=(buddy, stranger))) solution = solve(SolverInput(shifts=(shift,), volunteers=(buddy, stranger)))
assert _by_shift(solution, "s1") == ("buddy",) assert _by_shift(solution, "s1") == ("buddy",)
@@ -290,10 +288,10 @@ def test_companion_bonus_scales_with_number_of_matches():
ideal_staff=4, already_assigned=2, assigned_volunteer_ids={"a", "b"} ideal_staff=4, already_assigned=2, assigned_volunteer_ids={"a", "b"}
) )
two_friends = _volunteer( two_friends = _volunteer(
"two_friends", availability=[_full_day(10)], wished_companion_ids={"a", "b"} "two_friends", wished_companion_ids={"a", "b"}
) )
one_friend = _volunteer( one_friend = _volunteer(
"one_friend", availability=[_full_day(10)], wished_companion_ids={"a"} "one_friend", wished_companion_ids={"a"}
) )
solution = solve(SolverInput(shifts=(shift,), volunteers=(two_friends, one_friend))) solution = solve(SolverInput(shifts=(shift,), volunteers=(two_friends, one_friend)))
assert _by_shift(solution, "s1") == ("two_friends", "one_friend") assert _by_shift(solution, "s1") == ("two_friends", "one_friend")
@@ -302,7 +300,7 @@ def test_companion_bonus_scales_with_number_of_matches():
def test_wished_companion_absent_from_the_shift_has_no_effect(): def test_wished_companion_absent_from_the_shift_has_no_effect():
shift = _shift() shift = _shift()
volunteer = _volunteer( volunteer = _volunteer(
"v", availability=[_full_day(10)], wished_companion_ids={"not-here"} "v", wished_companion_ids={"not-here"}
) )
solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,))) solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,)))
assert _by_shift(solution, "s1") == ("v",) assert _by_shift(solution, "s1") == ("v",)
+27
View File
@@ -0,0 +1,27 @@
"""Construction du `SolverInput` depuis la base (`app/solver/loader.py`)."""
from app.solver.loader import build_solver_input
from tests.factories import dt_at, make_position, make_shift
def test_predecessor_is_found_across_midnight_within_the_festival_day(edition, session):
# Début de journée = 06:00 (fixture) : 23h–00h le 9 puis 00h–01h le 10
# appartiennent à la même soirée — le second enchaîne le premier.
bar = make_position(session, edition, name="Bar")
before = make_shift(session, edition, bar, start=dt_at(9, 23), end=dt_at(10, 0))
after = make_shift(session, edition, bar, start=dt_at(10, 0), end=dt_at(10, 1))
data = build_solver_input(session=session, edition=edition)
by_id = {s.id: s for s in data.shifts}
assert by_id[str(after.id)].predecessor_shift_ids == frozenset({str(before.id)})
def test_no_predecessor_across_the_festival_day_boundary(edition, session):
# 05h–06h le 10 clôt la journée du 9 ; 06h–07h le 10 ouvre la suivante.
bar = make_position(session, edition, name="Bar")
make_shift(session, edition, bar, start=dt_at(10, 5), end=dt_at(10, 6))
morning = make_shift(session, edition, bar, start=dt_at(10, 6), end=dt_at(10, 7))
data = build_solver_input(session=session, edition=edition)
by_id = {s.id: s for s in data.shifts}
assert not by_id[str(morning.id)].predecessor_shift_ids
+59
View File
@@ -0,0 +1,59 @@
"""Client SumUp : lecture de la transaction d'un checkout payé, et
diagnostic d'un appel refusé (journalisé sans données du payeur)."""
import logging
import httpx
from app.sumup import client as sumup
def test_transaction_is_read_from_the_checkout():
checkout = sumup._to_checkout(
{
"id": "co_1",
"status": "PAID",
"transaction_code": "TEENSK4W2K",
"transaction_id": "tx-1",
}
)
assert checkout.transaction_code == "TEENSK4W2K"
assert checkout.transaction_id == "tx-1"
def test_transaction_falls_back_to_the_successful_attempt():
checkout = sumup._to_checkout(
{
"id": "co_1",
"status": "PAID",
"transactions": [
{"id": "tx-failed", "transaction_code": "TFAILED", "status": "FAILED"},
{"id": "tx-ok", "transaction_code": "TOK", "status": "SUCCESSFUL"},
],
}
)
assert checkout.transaction_code == "TOK"
assert checkout.transaction_id == "tx-ok"
def test_unpaid_checkout_has_no_transaction():
checkout = sumup._to_checkout({"id": "co_1", "status": "PENDING"})
assert checkout.transaction_code is None
assert checkout.transaction_id is None
def test_refused_call_logs_the_sumup_error_code(monkeypatch, caplog):
request = httpx.Request("POST", "https://api.sumup.com/v0.1/checkouts")
response = httpx.Response(
409,
json={"error_code": "DUPLICATED_CHECKOUT", "message": "Checkout already exists"},
request=request,
)
monkeypatch.setattr(httpx, "request", lambda *a, **kw: response)
with caplog.at_level(logging.WARNING, logger="app.sumup"):
result = sumup.get_checkout(api_key="k", checkout_id="co_1")
assert result is None
assert "HTTP 409" in caplog.text
assert "DUPLICATED_CHECKOUT" in caplog.text
+61 -226
View File
@@ -4,7 +4,6 @@ règle, plus les cas limites de la sémantique figée (CLAUDE.md § 3)."""
import datetime as dt import datetime as dt
from app.validation import ( from app.validation import (
AvailabilityWindow,
DayPartWindow, DayPartWindow,
Severity, Severity,
ShiftInfo, ShiftInfo,
@@ -33,14 +32,6 @@ def _codes(violations) -> list[ViolationCode]:
return [v.code for v in violations] return [v.code for v in violations]
def _win(day: int, start_h: int, end_h: int) -> AvailabilityWindow:
"""Fenêtre de dispo le <day> juillet, de start_h:00 à end_h:00.
`end_h <= start_h` ⇒ se termine le lendemain."""
return AvailabilityWindow(
dt.date(2026, 7, day), dt.time(start_h % 24, 0), dt.time(end_h % 24, 0)
)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# SHIFT_OVERLAP # # SHIFT_OVERLAP #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -48,230 +39,74 @@ def test_overlap_pass_when_shifts_are_adjacent():
data = ValidationInput( data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)), candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),), existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),),
availabilities=(_win(10, 8, 23),),
) )
assert ViolationCode.SHIFT_OVERLAP not in _codes(validate(data)) assert validate(data) == []
def test_overlap_triggers_and_is_blocking(): def test_overlap_triggers_as_a_warning_only():
data = ValidationInput( data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)), candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),), existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),),
availabilities=(_win(10, 8, 23),),
) )
violations = validate(data) violations = validate(data)
overlap = [v for v in violations if v.code is ViolationCode.SHIFT_OVERLAP] assert _codes(violations) == [ViolationCode.SHIFT_OVERLAP]
assert len(overlap) == 1 # Deux postes en même temps restent possibles : avertissement seulement.
assert overlap[0].severity is Severity.BLOCKING
assert overlap[0].params["other_shift_id"] == "e"
assert has_blocking(violations) is True
# --------------------------------------------------------------------------- #
# OUTSIDE_AVAILABILITY #
# --------------------------------------------------------------------------- #
def test_availability_pass_when_shift_within_declared_window():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
availabilities=(_win(10, 13, 19),),
)
assert validate(data) == []
def test_availability_pass_when_window_exactly_matches_shift():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
availabilities=(_win(10, 14, 18),),
)
assert validate(data) == []
def test_availability_triggers_when_nothing_declared():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
)
violations = validate(data)
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
assert violations[0].severity is Severity.WARNING assert violations[0].severity is Severity.WARNING
assert violations[0].params["date"] == "2026-07-10" assert violations[0].params["other_shift_id"] == "e"
assert violations[0].params["uncovered_minutes"] == 240
def test_availability_triggers_on_partial_coverage_and_counts_the_gap():
# Créneau 16:00 -> 20:00 ; dispo seulement 13:00 -> 19:00 ⇒ 60 min à découvert.
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 16), _dt(10, 20)),
availabilities=(_win(10, 13, 19),),
)
violations = validate(data)
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
assert violations[0].params["uncovered_minutes"] == 60
def test_availability_union_of_several_windows_covers_the_shift():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 12), _dt(10, 20)),
availabilities=(_win(10, 12, 16), _win(10, 15, 20)),
)
assert validate(data) == []
def test_availability_midnight_crossing_window_covers_night_shift():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 22), _dt(11, 2)),
availabilities=(_win(10, 19, 4),), # 19:00 -> 04:00 le lendemain
)
assert validate(data) == []
def test_availability_gap_between_two_windows_is_flagged():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 18)),
availabilities=(_win(10, 10, 12), _win(10, 14, 18)), # trou 12:00-14:00
)
violations = validate(data)
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
assert violations[0].params["uncovered_minutes"] == 120
# --------------------------------------------------------------------------- #
# DAILY_HOURS_EXCEEDED #
# --------------------------------------------------------------------------- #
def test_daily_hours_pass_at_exactly_eight():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h pile
day_parts=DAY_PARTS,
availabilities=(_win(10, 8, 18),),
)
assert ViolationCode.DAILY_HOURS_EXCEEDED not in _codes(validate(data))
def test_daily_hours_triggers_above_eight():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h
existing_shifts=(_shift("e", _dt(10, 20), _dt(10, 20, 30)),), # +30 min
day_parts=DAY_PARTS,
)
violations = [
v for v in validate(data) if v.code is ViolationCode.DAILY_HOURS_EXCEEDED
]
assert len(violations) == 1
assert violations[0].params["total_minutes"] == 510
assert violations[0].params["date"] == "2026-07-10"
def test_daily_hours_day_starts_at_first_day_part_boundary():
# Frontière de journée = 06:00 (début de MORNING). Un créneau de nuit qui
# commence à 03:00 le 11 appartient à la journée du 10.
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 8), _dt(10, 15)), # 7 h, journée 10
existing_shifts=(_shift("e", _dt(11, 3), _dt(11, 5)),), # 2 h, journée 10
day_parts=DAY_PARTS,
)
violations = [
v for v in validate(data) if v.code is ViolationCode.DAILY_HOURS_EXCEEDED
]
assert len(violations) == 1
assert violations[0].params["date"] == "2026-07-10"
def test_daily_hours_ignores_other_days():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h journée 10
existing_shifts=(_shift("e", _dt(9, 9), _dt(9, 17)),), # 8 h journée 9
day_parts=DAY_PARTS,
)
assert ViolationCode.DAILY_HOURS_EXCEEDED not in _codes(validate(data))
# --------------------------------------------------------------------------- #
# INSUFFICIENT_REST #
# --------------------------------------------------------------------------- #
def test_rest_pass_with_more_than_eight_hours_gap():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 8), _dt(10, 12)),
existing_shifts=(_shift("e", _dt(9, 18), _dt(9, 23)),), # écart 9 h
availabilities=(_win(10, 8, 12),),
)
assert ViolationCode.INSUFFICIENT_REST not in _codes(validate(data))
def test_rest_pass_at_exactly_eight_hours():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 4), _dt(10, 12)),
existing_shifts=(_shift("e", _dt(10, 20), _dt(10, 22)),), # écart 8 h pile
)
assert ViolationCode.INSUFFICIENT_REST not in _codes(validate(data))
def test_rest_triggers_for_shift_before():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 14)),
existing_shifts=(_shift("e", _dt(10, 2), _dt(10, 6)),), # écart 4 h
)
violations = [
v for v in validate(data) if v.code is ViolationCode.INSUFFICIENT_REST
]
assert len(violations) == 1
assert violations[0].params["direction"] == "BEFORE"
assert violations[0].params["gap_minutes"] == 240
def test_rest_triggers_for_shift_after():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 14)),
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 20)),), # écart 4 h
)
violations = [
v for v in validate(data) if v.code is ViolationCode.INSUFFICIENT_REST
]
assert len(violations) == 1
assert violations[0].params["direction"] == "AFTER"
def test_back_to_back_shifts_are_rest_violation_not_overlap():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),),
availabilities=(_win(10, 8, 23),),
)
codes = _codes(validate(data))
assert ViolationCode.SHIFT_OVERLAP not in codes
assert ViolationCode.INSUFFICIENT_REST in codes
# --------------------------------------------------------------------------- #
# Combinaisons #
# --------------------------------------------------------------------------- #
def test_clean_assignment_has_no_violation():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(9, 8), _dt(9, 12)),),
day_parts=DAY_PARTS,
availabilities=(_win(10, 13, 19),),
)
assert validate(data) == []
def test_multiple_rules_fire_together():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),), # chevauchement
# aucune dispo déclarée ⇒ OUTSIDE_AVAILABILITY
)
codes = set(_codes(validate(data)))
assert {
ViolationCode.SHIFT_OVERLAP,
ViolationCode.OUTSIDE_AVAILABILITY,
} <= codes
assert has_blocking(validate(data)) is True
def test_only_warnings_is_not_blocking():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
# pas de dispo ⇒ un seul avertissement OUTSIDE_AVAILABILITY
)
violations = validate(data)
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
assert has_blocking(violations) is False assert has_blocking(violations) is False
# --------------------------------------------------------------------------- #
# Plus de limite de temps dans la journée #
# --------------------------------------------------------------------------- #
def test_long_day_and_short_rest_raise_nothing():
"""Ni cumul journalier (> 8 h) ni repos minimal (< 8 h) : seuls les
chevauchements sont signalés."""
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 22)),
existing_shifts=(
_shift("matin", _dt(10, 6), _dt(10, 14)),
_shift("nuit", _dt(10, 23), _dt(11, 3)),
),
)
assert validate(data) == []
def test_clean_assignment_has_no_violation():
data = ValidationInput(candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)))
assert validate(data) == []
assert has_blocking([]) is False
# --- Début de « journée de festival » (day_boundary) ------------------------
def test_day_boundary_starts_after_last_day_part_ends():
from app.validation.engine import day_boundary
parts = (
DayPartWindow("MATIN", dt.time(8, 0), dt.time(13, 0), 0),
DayPartWindow("APRES_MIDI", dt.time(13, 0), dt.time(19, 0), 1),
DayPartWindow("SOIREE", dt.time(19, 0), dt.time(2, 0), 2),
)
assert day_boundary(parts) == dt.time(2, 0)
def test_day_boundary_ignores_order_out_of_step_with_hours():
"""Un créneau type d'ordre 0 déplacé à 21 h ne fait plus commencer la
journée à 21 h : seul compte le creux entre la fin du dernier et le
début du premier."""
from app.validation.engine import day_boundary
parts = (
DayPartWindow("SOIREE", dt.time(21, 0), dt.time(3, 0), 0),
DayPartWindow("JOUR", dt.time(10, 0), dt.time(21, 0), 1),
)
assert day_boundary(parts) == dt.time(3, 0)
def test_day_boundary_full_coverage_falls_back_to_first_by_order():
from app.validation.engine import day_boundary
assert day_boundary(DAY_PARTS) == dt.time(6, 0)
assert day_boundary(()) == dt.time(0, 0)
-13
View File
@@ -1,11 +1,7 @@
import datetime as dt
from app.models import Assignment from app.models import Assignment
from app.validation import AvailabilityWindow
from app.validation.loader import build_validation_input from app.validation.loader import build_validation_input
from tests.factories import ( from tests.factories import (
dt_at, dt_at,
make_availability,
make_position, make_position,
make_shift, make_shift,
make_volunteer, make_volunteer,
@@ -21,11 +17,6 @@ def test_build_input_gathers_everything_from_db(client, edition, session, day_pa
session, edition, position, start=dt_at(11, 9), end=dt_at(11, 12) session, edition, position, start=dt_at(11, 9), end=dt_at(11, 12)
) )
volunteer = make_volunteer(session, edition, email="v@ex.fr") volunteer = make_volunteer(session, edition, email="v@ex.fr")
make_availability(
session, edition, volunteer,
date=dt.date(2026, 7, 10),
start_time=dt.time(13, 0), end_time=dt.time(19, 0),
)
session.add( session.add(
Assignment( Assignment(
edition_id=edition.id, volunteer_id=volunteer.id, edition_id=edition.id, volunteer_id=volunteer.id,
@@ -41,10 +32,6 @@ def test_build_input_gathers_everything_from_db(client, edition, session, day_pa
assert data.candidate_shift.id == str(candidate.id) assert data.candidate_shift.id == str(candidate.id)
# Toute affectation existante compte, sauf le créneau candidat lui-même. # Toute affectation existante compte, sauf le créneau candidat lui-même.
assert [s.id for s in data.existing_shifts] == [str(other_shift.id)] assert [s.id for s in data.existing_shifts] == [str(other_shift.id)]
assert len(data.day_parts) == 3
assert data.availabilities == (
AvailabilityWindow(dt.date(2026, 7, 10), dt.time(13, 0), dt.time(19, 0)),
)
def test_exclude_assignment_id_is_honoured(client, edition, session, day_parts): def test_exclude_assignment_id_is_honoured(client, edition, session, day_parts):
+106 -2
View File
@@ -18,10 +18,11 @@ import type { AccountRead } from '@/api/schema';
import { queryKeys } from '@/api/hooks'; import { queryKeys } from '@/api/hooks';
import { AuthContext, AuthProvider, type AuthContextValue } from '@/auth/auth-context'; import { AuthContext, AuthProvider, type AuthContextValue } from '@/auth/auth-context';
import { routes } from '@/app/router'; import { routes } from '@/app/router';
import { buildTimeGrid, placeInTimeGrid } from '@/lib/planning'; import { buildTimeGrid, dayBoundaryFromDayParts, placeInTimeGrid } from '@/lib/planning';
import { BadgesPage } from '@/pages/BadgesPage'; import { BadgesPage } from '@/pages/BadgesPage';
import { ConfigPage } from '@/pages/ConfigPage'; import { ConfigPage } from '@/pages/ConfigPage';
import { FormBuilderPage } from '@/pages/FormBuilderPage'; import { FormBuilderPage } from '@/pages/FormBuilderPage';
import { StatsPage } from '@/pages/StatsPage';
import { GrillePage } from '@/pages/GrillePage'; import { GrillePage } from '@/pages/GrillePage';
import { MobileLeadPage } from '@/pages/MobileLeadPage'; import { MobileLeadPage } from '@/pages/MobileLeadPage';
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage'; import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
@@ -423,7 +424,107 @@ function renderPublic(path: string) {
} }
} }
// Début de journée indépendant de `order` (régression : un créneau type
// d'ordre 0 déplacé à 21:00 faisait commencer chaque journée à 21:00 dans les
// grilles, jusqu'à 03:00 le lendemain) — miroir de
// backend/tests/test_validation_engine.py (day_boundary).
function checkDayBoundary() {
const cases: Array<[Parameters<typeof dayBoundaryFromDayParts>[0], string]> = [
[[], '00:00'],
[
[
{ start_time: '08:00:00', end_time: '13:00:00', order: 0 },
{ start_time: '13:00:00', end_time: '19:00:00', order: 1 },
{ start_time: '19:00:00', end_time: '02:00:00', order: 2 },
],
'02:00',
],
[
[
{ start_time: '21:00:00', end_time: '03:00:00', order: 0 },
{ start_time: '10:00:00', end_time: '21:00:00', order: 1 },
],
'03:00',
],
[
[
{ start_time: '06:00:00', end_time: '19:00:00', order: 1 },
{ start_time: '19:00:00', end_time: '06:00:00', order: 0 },
],
'19:00',
],
];
for (const [parts, expected] of cases) {
const got = dayBoundaryFromDayParts(parts);
if (got !== expected) throw new Error(`attendu ${expected}, obtenu ${got}`);
}
console.log(`ok ${'dayBoundary'.padEnd(26)} ${cases.length} cas`);
}
// Éditeur de badges (onglet « Personnaliser ») : un champ par face, rendu
// avec les vraies données (nom du premier bénévole dans l'ordre
// d'impression), contrôle avant génération présent ; le mock refuse un même
// type de champ deux fois sur une face, mais l'accepte sur les deux.
async function checkBadgeEditor() {
if (!coordinator) throw new Error('badges : pas de session coordination pour ce test');
const base = {
x_mm: 4, y_mm: 4, width_mm: 40, height_mm: 12, font_size: 9, align: 'LEFT' as const,
bold: true, color: '#14110E', bg_color: null, auto_fit: true, photo_shape: 'RECT' as const,
};
let duplicate = 'non';
try {
await api.badges.setFields(e, {
fields: [
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
],
});
} catch (err) {
duplicate = err instanceof ApiError ? err.code : String(err);
}
if (duplicate !== 'badge.duplicate_field_type') throw new Error(`doublon accepté : ${duplicate}`);
await api.badges.setFields(e, {
fields: [
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
{ ...base, field_type: 'POSITIONS', face: 'FRONT', y_mm: 18, height_mm: 30, bold: false },
{ ...base, field_type: 'FULL_NAME', face: 'BACK', bg_color: '#FFD23F' },
{ ...base, field_type: 'FREE_TEXT', face: 'BACK', y_mm: 30, text: 'STAFF' },
],
});
const vols = [...(await api.volunteers.list(e))].sort(
(a, b) =>
a.last_name.localeCompare(b.last_name, 'fr') || a.first_name.localeCompare(b.first_name, 'fr'),
);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await prefetchCore(client);
await Promise.all([
client.prefetchQuery({ queryKey: ['badgeTemplate', e], queryFn: () => api.badges.getTemplate(e) }),
client.prefetchQuery({ queryKey: ['dayParts', e], queryFn: () => api.dayParts.list(e) }),
client.prefetchQuery({ queryKey: ['accounts', e], queryFn: () => api.accounts.list(e) }),
]);
const html = renderToString(
createElement(
QueryClientProvider,
{ client },
createElement(
AuthContext.Provider,
{ value: authValue(coordinator) },
createElement(MemoryRouter, null, createElement(BadgesPage)),
),
),
);
const expected = [vols[0]?.last_name ?? '', 'STAFF', 'Contrôle des'];
const missing = expected.filter((text) => !html.includes(text));
if (missing.length) throw new Error(`badges : absent du rendu ${JSON.stringify(missing)}`);
console.log(`ok ${'/badges'.padEnd(26)} ${html.length} octets`);
}
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
try {
checkDayBoundary();
} catch (err) {
fail('checkDayBoundary', err);
}
await checkAuth().catch((err) => fail('checkAuth', err)); await checkAuth().catch((err) => fail('checkAuth', err));
await checkPublicForm().catch((err) => fail('checkPublicForm', err)); await checkPublicForm().catch((err) => fail('checkPublicForm', err));
await checkPositionSheets().catch((err) => fail('checkPositionSheets', err)); await checkPositionSheets().catch((err) => fail('checkPositionSheets', err));
@@ -441,8 +542,11 @@ if (coordinator) {
await renderProtected('/grille', coordinator, createElement(GrillePage)); await renderProtected('/grille', coordinator, createElement(GrillePage));
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage)); await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
await renderProtected('/configuration', coordinator, createElement(ConfigPage)); await renderProtected('/configuration', coordinator, createElement(ConfigPage));
await renderProtected('/badges', coordinator, createElement(BadgesPage)); await checkBadgeEditor().catch((err) => fail('checkBadgeEditor', err));
await renderProtected('/inscriptions', coordinator, createElement(FormBuilderPage)); await renderProtected('/inscriptions', coordinator, createElement(FormBuilderPage));
await renderProtected('/statistiques', coordinator, createElement(StatsPage), async (client) => {
await client.prefetchQuery({ queryKey: ['stats', e], queryFn: () => api.stats.get(e) });
});
} }
if (volunteerAccount) { if (volunteerAccount) {
const vid = volunteerAccount.volunteer_id as string; const vid = volunteerAccount.volunteer_id as string;
+82 -37
View File
@@ -16,7 +16,6 @@ import type {
AssignmentCreate, AssignmentCreate,
AssignmentUpdate, AssignmentUpdate,
AssignmentValidateResult, AssignmentValidateResult,
AvailabilitySet,
BadgeFieldsSet, BadgeFieldsSet,
BadgeGenerateRequest, BadgeGenerateRequest,
BadgeSizeUpdate, BadgeSizeUpdate,
@@ -38,6 +37,9 @@ import type {
ShiftUpdate, ShiftUpdate,
VolunteerCreate, VolunteerCreate,
VolunteerUpdate, VolunteerUpdate,
AssignmentRead,
AttendanceStatus,
TestEmailRequest,
} from './schema'; } from './schema';
const edition = EDITION_ID; const edition = EDITION_ID;
@@ -104,9 +106,15 @@ export function useVolunteerPlanning(volunteerId: string | null | undefined) {
}); });
} }
/** URL objet (aperçu) de la photo d'un bénévole (formulaire public), ou /** URL objet (aperçu) de la photo d'un bénévole (formulaire public ou
* `null` si absente. */ * ajoutée par la coordination), ou `null` si absente. `version` (ex.
export function useVolunteerPhotoUrl(volunteerId: string | null | undefined, hasPhoto: boolean) { * `updated_at`) force le rechargement quand la photo est remplacée —
* `hasPhoto` resterait `true` et rien ne changerait sinon. */
export function useVolunteerPhotoUrl(
volunteerId: string | null | undefined,
hasPhoto: boolean,
version?: string,
) {
const [url, setUrl] = useState<string | null>(null); const [url, setUrl] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let objectUrl: string | null = null; let objectUrl: string | null = null;
@@ -124,7 +132,7 @@ export function useVolunteerPhotoUrl(volunteerId: string | null | undefined, has
cancelled = true; cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl); if (objectUrl) URL.revokeObjectURL(objectUrl);
}; };
}, [volunteerId, hasPhoto]); }, [volunteerId, hasPhoto, version]);
return url; return url;
} }
@@ -135,23 +143,6 @@ export function useShifts(positionId?: string) {
}); });
} }
/** Fenêtres de disponibilité d'un bénévole. */
export function useVolunteerAvailability(volunteerId: string | null | undefined) {
return useQuery({
queryKey: ['availability', edition, volunteerId ?? null],
queryFn: () => api.availability.list(edition, volunteerId as string),
enabled: Boolean(volunteerId),
});
}
/** Toutes les disponibilités de l'édition (grille des besoins). */
export function useEditionAvailability() {
return useQuery({
queryKey: ['availability', edition, 'all'],
queryFn: () => api.availability.listEdition(edition),
});
}
export function useAssignments( export function useAssignments(
params?: { volunteerId?: string; shiftId?: string }, params?: { volunteerId?: string; shiftId?: string },
options?: { enabled?: boolean }, options?: { enabled?: boolean },
@@ -167,6 +158,18 @@ export function useMatrix() {
return useQuery({ queryKey: queryKeys.matrix, queryFn: () => api.matrix.get(edition) }); return useQuery({ queryKey: queryKeys.matrix, queryFn: () => api.matrix.get(edition) });
} }
/** Statistiques de l'édition (inscriptions, réponses, paiements, besoins) —
* recalculées côté serveur à chaque appel. */
/** Rafraîchie toutes les 30 s tant que l'écran est ouvert : la section
* « Présence » suit le pointage des référents en temps réel. */
export function useStats() {
return useQuery({
queryKey: ['stats', edition] as const,
queryFn: () => api.stats.get(edition),
refetchInterval: 30_000,
});
}
/** Configuration du recto/verso des badges (images, champs). */ /** Configuration du recto/verso des badges (images, champs). */
export function useBadgeTemplate() { export function useBadgeTemplate() {
return useQuery({ return useQuery({
@@ -241,7 +244,6 @@ function useInvalidate() {
const client = useQueryClient(); const client = useQueryClient();
return { return {
volunteers: () => client.invalidateQueries({ queryKey: ['volunteers', edition] }), volunteers: () => client.invalidateQueries({ queryKey: ['volunteers', edition] }),
availability: () => client.invalidateQueries({ queryKey: ['availability', edition] }),
artists: () => client.invalidateQueries({ queryKey: ['artists', edition] }), artists: () => client.invalidateQueries({ queryKey: ['artists', edition] }),
accounts: () => client.invalidateQueries({ queryKey: ['accounts', edition] }), accounts: () => client.invalidateQueries({ queryKey: ['accounts', edition] }),
positions: () => client.invalidateQueries({ queryKey: ['positions', edition] }), positions: () => client.invalidateQueries({ queryKey: ['positions', edition] }),
@@ -349,6 +351,14 @@ export function useUpdateAccount() {
}); });
} }
export function useResetLoginLink() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.accounts.resetLoginLink(edition, id),
onSuccess: invalidate.accounts,
});
}
export function useDeleteAccount() { export function useDeleteAccount() {
const invalidate = useInvalidate(); const invalidate = useInvalidate();
return useMutation({ return useMutation({
@@ -374,6 +384,23 @@ export function useUpdateVolunteer() {
}); });
} }
export function useSetVolunteerPhoto() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, file }: { id: string; file: File }) =>
api.volunteers.setPhoto(edition, id, file),
onSuccess: invalidate.volunteers,
});
}
export function useRemoveVolunteerPhoto() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.volunteers.removePhoto(edition, id),
onSuccess: invalidate.volunteers,
});
}
export function useDeleteVolunteer() { export function useDeleteVolunteer() {
const invalidate = useInvalidate(); const invalidate = useInvalidate();
return useMutation({ return useMutation({
@@ -402,20 +429,6 @@ export function useExportMecenatReceipts() {
}); });
} }
/** Remplace toutes les fenêtres de disponibilité d'un bénévole. */
export function useReplaceAvailability() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ volunteerId, body }: { volunteerId: string; body: AvailabilitySet }) =>
api.availability.replace(edition, volunteerId, body),
onSuccess: () => {
invalidate.availability();
// Les avertissements « hors disponibilité » de la grille en dépendent.
invalidate.planning();
},
});
}
/** Import de bénévoles — phase 1 : analyse du fichier. */ /** Import de bénévoles — phase 1 : analyse du fichier. */
export function useAnalyzeImport() { export function useAnalyzeImport() {
return useMutation({ return useMutation({
@@ -527,6 +540,32 @@ export function useUpdateAssignment() {
}); });
} }
/** Pointage présent / absent (`null` annule), enregistré en base. Mis à
* jour tout de suite dans les listes d'affectations en cache (le référent
* pointe vite, sur un réseau de festival souvent lent), puis rechargé. */
export function useSetAttendance() {
const client = useQueryClient();
const patch = (id: string, change: Partial<AssignmentRead>) =>
client.setQueriesData<AssignmentRead[]>({ queryKey: ['assignments', edition] }, (list) =>
list?.map((a) => (a.id === id ? { ...a, ...change } : a)),
);
return useMutation({
mutationFn: ({ id, attendance }: { id: string; attendance: AttendanceStatus | null }) =>
api.assignments.setAttendance(edition, id, { attendance }),
onMutate: async ({ id, attendance }) => {
await client.cancelQueries({ queryKey: ['assignments', edition] });
const previous = client.getQueriesData<AssignmentRead[]>({ queryKey: ['assignments', edition] });
patch(id, { attendance });
return { previous };
},
onError: (_error, _vars, context) => {
for (const [key, data] of context?.previous ?? []) client.setQueryData(key, data);
},
onSuccess: (saved) => patch(saved.id, saved),
onSettled: () => client.invalidateQueries({ queryKey: ['assignments', edition] }),
});
}
export function useDeleteAssignment() { export function useDeleteAssignment() {
const invalidate = useInvalidate(); const invalidate = useInvalidate();
return useMutation({ return useMutation({
@@ -556,6 +595,12 @@ export function useGeneratePositionSheets() {
}); });
} }
export function useSendTestEmail() {
return useMutation({
mutationFn: (body: TestEmailRequest) => api.notifications.sendTest(edition, body),
});
}
export function useSendPlanningNotification() { export function useSendPlanningNotification() {
return useMutation({ return useMutation({
mutationFn: (body: PlanningNotificationRequest) => mutationFn: (body: PlanningNotificationRequest) =>
+30 -13
View File
@@ -6,7 +6,6 @@ import type {
AssignmentCreate, AssignmentCreate,
AssignmentUpdate, AssignmentUpdate,
AssignmentValidateRequest, AssignmentValidateRequest,
AvailabilitySet,
BadgeFieldsSet, BadgeFieldsSet,
BadgeGenerateRequest, BadgeGenerateRequest,
BadgeSizeUpdate, BadgeSizeUpdate,
@@ -31,6 +30,8 @@ import type {
VerifyRequest, VerifyRequest,
VolunteerCreate, VolunteerCreate,
VolunteerUpdate, VolunteerUpdate,
AttendanceUpdate,
TestEmailRequest,
} from './schema'; } from './schema';
import { getAuthToken, notifyUnauthorized } from './session'; import { getAuthToken, notifyUnauthorized } from './session';
import { ApiError, type PlanningApi } from './types'; import { ApiError, type PlanningApi } from './types';
@@ -71,7 +72,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
? ((detail as { violations: [] }).violations ?? []) ? ((detail as { violations: [] }).violations ?? [])
: []; : [];
if (res.status === 401 && !path.includes('/auth/')) notifyUnauthorized(); if (res.status === 401 && !path.includes('/auth/')) notifyUnauthorized();
throw new ApiError(res.status, code, violations); const requestId = res.headers.get('X-Request-ID');
if (res.status >= 500) {
console.error(`API ${res.status} ${path} — référence ${requestId ?? '?'}`, detail);
}
throw new ApiError(res.status, code, violations, requestId);
} }
return body as T; return body as T;
} }
@@ -100,7 +105,7 @@ export function createHttpApi(baseUrl: string): PlanningApi {
// Corps non JSON (ex. erreur réseau) : garde le code générique. // Corps non JSON (ex. erreur réseau) : garde le code générique.
} }
if (res.status === 401) notifyUnauthorized(); if (res.status === 401) notifyUnauthorized();
throw new ApiError(res.status, code, []); throw new ApiError(res.status, code, [], res.headers.get('X-Request-ID'));
} }
return res.blob(); return res.blob();
} }
@@ -152,6 +157,13 @@ export function createHttpApi(baseUrl: string): PlanningApi {
planning: (editionId, id) => planning: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}/planning`), request(`${base(editionId)}/volunteers/${id}/planning`),
photoBlob: (editionId, id) => requestBlob(`${base(editionId)}/volunteers/${id}/photo`), photoBlob: (editionId, id) => requestBlob(`${base(editionId)}/volunteers/${id}/photo`),
setPhoto: (editionId, id, file: File) => {
const form = new FormData();
form.append('file', file);
return request(`${base(editionId)}/volunteers/${id}/photo`, { method: 'PUT', body: form });
},
removePhoto: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}/photo`, { method: 'DELETE' }),
exportXlsx: async (editionId) => { exportXlsx: async (editionId) => {
const blob = await requestBlob(`${base(editionId)}/volunteers/export`); const blob = await requestBlob(`${base(editionId)}/volunteers/export`);
if (!blob) throw new ApiError(500, 'error.generic', []); if (!blob) throw new ApiError(500, 'error.generic', []);
@@ -171,16 +183,8 @@ export function createHttpApi(baseUrl: string): PlanningApi {
request(`${base(editionId)}/accounts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), request(`${base(editionId)}/accounts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) => remove: (editionId, id) =>
request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }), request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }),
}, resetLoginLink: (editionId, id) =>
availability: { request(`${base(editionId)}/accounts/${id}/reset-login-link`, { method: 'POST' }),
list: (editionId, volunteerId) =>
request(`${base(editionId)}/volunteers/${volunteerId}/availability`),
replace: (editionId, volunteerId, body: AvailabilitySet) =>
request(`${base(editionId)}/volunteers/${volunteerId}/availability`, {
method: 'PUT',
body: JSON.stringify(body),
}),
listEdition: (editionId) => request(`${base(editionId)}/availability`),
}, },
imports: { imports: {
analyze: (editionId, file: File) => { analyze: (editionId, file: File) => {
@@ -232,6 +236,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
request(`${base(editionId)}/assignments/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), request(`${base(editionId)}/assignments/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) => remove: (editionId, id) =>
request(`${base(editionId)}/assignments/${id}`, { method: 'DELETE' }), request(`${base(editionId)}/assignments/${id}`, { method: 'DELETE' }),
setAttendance: (editionId, id, body: AttendanceUpdate) =>
request(`${base(editionId)}/assignments/${id}/attendance`, {
method: 'PUT',
body: JSON.stringify(body),
}),
validate: (editionId, body: AssignmentValidateRequest) => validate: (editionId, body: AssignmentValidateRequest) =>
request(`${base(editionId)}/assignments/validate`, { request(`${base(editionId)}/assignments/validate`, {
method: 'POST', method: 'POST',
@@ -241,6 +250,9 @@ export function createHttpApi(baseUrl: string): PlanningApi {
matrix: { matrix: {
get: (editionId) => request(`${base(editionId)}/matrix`), get: (editionId) => request(`${base(editionId)}/matrix`),
}, },
stats: {
get: (editionId) => request(`${base(editionId)}/stats`),
},
badges: { badges: {
getTemplate: (editionId) => request(`${base(editionId)}/badges/template`), getTemplate: (editionId) => request(`${base(editionId)}/badges/template`),
uploadFrontImage: (editionId, file: File) => { uploadFrontImage: (editionId, file: File) => {
@@ -369,6 +381,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
sendTest: (editionId, body: TestEmailRequest) =>
request(`${base(editionId)}/notifications/test`, {
method: 'POST',
body: JSON.stringify(body),
}),
}, },
auth: { auth: {
login: (editionId, body: LoginRequest) => login: (editionId, body: LoginRequest) =>
+49 -38
View File
@@ -4,7 +4,6 @@ import type {
AccountRead, AccountRead,
ArtistRead, ArtistRead,
AssignmentRead, AssignmentRead,
AvailabilityRead,
BadgeFieldRead, BadgeFieldRead,
DayPartRead, DayPartRead,
EditionRead, EditionRead,
@@ -67,7 +66,6 @@ export interface MockState {
artists: ArtistRead[]; artists: ArtistRead[];
volunteers: VolunteerRead[]; volunteers: VolunteerRead[];
/** volunteerId -> fenêtres de disponibilité déclarées. */ /** volunteerId -> fenêtres de disponibilité déclarées. */
availability: Map<string, AvailabilityRead[]>;
positions: PositionRead[]; positions: PositionRead[];
shifts: ShiftRead[]; shifts: ShiftRead[];
assignments: AssignmentRead[]; assignments: AssignmentRead[];
@@ -161,6 +159,14 @@ const ARTIST_POOL = [
]; ];
const NOW = '2026-10-10T09:00:00'; const NOW = '2026-10-10T09:00:00';
/** Date d'inscription de démonstration du i-ème bénévole : plus dense en
* fin de période (relances), jamais après `NOW`. */
function registeredAt(i: number): string {
const daysBefore = Math.floor(((i * 37) % 64) ** 1.4 / 9);
const d = new Date(Date.UTC(2026, 9, 10 - daysBefore, 9 + (i % 10), (i * 13) % 60));
return d.toISOString().slice(0, 19);
}
const NEVER_ASSIGNED = new Set([7, 21, 34, 48, 55]); const NEVER_ASSIGNED = new Set([7, 21, 34, 48, 55]);
const OVERLOADED = [3, 9]; const OVERLOADED = [3, 9];
@@ -189,6 +195,11 @@ function demoEmail(firstName: string, lastName: string): string {
return `${n(firstName)}.${n(lastName)}@mail.fr`; return `${n(firstName)}.${n(lastName)}@mail.fr`;
} }
const DEMO_POSITION_MESSAGES: Record<string, string> = {
'Parking — Nord': 'Gilet jaune fourni à la base bénévoles. Lampe frontale conseillée le soir.',
'Bar — Grande scène': 'Caisse à récupérer auprès du référent en début de créneau. Pas de vente aux mineurs.',
};
export function buildMockState(): MockState { export function buildMockState(): MockState {
const rnd = mulberry32(20260715); const rnd = mulberry32(20260715);
const edition_id = MOCK_EDITION_ID; const edition_id = MOCK_EDITION_ID;
@@ -200,6 +211,9 @@ export function buildMockState(): MockState {
location: p.location, location: p.location,
category: p.category, category: p.category,
lead_account_id: null, lead_account_id: null,
// Quelques consignes de démo (voir `PositionRead.message`).
message: DEMO_POSITION_MESSAGES[p.name] ?? null,
message_updated_at: DEMO_POSITION_MESSAGES[p.name] ? NOW : null,
created_at: NOW, created_at: NOW,
updated_at: NOW, updated_at: NOW,
})); }));
@@ -258,37 +272,10 @@ export function buildMockState(): MockState {
); );
const volunteers: VolunteerRead[] = []; const volunteers: VolunteerRead[] = [];
const availability = new Map<string, AvailabilityRead[]>();
let availSeq = 0;
const window = (
volunteerId: string,
date: string,
start_time: string,
end_time: string,
): AvailabilityRead => ({
id: `av${availSeq++}`,
edition_id,
volunteer_id: volunteerId,
date,
start_time,
end_time,
created_at: NOW,
updated_at: NOW,
});
for (let i = 0; i < 64; i += 1) { for (let i = 0; i < 64; i += 1) {
const first_name = FIRST_NAMES[i % FIRST_NAMES.length]; const first_name = FIRST_NAMES[i % FIRST_NAMES.length];
const last_name = LAST_NAMES[(i * 7 + 3) % LAST_NAMES.length]; const last_name = LAST_NAMES[(i * 7 + 3) % LAST_NAMES.length];
const id = `v${i}`; const id = `v${i}`;
// Pour ~75 % des jours : une fenêtre large 08:00 -> 02:00 le lendemain.
const windows: AvailabilityRead[] = [];
for (const dateStr of DAY_DATES) {
const edge = dateStr === DAY_DATES[0] || dateStr === DAY_DATES[3];
if (rnd() < (edge ? 0.55 : 0.8)) {
windows.push(window(id, dateStr, '08:00:00', '02:00:00'));
}
}
availability.set(id, windows);
const pair = () => `0${Math.floor(rnd() * 90 + 10)}`.slice(-2); const pair = () => `0${Math.floor(rnd() * 90 + 10)}`.slice(-2);
const artist_ids = artists.filter(() => rnd() < 0.25).map((a) => a.id); const artist_ids = artists.filter(() => rnd() < 0.25).map((a) => a.id);
const wished_position_ids = const wished_position_ids =
@@ -310,6 +297,8 @@ export function buildMockState(): MockState {
wished_companion_ids, wished_companion_ids,
has_photo: false, has_photo: false,
companion_wish_note: null, companion_wish_note: null,
badge_text: null,
badge_count: 1,
self_declared_available: false, self_declared_available: false,
willing_to_pay_fee: false, willing_to_pay_fee: false,
fee_payment_label: null, fee_payment_label: null,
@@ -320,17 +309,36 @@ export function buildMockState(): MockState {
mecenat_country: null, mecenat_country: null,
fee_payment_status: null, fee_payment_status: null,
fee_paid_at: null, fee_paid_at: null,
fee_transaction_code: null,
fee_transaction_id: null,
created_at: NOW, created_at: NOW,
updated_at: NOW, updated_at: NOW,
}); });
// Démo des statistiques : inscriptions étalées sur ~5 semaines et
// quelques cotisations — dérivées de `i`, pas de `rnd()`, pour ne rien
// décaler du reste du jeu de démonstration.
const v = volunteers[volunteers.length - 1];
v.created_at = registeredAt(i);
if (i % 4 === 1) v.willing_to_pay_fee = true;
if (i % 7 === 2 || i % 11 === 3) {
const mecenat = i % 11 === 3;
Object.assign(v, {
willing_to_pay_fee: true,
fee_payment_label: mecenat ? 'Mécénat' : 'Cotisation classique',
fee_amount: mecenat ? 40 : 15,
fee_payment_status: 'PAID',
fee_paid_at: v.created_at,
fee_transaction_code: `TDEMO${String(i).padStart(5, '0')}`,
fee_transaction_id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
...(mecenat
? { mecenat_address: '1 rue du Festival', mecenat_city: 'Rennes', mecenat_postal_code: '35000', mecenat_country: 'France' }
: {}),
});
}
} }
const me = volunteers[0]; const me = volunteers[0];
me.email = demoEmail(me.first_name, me.last_name); // toujours un compte pour la démo me.email = demoEmail(me.first_name, me.last_name); // toujours un compte pour la démo
availability.set(
me.id,
DAY_DATES.map((dateStr) => window(me.id, dateStr, '08:00:00', '02:00:00')),
);
NEVER_ASSIGNED.forEach((i) => { NEVER_ASSIGNED.forEach((i) => {
volunteers[i].comment = 'Inscrit, pas encore affecté'; volunteers[i].comment = 'Inscrit, pas encore affecté';
}); });
@@ -363,10 +371,7 @@ export function buildMockState(): MockState {
.sort((a, b) => score(b) - score(a)); .sort((a, b) => score(b) - score(a));
function score(v: VolunteerRead): number { function score(v: VolunteerRead): number {
const available = (availability.get(v.id) ?? []).some((w) => w.date === dateStr) return -(totalHours.get(v.id) ?? 0) * 3;
? 100
: 0;
return available - (totalHours.get(v.id) ?? 0) * 3;
} }
for (const v of pool.slice(0, target)) { for (const v of pool.slice(0, target)) {
@@ -377,6 +382,8 @@ export function buildMockState(): MockState {
shift_id: shift.id, shift_id: shift.id,
source: 'SOLVER', source: 'SOLVER',
locked: false, locked: false,
attendance: null,
attendance_marked_at: null,
created_at: NOW, created_at: NOW,
updated_at: NOW, updated_at: NOW,
}); });
@@ -406,6 +413,8 @@ export function buildMockState(): MockState {
shift_id: shift.id, shift_id: shift.id,
source: 'MANUAL', source: 'MANUAL',
locked: false, locked: false,
attendance: null,
attendance_marked_at: null,
created_at: NOW, created_at: NOW,
updated_at: NOW, updated_at: NOW,
}); });
@@ -469,6 +478,9 @@ export function buildMockState(): MockState {
start_date: DAY_DATES[0], start_date: DAY_DATES[0],
end_date: DAY_DATES[DAY_DATES.length - 1], end_date: DAY_DATES[DAY_DATES.length - 1],
retention_days: 365, retention_days: 365,
volunteer_message:
"Badges et t-shirts à récupérer à l'accueil bénévoles dès 17:00. Repas chaud servi au catering de 17:30 à 18:30.",
volunteer_message_updated_at: NOW,
created_at: NOW, created_at: NOW,
updated_at: NOW, updated_at: NOW,
}; };
@@ -483,7 +495,6 @@ export function buildMockState(): MockState {
assignments, assignments,
accounts, accounts,
passwords, passwords,
availability,
badgeTemplate: { badgeTemplate: {
frontImage: null, frontImage: null,
frontImageType: null, frontImageType: null,
+299 -152
View File
@@ -1,5 +1,5 @@
import { uncoveredMinutes } from '@/lib/planning'; import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
import { hoursBetween, naiveMinutes } from '@/lib/time'; import { hoursBetween, naiveMinutes, nowNaiveMinutes } from '@/lib/time';
import type { import type {
AccountRead, AccountRead,
@@ -8,8 +8,6 @@ import type {
ArtistUpdate, ArtistUpdate,
AssignmentRead, AssignmentRead,
AssignmentValidateResult, AssignmentValidateResult,
AvailabilityRead,
AvailabilitySet,
BadgeFieldRead, BadgeFieldRead,
BadgeFieldsSet, BadgeFieldsSet,
BadgeGenerateRequest, BadgeGenerateRequest,
@@ -44,7 +42,9 @@ import type {
TokenResponse, TokenResponse,
Violation, Violation,
VolunteerRead, VolunteerRead,
EditionStats,
} from '../schema'; } from '../schema';
import { MESSAGE_MAX_LENGTH } from '../schema';
import { getAuthToken } from '../session'; import { getAuthToken } from '../session';
import { ApiError, type PlanningApi } from '../types'; import { ApiError, type PlanningApi } from '../types';
import { buildMockState, type MockPendingSubmission, type MockState } from './dataset'; import { buildMockState, type MockPendingSubmission, type MockState } from './dataset';
@@ -53,7 +53,6 @@ import { buildMockState, type MockPendingSubmission, type MockState } from './da
// comportement (chargements, invalidations) proche du back-end réel. // comportement (chargements, invalidations) proche du back-end réel.
const LATENCY_MS = 120; const LATENCY_MS = 120;
const EIGHT_HOURS_MIN = 8 * 60;
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; const MAGIC_LINK_TTL_MS = 15 * 60 * 1000;
let state: MockState = buildMockState(); let state: MockState = buildMockState();
@@ -198,14 +197,6 @@ const slugCode = (label: string): string =>
/** « HH:MM » ou « HH:MM:SS » -> « HH:MM:SS ». */ /** « HH:MM » ou « HH:MM:SS » -> « HH:MM:SS ». */
const hms = (time: string): string => (time.length === 5 ? `${time}:00` : time); const hms = (time: string): string => (time.length === 5 ? `${time}:00` : time);
const sortWindows = (ws: AvailabilityRead[]): AvailabilityRead[] =>
[...ws].sort(
(a, b) =>
a.volunteer_id.localeCompare(b.volunteer_id) ||
a.date.localeCompare(b.date) ||
a.start_time.localeCompare(b.start_time),
);
/** Vœux du bénévole : dé-doublonne, ordre conservé ; 422 si un id est étranger. */ /** Vœux du bénévole : dé-doublonne, ordre conservé ; 422 si un id est étranger. */
function resolveWishes<T extends { id: string }>( function resolveWishes<T extends { id: string }>(
pool: T[], pool: T[],
@@ -244,6 +235,8 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
wished_companion_ids: [], wished_companion_ids: [],
has_photo: pending.photo !== null, has_photo: pending.photo !== null,
companion_wish_note: pending.companionWishNote, companion_wish_note: pending.companionWishNote,
badge_text: null,
badge_count: 1,
self_declared_available: pending.available, self_declared_available: pending.available,
willing_to_pay_fee: true, willing_to_pay_fee: true,
fee_payment_label: pending.paymentLabel, fee_payment_label: pending.paymentLabel,
@@ -254,12 +247,13 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
mecenat_country: pending.mecenatCountry, mecenat_country: pending.mecenatCountry,
fee_payment_status: 'PAID', fee_payment_status: 'PAID',
fee_paid_at: now, fee_paid_at: now,
fee_transaction_code: `TMOCK${String(state.seq).padStart(5, '0')}`,
fee_transaction_id: `mock-transaction-${state.seq}`,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
if (pending.photo) state.photos.set(volunteer.id, pending.photo); if (pending.photo) state.photos.set(volunteer.id, pending.photo);
state.volunteers.push(volunteer); state.volunteers.push(volunteer);
state.availability.set(volunteer.id, []);
pending.promotedVolunteerId = volunteer.id; pending.promotedVolunteerId = volunteer.id;
pending.status = 'PAID'; pending.status = 'PAID';
@@ -455,12 +449,12 @@ function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignm
const violations: Violation[] = []; const violations: Violation[] = [];
// SHIFT_OVERLAP (bloquante) // SHIFT_OVERLAP (avertissement : deux postes à la fois restent possibles)
for (const other of existing) { for (const other of existing) {
if (overlaps(candidate, other)) { if (overlaps(candidate, other)) {
violations.push({ violations.push({
code: 'SHIFT_OVERLAP', code: 'SHIFT_OVERLAP',
severity: 'BLOCKING', severity: 'WARNING',
params: { params: {
other_shift_id: other.id, other_shift_id: other.id,
other_starts_at: other.starts_at, other_starts_at: other.starts_at,
@@ -470,63 +464,6 @@ function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignm
} }
} }
// OUTSIDE_AVAILABILITY (avertissement) — créneau non couvert par l'union des
// fenêtres de disponibilité déclarées.
const gapMin = uncoveredMinutes(
state.availability.get(volunteerId) ?? [],
candidate.starts_at,
candidate.ends_at,
);
if (gapMin > 0) {
violations.push({
code: 'OUTSIDE_AVAILABILITY',
severity: 'WARNING',
params: {
date: candidate.starts_at.slice(0, 10),
uncovered_minutes: gapMin,
},
});
}
// DAILY_HOURS_EXCEEDED (avertissement) — journée = date de début du créneau.
const dayKey = candidate.starts_at.slice(0, 10);
let totalMin = (naiveMinutes(candidate.ends_at) - naiveMinutes(candidate.starts_at));
for (const other of existing) {
if (other.starts_at.slice(0, 10) === dayKey) {
totalMin += naiveMinutes(other.ends_at) - naiveMinutes(other.starts_at);
}
}
if (totalMin > EIGHT_HOURS_MIN) {
violations.push({
code: 'DAILY_HOURS_EXCEEDED',
severity: 'WARNING',
params: { date: dayKey, total_minutes: totalMin },
});
}
// INSUFFICIENT_REST (avertissement) — < 8 h entre deux créneaux (strict).
for (const other of existing) {
if (overlaps(candidate, other)) continue;
let gap: number;
let direction: 'BEFORE' | 'AFTER';
if (naiveMinutes(other.ends_at) <= naiveMinutes(candidate.starts_at)) {
gap = naiveMinutes(candidate.starts_at) - naiveMinutes(other.ends_at);
direction = 'BEFORE';
} else if (naiveMinutes(candidate.ends_at) <= naiveMinutes(other.starts_at)) {
gap = naiveMinutes(other.starts_at) - naiveMinutes(candidate.ends_at);
direction = 'AFTER';
} else {
continue;
}
if (gap < EIGHT_HOURS_MIN) {
violations.push({
code: 'INSUFFICIENT_REST',
severity: 'WARNING',
params: { other_shift_id: other.id, direction, gap_minutes: gap },
});
}
}
return violations; return violations;
} }
@@ -534,6 +471,179 @@ function hasBlocking(violations: Violation[]): boolean {
return violations.some((v) => v.severity === 'BLOCKING'); return violations.some((v) => v.severity === 'BLOCKING');
} }
/** Miroir de `app/api/stats.py` (mêmes règles, données du mock). */
function buildStats(): EditionStats {
const vols = state.volunteers;
const round = (n: number) => Math.round(n * 100) / 100;
const addDay = (d: string) =>
new Date(Date.UTC(+d.slice(0, 4), +d.slice(5, 7) - 1, +d.slice(8, 10) + 1))
.toISOString()
.slice(0, 10);
const perDay = new Map<string, number>();
for (const v of vols) perDay.set(v.created_at.slice(0, 10), (perDay.get(v.created_at.slice(0, 10)) ?? 0) + 1);
const byDay: EditionStats['registrations']['by_day'] = [];
const days = [...perDay.keys()].sort();
if (days.length) {
let cumulative = 0;
for (let d = days[0]; d <= days[days.length - 1]; d = addDay(d)) {
cumulative += perDay.get(d) ?? 0;
byDay.push({ date: d, count: perDay.get(d) ?? 0, cumulative });
}
}
const latest = days.length ? days[days.length - 1] : '';
const weekAgo = latest
? new Date(Date.parse(`${latest}T00:00:00Z`) - 6 * 86400000).toISOString().slice(0, 10)
: '';
const paid = vols.filter((v) => v.fee_payment_status === 'PAID');
const byLabel = new Map<string, VolunteerRead[]>();
for (const v of paid) byLabel.set(v.fee_payment_label ?? '', [...(byLabel.get(v.fee_payment_label ?? '') ?? []), v]);
const mecenat = paid.filter((v) => v.mecenat_address);
const sum = (list: VolunteerRead[]) => round(list.reduce((t, v) => t + (v.fee_amount ?? 0), 0));
const boundary = dayBoundaryFromDayParts(state.dayParts);
type Row = { shifts: number; min: number; ideal: number; assigned: number };
const rows = new Map<string, Row>();
const row = (d: string) => {
if (!rows.has(d)) rows.set(d, { shifts: 0, min: 0, ideal: 0, assigned: 0 });
return rows.get(d) as Row;
};
const shiftsById = new Map(state.shifts.map((s) => [s.id, s]));
for (const s of state.shifts) {
const r = row(festivalDay(s.starts_at, boundary));
const h = hoursBetween(s.starts_at, s.ends_at);
r.shifts += 1;
r.min += h * s.min_staff;
r.ideal += h * s.ideal_staff;
}
for (const a of state.assignments) {
const s = shiftsById.get(a.shift_id);
if (s) row(festivalDay(s.starts_at, boundary)).assigned += hoursBetween(s.starts_at, s.ends_at);
}
const assignedByShift = new Map<string, number>();
for (const a of state.assignments) assignedByShift.set(a.shift_id, (assignedByShift.get(a.shift_id) ?? 0) + 1);
return {
registrations: {
total: vols.length,
last_7_days: vols.filter((v) => v.created_at.slice(0, 10) >= weekAgo).length,
by_day: byDay,
},
answers: {
total: vols.length,
email: vols.filter((v) => v.email).length,
phone: vols.filter((v) => v.phone).length,
photo: vols.filter((v) => v.has_photo).length,
comment: vols.filter((v) => v.comment).length,
companion_note: vols.filter((v) => v.companion_wish_note).length,
available: vols.filter((v) => v.self_declared_available).length,
position_wishes: vols.filter((v) => v.wished_position_ids.length).length,
artist_wishes: vols.filter((v) => v.artist_ids.length).length,
companion_wishes: vols.filter((v) => v.wished_companion_ids.length).length,
},
payments: {
online: vols.filter((v) => v.fee_payment_status !== null).length,
on_site: vols.filter((v) => v.fee_payment_status === null && v.willing_to_pay_fee).length,
none: vols.filter((v) => v.fee_payment_status === null && !v.willing_to_pay_fee).length,
pending_unfinished: [...state.pendingSubmissions.values()].filter((p) => p.promotedVolunteerId === null).length,
total_collected: sum(paid),
mecenat_count: mecenat.length,
mecenat_total: sum(mecenat),
by_option: [...byLabel.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([label, group]) => ({
label,
mecenat: group.some((v) => v.mecenat_address),
count: group.length,
amount_total: sum(group),
})),
},
artist_wishes: [...state.artists]
.sort((a, b) => a.performance_date.localeCompare(b.performance_date) || a.start_time.localeCompare(b.start_time))
.map((a) => ({
id: a.id,
name: a.name,
date: a.performance_date,
count: vols.filter((v) => v.artist_ids.includes(a.id)).length,
})),
needs: {
by_day: [...rows.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, r]) => ({
date,
shifts: r.shifts,
min_hours: round(r.min),
ideal_hours: round(r.ideal),
assigned_hours: round(r.assigned),
})),
by_position: [...state.positions]
.sort((a, b) => a.name.localeCompare(b.name))
.map((p) => {
const own = state.shifts.filter((s) => s.position_id === p.id);
return {
position_id: p.id,
name: p.name,
shifts: own.length,
min_slots: own.reduce((t, s) => t + s.min_staff, 0),
ideal_slots: own.reduce((t, s) => t + s.ideal_staff, 0),
assigned: own.reduce((t, s) => t + (assignedByShift.get(s.id) ?? 0), 0),
filled: own.reduce((t, s) => t + Math.min(assignedByShift.get(s.id) ?? 0, s.ideal_staff), 0),
wishes: vols.filter((v) => v.wished_position_ids.includes(p.id)).length,
};
}),
volunteers_with_assignment: new Set(state.assignments.map((a) => a.volunteer_id)).size,
},
attendance: buildAttendance(),
};
}
/** Même calcul que `app/api/stats.py:_attendance`. */
function buildAttendance(): EditionStats['attendance'] {
const now = nowNaiveMinutes();
const empty = () => ({ assigned: 0, present: 0, absent: 0, unmarked: 0 });
const count = (c: ReturnType<typeof empty>, a: AssignmentRead) => {
c.assigned += 1;
if (a.attendance === 'PRESENT') c.present += 1;
else if (a.attendance === 'ABSENT') c.absent += 1;
else c.unmarked += 1;
};
const past = empty();
const live = empty();
const rows = new Map(
state.positions.map((p) => [
p.id,
{ position_id: p.id, name: p.name, past: empty(), live: empty(), live_shifts: 0 },
]),
);
const phase = new Map<string, 'past' | 'live'>();
for (const s of state.shifts) {
if (naiveMinutes(s.ends_at) <= now) phase.set(s.id, 'past');
else if (naiveMinutes(s.starts_at) <= now) {
phase.set(s.id, 'live');
const row = rows.get(s.position_id);
if (row) row.live_shifts += 1;
}
}
for (const a of state.assignments) {
const when = phase.get(a.shift_id);
const shift = state.shifts.find((s) => s.id === a.shift_id);
const row = shift ? rows.get(shift.position_id) : undefined;
if (!when || !row) continue;
count(when === 'past' ? past : live, a);
count(when === 'past' ? row.past : row.live, a);
}
return {
as_of: touch(),
past,
live,
live_shifts: [...rows.values()].reduce((n, r) => n + r.live_shifts, 0),
by_position: [...rows.values()]
.filter((r) => r.past.assigned || r.live.assigned || r.live_shifts)
.sort((a, b) => a.name.localeCompare(b.name, 'fr')),
};
}
function buildMatrix(): MatrixResponse { function buildMatrix(): MatrixResponse {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const a of state.assignments) { for (const a of state.assignments) {
@@ -573,8 +683,6 @@ function buildMatrix(): MatrixResponse {
const SOLVER_URGENT_BASE = 1000; const SOLVER_URGENT_BASE = 1000;
const SOLVER_NORMAL_BASE = 20; const SOLVER_NORMAL_BASE = 20;
const SOLVER_WISH_BONUS = 30; const SOLVER_WISH_BONUS = 30;
const SOLVER_AVAIL_PENALTY_PER_HALF_HOUR = 5;
const SOLVER_REST_PENALTY = 60;
const SOLVER_ARTIST_CONFLICT_PENALTY = 80; const SOLVER_ARTIST_CONFLICT_PENALTY = 80;
const SOLVER_FAIRNESS_PENALTY_PER_HOUR = 2; const SOLVER_FAIRNESS_PENALTY_PER_HOUR = 2;
// Le bénévole a déjà tenu le créneau immédiatement précédent (même jour, // Le bénévole a déjà tenu le créneau immédiatement précédent (même jour,
@@ -585,6 +693,8 @@ const SOLVER_CHAIN_PENALTY = 40;
// traité comme réciproque : si A souhaite B, B en profite aussi (voir // traité comme réciproque : si A souhaite B, B en profite aussi (voir
// `buildEffectiveCompanions`). // `buildEffectiveCompanions`).
const SOLVER_COMPANION_BONUS_PER_MATCH = 25; const SOLVER_COMPANION_BONUS_PER_MATCH = 25;
// Déjà affecté à un autre poste au même moment (cf. `_ALREADY_BUSY_PENALTY`).
const SOLVER_ALREADY_BUSY_PENALTY = 300;
type TimeWindow = { starts_at: string; ends_at: string }; type TimeWindow = { starts_at: string; ends_at: string };
@@ -687,24 +797,7 @@ function solverScore(
if (effectiveCompanionIds.has(id)) companionsPresent += 1; if (effectiveCompanionIds.has(id)) companionsPresent += 1;
} }
score += SOLVER_COMPANION_BONUS_PER_MATCH * companionsPresent; score += SOLVER_COMPANION_BONUS_PER_MATCH * companionsPresent;
if (existing.some((other) => overlaps(shift, other))) score -= SOLVER_ALREADY_BUSY_PENALTY;
const windows = state.availability.get(volunteer.id) ?? [];
const missed = uncoveredMinutes(windows, shift.starts_at, shift.ends_at);
score -= Math.floor(missed / 30) * SOLVER_AVAIL_PENALTY_PER_HALF_HOUR;
for (const other of existing) {
if (overlaps(shift, other)) continue;
let gap: number | null = null;
if (naiveMinutes(other.ends_at) <= naiveMinutes(shift.starts_at)) {
gap = naiveMinutes(shift.starts_at) - naiveMinutes(other.ends_at);
} else if (naiveMinutes(shift.ends_at) <= naiveMinutes(other.starts_at)) {
gap = naiveMinutes(other.starts_at) - naiveMinutes(shift.ends_at);
}
if (gap !== null && gap < EIGHT_HOURS_MIN) {
score -= SOLVER_REST_PENALTY;
break;
}
}
const existingMinutes = existing.reduce( const existingMinutes = existing.reduce(
(sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)), (sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)),
@@ -747,13 +840,8 @@ function runSolver(): SolverRunResult {
if (gap <= 0) continue; if (gap <= 0) continue;
const alreadyAssignedIds = assignedVolunteerIds(shift.id); const alreadyAssignedIds = assignedVolunteerIds(shift.id);
const picked = state.volunteers const picked = state.volunteers
.filter((v) => { .filter((v) => !alreadyAssignedIds.has(v.id))
const busy = [ .filter((v) => !(takenByVolunteer.get(v.id) ?? []).some((other) => overlaps(shift, other)))
...(existingByVolunteer.get(v.id) ?? []),
...(takenByVolunteer.get(v.id) ?? []),
];
return !busy.some((other) => overlaps(shift, other));
})
.map((v) => { .map((v) => {
const existing = existingByVolunteer.get(v.id) ?? []; const existing = existingByVolunteer.get(v.id) ?? [];
const predecessorIds = predecessorByShift.get(shift.id); const predecessorIds = predecessorByShift.get(shift.id);
@@ -796,6 +884,14 @@ function runSolver(): SolverRunResult {
}; };
} }
/** Message (organisation, poste) comme l'API : bords retirés, vide = plus
* de message, 2000 caractères au plus. */
function checkMessage(value: string | null | undefined): string | null {
const text = (value ?? '').trim() || null;
if (text && text.length > MESSAGE_MAX_LENGTH) throw new ApiError(422, 'error.generic');
return text;
}
export function createMockApi(): PlanningApi { export function createMockApi(): PlanningApi {
return { return {
editions: { editions: {
@@ -807,6 +903,13 @@ export function createMockApi(): PlanningApi {
if (body.start_date) next.start_date = body.start_date; if (body.start_date) next.start_date = body.start_date;
if (body.end_date) next.end_date = body.end_date; if (body.end_date) next.end_date = body.end_date;
if ('retention_days' in body) next.retention_days = body.retention_days ?? null; if ('retention_days' in body) next.retention_days = body.retention_days ?? null;
if ('volunteer_message' in body) {
const text = checkMessage(body.volunteer_message);
if (text !== next.volunteer_message) {
next.volunteer_message = text;
next.volunteer_message_updated_at = text ? touch() : null;
}
}
if (next.end_date < next.start_date) { if (next.end_date < next.start_date) {
throw new ApiError(422, 'edition.dates_inconsistent'); throw new ApiError(422, 'edition.dates_inconsistent');
} }
@@ -957,6 +1060,8 @@ export function createMockApi(): PlanningApi {
wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []), wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []),
has_photo: false, has_photo: false,
companion_wish_note: null, companion_wish_note: null,
badge_text: null,
badge_count: 1,
self_declared_available: false, self_declared_available: false,
willing_to_pay_fee: false, willing_to_pay_fee: false,
fee_payment_label: null, fee_payment_label: null,
@@ -967,11 +1072,12 @@ export function createMockApi(): PlanningApi {
mecenat_country: null, mecenat_country: null,
fee_payment_status: null, fee_payment_status: null,
fee_paid_at: null, fee_paid_at: null,
fee_transaction_code: null,
fee_transaction_id: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
state.volunteers.push(volunteer); state.volunteers.push(volunteer);
state.availability.set(volunteer.id, []);
return delay(volunteer); return delay(volunteer);
}, },
update: (_edition, id, body) => { update: (_edition, id, body) => {
@@ -1000,6 +1106,15 @@ export function createMockApi(): PlanningApi {
...('companion_wish_note' in body ...('companion_wish_note' in body
? { companion_wish_note: body.companion_wish_note ?? null } ? { companion_wish_note: body.companion_wish_note ?? null }
: {}), : {}),
...('badge_text' in body ? { badge_text: body.badge_text ?? null } : {}),
...(body.badge_count != null
? (() => {
if (!Number.isInteger(body.badge_count) || body.badge_count < 1 || body.badge_count > 100) {
throw new ApiError(422, 'error.generic');
}
return { badge_count: body.badge_count };
})()
: {}),
...('self_declared_available' in body && body.self_declared_available != null ...('self_declared_available' in body && body.self_declared_available != null
? { self_declared_available: body.self_declared_available } ? { self_declared_available: body.self_declared_available }
: {}), : {}),
@@ -1013,7 +1128,6 @@ export function createMockApi(): PlanningApi {
remove: (_edition, id) => { remove: (_edition, id) => {
state.volunteers = state.volunteers.filter((x) => x.id !== id); state.volunteers = state.volunteers.filter((x) => x.id !== id);
state.assignments = state.assignments.filter((a) => a.volunteer_id !== id); state.assignments = state.assignments.filter((a) => a.volunteer_id !== id);
state.availability.delete(id);
state.photos.delete(id); state.photos.delete(id);
// RGPD : effacement complet, y compris le compte de connexion éventuel // RGPD : effacement complet, y compris le compte de connexion éventuel
// et les vœux de compagnon dont il était la cible. // et les vœux de compagnon dont il était la cible.
@@ -1028,6 +1142,21 @@ export function createMockApi(): PlanningApi {
if (!dataUrl) return null; if (!dataUrl) return null;
return (await fetch(dataUrl)).blob(); return (await fetch(dataUrl)).blob();
}, },
setPhoto: async (_edition, id, file) => {
const v = state.volunteers.find((x) => x.id === id) ?? notFound();
if (!['image/png', 'image/jpeg'].includes(file.type)) {
throw new ApiError(422, 'form.photo_type_not_supported');
}
state.photos.set(id, await fileToDataUrl(file));
Object.assign(v, { has_photo: true, updated_at: touch() });
return delay(v);
},
removePhoto: (_edition, id) => {
const v = state.volunteers.find((x) => x.id === id) ?? notFound();
state.photos.delete(id);
Object.assign(v, { has_photo: false, updated_at: touch() });
return delay(v);
},
exportXlsx: () => delay(MOCK_XLSX_BLOB()), exportXlsx: () => delay(MOCK_XLSX_BLOB()),
exportMecenatReceipts: () => { exportMecenatReceipts: () => {
const paid = state.volunteers.some( const paid = state.volunteers.some(
@@ -1058,6 +1187,8 @@ export function createMockApi(): PlanningApi {
position_location: position?.location ?? null, position_location: position?.location ?? null,
referent_name: referent ? `${referent.first_name} ${referent.last_name}` : null, referent_name: referent ? `${referent.first_name} ${referent.last_name}` : null,
referent_phone: referent?.phone ?? null, referent_phone: referent?.phone ?? null,
position_message: position?.message ?? null,
position_message_updated_at: position?.message_updated_at ?? null,
}; };
}) })
.sort((a, b) => a.starts_at.localeCompare(b.starts_at)); .sort((a, b) => a.starts_at.localeCompare(b.starts_at));
@@ -1109,56 +1240,15 @@ export function createMockApi(): PlanningApi {
account.updated_at = touch(); account.updated_at = touch();
return delay(account); return delay(account);
}, },
// Pas de lien permanent en démo (liens magiques simulés) : rien à
// révoquer, le compte est renvoyé tel quel.
resetLoginLink: (_edition, id) => delay(state.accounts.find((a) => a.id === id) ?? notFound()),
remove: (_edition, id) => { remove: (_edition, id) => {
state.accounts = state.accounts.filter((a) => a.id !== id); state.accounts = state.accounts.filter((a) => a.id !== id);
return delay(undefined); return delay(undefined);
}, },
}, },
availability: {
list: (_edition, volunteerId) => {
if (!state.volunteers.some((v) => v.id === volunteerId)) return notFound();
return delay(sortWindows(state.availability.get(volunteerId) ?? []));
},
listEdition: () =>
delay(
sortWindows(
[...state.availability.values()].flat().filter((w) =>
state.volunteers.some((v) => v.id === w.volunteer_id),
),
),
),
replace: (_edition, volunteerId, body: AvailabilitySet) => {
if (!state.volunteers.some((v) => v.id === volunteerId)) return notFound();
const seen = new Set<string>();
const windows: AvailabilityRead[] = [];
for (const w of body.windows) {
if (w.date < state.edition.start_date || w.date > state.edition.end_date) {
throw new ApiError(422, 'availability.date_outside_edition');
}
const start = hms(w.start_time);
const end = hms(w.end_time);
if (start === end) throw new ApiError(422, 'availability.empty_window');
const key = `${w.date}|${start}|${end}`;
if (seen.has(key)) continue;
seen.add(key);
const now = touch();
windows.push({
id: `av${state.seq++}`,
edition_id: state.edition.id,
volunteer_id: volunteerId,
date: w.date,
start_time: start,
end_time: end,
created_at: now,
updated_at: now,
});
}
state.availability.set(volunteerId, windows);
return delay(sortWindows(windows));
},
},
imports: { imports: {
analyze: async (_edition, file: File) => { analyze: async (_edition, file: File) => {
const { headers, rows } = await readImportTable(file); const { headers, rows } = await readImportTable(file);
@@ -1308,6 +1398,8 @@ export function createMockApi(): PlanningApi {
wished_companion_ids: [], wished_companion_ids: [],
has_photo: false, has_photo: false,
companion_wish_note: null, companion_wish_note: null,
badge_text: null,
badge_count: 1,
self_declared_available: false, self_declared_available: false,
willing_to_pay_fee: false, willing_to_pay_fee: false,
fee_payment_label: null, fee_payment_label: null,
@@ -1318,6 +1410,8 @@ export function createMockApi(): PlanningApi {
mecenat_country: null, mecenat_country: null,
fee_payment_status: null, fee_payment_status: null,
fee_paid_at: null, fee_paid_at: null,
fee_transaction_code: null,
fee_transaction_id: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
@@ -1339,9 +1433,6 @@ export function createMockApi(): PlanningApi {
if (!mapping.dry_run) { if (!mapping.dry_run) {
state.volunteers = working; state.volunteers = working;
for (const v of working) {
if (!state.availability.has(v.id)) state.availability.set(v.id, []);
}
} }
return delay(report); return delay(report);
}, },
@@ -1361,6 +1452,8 @@ export function createMockApi(): PlanningApi {
location: body.location ?? null, location: body.location ?? null,
category: body.category, category: body.category,
lead_account_id: body.lead_account_id ?? null, lead_account_id: body.lead_account_id ?? null,
message: null,
message_updated_at: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
@@ -1372,6 +1465,13 @@ export function createMockApi(): PlanningApi {
update: (_edition, id, body) => { update: (_edition, id, body) => {
const p = state.positions.find((x) => x.id === id) ?? notFound(); const p = state.positions.find((x) => x.id === id) ?? notFound();
if ('lead_account_id' in body) checkLeadAccount(body.lead_account_id); if ('lead_account_id' in body) checkLeadAccount(body.lead_account_id);
if ('message' in body) {
const text = checkMessage(body.message);
if (text !== p.message) {
p.message = text;
p.message_updated_at = text ? touch() : null;
}
}
Object.assign(p, { Object.assign(p, {
...('name' in body ? { name: body.name } : {}), ...('name' in body ? { name: body.name } : {}),
...('location' in body ? { location: body.location ?? null } : {}), ...('location' in body ? { location: body.location ?? null } : {}),
@@ -1486,6 +1586,8 @@ export function createMockApi(): PlanningApi {
shift_id: body.shift_id, shift_id: body.shift_id,
source: body.source ?? 'MANUAL', source: body.source ?? 'MANUAL',
locked: body.locked ?? false, locked: body.locked ?? false,
attendance: null,
attendance_marked_at: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
@@ -1501,6 +1603,13 @@ export function createMockApi(): PlanningApi {
}); });
return delay({ assignment: a, warnings: [] as Violation[] }); return delay({ assignment: a, warnings: [] as Violation[] });
}, },
setAttendance: (_edition, id, body) => {
const a = state.assignments.find((x) => x.id === id) ?? notFound();
a.attendance = body.attendance;
a.attendance_marked_at = body.attendance ? touch() : null;
a.updated_at = touch();
return delay({ ...a });
},
remove: (_edition, id) => { remove: (_edition, id) => {
state.assignments = state.assignments.filter((x) => x.id !== id); state.assignments = state.assignments.filter((x) => x.id !== id);
return delay(undefined); return delay(undefined);
@@ -1515,6 +1624,10 @@ export function createMockApi(): PlanningApi {
get: () => delay(buildMatrix()), get: () => delay(buildMatrix()),
}, },
stats: {
get: () => delay(buildStats()),
},
badges: { badges: {
getTemplate: (): Promise<BadgeTemplateRead> => getTemplate: (): Promise<BadgeTemplateRead> =>
delay({ delay({
@@ -1561,13 +1674,25 @@ export function createMockApi(): PlanningApi {
return (await fetch(state.badgeTemplate.backImage)).blob(); return (await fetch(state.badgeTemplate.backImage)).blob();
}, },
setFields: (_edition, body: BadgeFieldsSet) => { setFields: (_edition, body: BadgeFieldsSet) => {
// Un même type de champ peut exister une fois par face (recto/verso),
// comme côté back-end.
const seen = new Set<string>(); const seen = new Set<string>();
const hexColor = /^#[0-9a-fA-F]{6}$/;
for (const f of body.fields) { for (const f of body.fields) {
if (seen.has(f.field_type)) throw new ApiError(422, 'badge.duplicate_field_type'); const key = `${f.face ?? 'FRONT'}:${f.field_type}`;
seen.add(f.field_type); if (seen.has(key)) throw new ApiError(422, 'badge.duplicate_field_type');
seen.add(key);
if (!hexColor.test(f.color) || (f.bg_color != null && !hexColor.test(f.bg_color))) {
throw new ApiError(422, 'badge.invalid_color');
}
} }
const fields: BadgeFieldRead[] = body.fields.map((f) => ({ const fields: BadgeFieldRead[] = body.fields.map((f) => ({
...f, ...f,
face: f.face ?? 'FRONT',
bg_color: f.bg_color ?? null,
auto_fit: f.auto_fit ?? true,
photo_shape: f.photo_shape ?? 'RECT',
z_order: f.z_order ?? 0,
id: `bf${state.seq++}`, id: `bf${state.seq++}`,
edition_id: state.edition.id, edition_id: state.edition.id,
})); }));
@@ -1775,6 +1900,8 @@ export function createMockApi(): PlanningApi {
wished_companion_ids: [], wished_companion_ids: [],
has_photo: photo !== null, has_photo: photo !== null,
companion_wish_note: companionWishNote, companion_wish_note: companionWishNote,
badge_text: null,
badge_count: 1,
self_declared_available: fields.has('AVAILABLE') ? available : false, self_declared_available: fields.has('AVAILABLE') ? available : false,
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'), willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'),
fee_payment_label: null, fee_payment_label: null,
@@ -1785,12 +1912,13 @@ export function createMockApi(): PlanningApi {
mecenat_country: null, mecenat_country: null,
fee_payment_status: null, fee_payment_status: null,
fee_paid_at: null, fee_paid_at: null,
fee_transaction_code: null,
fee_transaction_id: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
if (photo) state.photos.set(volunteer.id, photo); if (photo) state.photos.set(volunteer.id, photo);
state.volunteers.push(volunteer); state.volunteers.push(volunteer);
state.availability.set(volunteer.id, []);
return delay<FormSubmissionResult>({ return delay<FormSubmissionResult>({
volunteer_id: volunteer.id, volunteer_id: volunteer.id,
pending_token: null, pending_token: null,
@@ -1927,6 +2055,25 @@ export function createMockApi(): PlanningApi {
}, },
notifications: { notifications: {
// Pas de serveur SMTP en démo : rien n'est envoyé, comme
// `EMAIL_ENABLED=false` côté serveur.
sendTest: (_edition, body) => {
if (!/^[^\s@]+@[^\s@]+$/.test(body.email.trim())) {
throw new ApiError(422, 'notifications.invalid_email');
}
const assigned = new Set(state.assignments.map((a) => a.volunteer_id));
const sorted = [...state.volunteers].sort((a, b) => a.last_name.localeCompare(b.last_name, 'fr'));
const example =
sorted.find((v) => v.id === body.volunteer_id) ?? sorted.find((v) => assigned.has(v.id)) ?? sorted[0];
return delay({
status: 'disabled' as const,
detail: null,
smtp_host: 'localhost:25',
smtp_from: 'noreply@festival.local',
example_volunteer_id: example?.id ?? null,
example_volunteer_name: example ? `${example.first_name} ${example.last_name}`.trim() : '',
});
},
sendPlanning: (_edition, body: PlanningNotificationRequest) => { sendPlanning: (_edition, body: PlanningNotificationRequest) => {
const ids = body.volunteer_ids ?? null; const ids = body.volunteer_ids ?? null;
const targets = state.volunteers.filter((v) => ids === null || ids.includes(v.id)); const targets = state.volunteers.filter((v) => ids === null || ids.includes(v.id));
+191 -32
View File
@@ -18,11 +18,7 @@ export type AssignmentSource = 'SOLVER' | 'MANUAL';
export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER'; export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER';
export type AuthMethod = 'password' | 'magic_link'; export type AuthMethod = 'password' | 'magic_link';
export type ViolationCode = export type ViolationCode = 'SHIFT_OVERLAP';
| 'SHIFT_OVERLAP'
| 'OUTSIDE_AVAILABILITY'
| 'DAILY_HOURS_EXCEEDED'
| 'INSUFFICIENT_REST';
export type ViolationSeverity = 'BLOCKING' | 'WARNING'; export type ViolationSeverity = 'BLOCKING' | 'WARNING';
@@ -43,16 +39,25 @@ export interface EditionRead {
start_date: string; start_date: string;
end_date: string; end_date: string;
retention_days: number | null; retention_days: number | null;
/** Message de l'organisation, le même pour tous les bénévoles (écran
* bénévole), et la date de sa dernière modification. */
volunteer_message: string | null;
volunteer_message_updated_at: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
/** Longueur maximale d'un message (organisation, poste) — comme l'API. */
export const MESSAGE_MAX_LENGTH = 2000;
export interface EditionUpdate { export interface EditionUpdate {
name?: string | null; name?: string | null;
year?: number; year?: number;
start_date?: string; start_date?: string;
end_date?: string; end_date?: string;
retention_days?: number | null; retention_days?: number | null;
/** Vide = plus de message. */
volunteer_message?: string | null;
} }
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
@@ -111,6 +116,11 @@ export interface VolunteerRead {
/** Renseignés par le formulaire public d'inscription (voir schema forms). */ /** Renseignés par le formulaire public d'inscription (voir schema forms). */
has_photo: boolean; has_photo: boolean;
companion_wish_note: string | null; companion_wish_note: string | null;
/** Texte libre imprimé sur le badge (champ `FREE_TEXT`), saisi par la
* coordination. */
badge_text: string | null;
/** Exemplaires de son badge imprimés sur les planches (1 par défaut). */
badge_count: number;
self_declared_available: boolean; self_declared_available: boolean;
willing_to_pay_fee: boolean; willing_to_pay_fee: boolean;
/** Paiement de la cotisation via SumUp (voir schema forms) — /** Paiement de la cotisation via SumUp (voir schema forms) —
@@ -121,6 +131,12 @@ export interface VolunteerRead {
fee_amount: number | null; fee_amount: number | null;
fee_payment_status: FeePaymentStatus | null; fee_payment_status: FeePaymentStatus | null;
fee_paid_at: string | null; fee_paid_at: string | null;
/** Transaction SumUp qui a réglé la cotisation : `fee_transaction_code` est
* le code affiché dans le tableau de bord SumUp (ex. `TEENSK4W2K`),
* `fee_transaction_id` l'identifiant technique. `null` tant que non payée
* (ou payée avant que l'appli ne l'enregistre). */
fee_transaction_code: string | null;
fee_transaction_id: string | null;
/** Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un type /** Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un type
* de paiement « mécénat ». */ * de paiement « mécénat ». */
mecenat_address: string | null; mecenat_address: string | null;
@@ -155,6 +171,9 @@ export interface VolunteerUpdate {
wished_position_ids?: string[] | null; wished_position_ids?: string[] | null;
wished_companion_ids?: string[] | null; wished_companion_ids?: string[] | null;
companion_wish_note?: string | null; companion_wish_note?: string | null;
badge_text?: string | null;
/** 1 à 100. */
badge_count?: number | null;
self_declared_available?: boolean | null; self_declared_available?: boolean | null;
willing_to_pay_fee?: boolean | null; willing_to_pay_fee?: boolean | null;
} }
@@ -175,32 +194,9 @@ export interface VolunteerPlanningEntry {
* rattaché à une fiche bénévole (pas de téléphone à donner alors). */ * rattaché à une fiche bénévole (pas de téléphone à donner alors). */
referent_name: string | null; referent_name: string | null;
referent_phone: string | null; referent_phone: string | null;
} /** Message du poste (voir `PositionRead.message`). */
position_message: string | null;
// --------------------------------------------------------------------------- // position_message_updated_at: string | null;
// Availability (disponibilités déclarées par le bénévole) //
// --------------------------------------------------------------------------- //
export interface AvailabilityWindowInput {
/** Date ISO (YYYY-MM-DD). */
date: string;
/** Heure « HH:MM » ou « HH:MM:SS ». end <= start ⇒ se termine le lendemain. */
start_time: string;
end_time: string;
}
export interface AvailabilitySet {
windows: AvailabilityWindowInput[];
}
export interface AvailabilityRead {
id: string;
edition_id: string;
volunteer_id: string;
date: string;
start_time: string;
end_time: string;
created_at: string;
updated_at: string;
} }
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
@@ -306,6 +302,10 @@ export interface PositionRead {
location: string | null; location: string | null;
category: PositionCategory; category: PositionCategory;
lead_account_id: string | null; lead_account_id: string | null;
/** Message du poste (consignes…), saisi par son référent, montré aux
* bénévoles affectés au poste. */
message: string | null;
message_updated_at: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -322,6 +322,8 @@ export interface PositionUpdate {
location?: string | null; location?: string | null;
category?: PositionCategory; category?: PositionCategory;
lead_account_id?: string | null; lead_account_id?: string | null;
/** Vide = plus de message. */
message?: string | null;
} }
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
@@ -368,10 +370,20 @@ export interface AssignmentRead {
shift_id: string; shift_id: string;
source: AssignmentSource; source: AssignmentSource;
locked: boolean; locked: boolean;
/** Pointage du référent (`null` = pas encore pointé) et son heure. */
attendance: AttendanceStatus | null;
attendance_marked_at: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export type AttendanceStatus = 'PRESENT' | 'ABSENT';
export interface AttendanceUpdate {
/** `null` = annuler le pointage. */
attendance: AttendanceStatus | null;
}
export interface AssignmentCreate { export interface AssignmentCreate {
volunteer_id: string; volunteer_id: string;
shift_id: string; shift_id: string;
@@ -443,11 +455,26 @@ export interface MatrixResponse {
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// Badges (planches PDF) // // Badges (planches PDF) //
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
export type BadgeFieldType = 'FULL_NAME' | 'POSITIONS' | 'CATEGORY' | 'ROLE'; /** `PHOTO` : photo du bénévole ajustée au cadre ; `FREE_TEXT` : texte libre
* de sa fiche (`VolunteerRead.badge_text`), saisi par la coordination. */
export type BadgeFieldType =
| 'FULL_NAME'
| 'POSITIONS'
| 'CATEGORY'
| 'ROLE'
| 'PHOTO'
| 'FREE_TEXT';
export type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT'; export type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT';
/** Face du badge où se trouve un champ : chaque type de champ peut exister
* une fois par face. */
export type BadgeFace = 'FRONT' | 'BACK';
/** Forme du cadre de la photo (`PHOTO`). */
export type BadgePhotoShape = 'RECT' | 'OVAL';
export interface BadgeFieldIn { export interface BadgeFieldIn {
field_type: BadgeFieldType; field_type: BadgeFieldType;
/** Défaut `FRONT`. */
face: BadgeFace;
x_mm: number; x_mm: number;
y_mm: number; y_mm: number;
width_mm: number; width_mm: number;
@@ -456,6 +483,19 @@ export interface BadgeFieldIn {
align: BadgeAlign; align: BadgeAlign;
bold: boolean; bold: boolean;
color: string; color: string;
/** Fond de la zone du champ (`#rrggbb`), `null` = transparent. */
bg_color: string | null;
/** Réduit la taille du texte (par pas de 0,5 pt, jusqu'à 4 pt) quand il
* ne tient pas dans le cadre. */
auto_fit: boolean;
/** `PHOTO` seulement : rectangle ou ovale inscrit dans le cadre. */
photo_shape: BadgePhotoShape;
/** Ordre d'empilement sur la face : un champ recouvre ceux d'un
* `z_order` inférieur (photo dessous à égalité). */
z_order: number;
/** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour
* un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */
text?: string | null;
} }
export interface BadgeFieldsSet { export interface BadgeFieldsSet {
@@ -564,6 +604,28 @@ export interface PlanningNotificationResult {
recipients: string[]; recipients: string[];
} }
/** Résultat de l'email de test (`POST .../notifications/test`). */
export interface TestEmailResult {
status: 'sent' | 'disabled' | 'connection_failed' | 'rejected';
/** Message du serveur SMTP en cas d'échec. */
detail: string | null;
smtp_host: string;
smtp_from: string;
/** Bénévole dont les données ont rempli le modèle (`null` : exemple
* fictif). */
example_volunteer_id: string | null;
example_volunteer_name: string;
}
/** Email de test : le récapitulatif de planning en cours d'édition, rempli
* avec les données d'un bénévole, envoyé à `email`. */
export interface TestEmailRequest {
email: string;
subject?: string | null;
body?: string | null;
volunteer_id?: string | null;
}
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// Formulaire public d'inscription // // Formulaire public d'inscription //
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
@@ -731,3 +793,100 @@ export interface PublicFormSubmission {
artist_wish_ids?: string[]; artist_wish_ids?: string[];
photo?: File | null; photo?: File | null;
} }
// --------------------------------------------------------------------------- //
// Statistiques d'une édition (`GET /editions/{id}/stats`, app/api/stats.py) //
// --------------------------------------------------------------------------- //
export interface StatsRegistrationDay {
date: string;
count: number;
cumulative: number;
}
export interface StatsAnswers {
total: number;
email: number;
phone: number;
photo: number;
comment: number;
companion_note: number;
available: number;
position_wishes: number;
artist_wishes: number;
companion_wishes: number;
}
export interface StatsPaymentOption {
label: string;
mecenat: boolean;
count: number;
amount_total: number;
}
export interface StatsNeedDay {
date: string;
shifts: number;
min_hours: number;
ideal_hours: number;
assigned_hours: number;
}
export interface StatsNeedPosition {
position_id: string;
name: string;
shifts: number;
min_slots: number;
ideal_slots: number;
assigned: number;
/** Places pourvues, plafonnées créneau par créneau à l'effectif idéal. */
filled: number;
wishes: number;
}
export interface EditionStats {
registrations: { total: number; last_7_days: number; by_day: StatsRegistrationDay[] };
answers: StatsAnswers;
payments: {
online: number;
on_site: number;
none: number;
/** Formulaires remplis dont le paiement en ligne n'a jamais abouti. */
pending_unfinished: number;
total_collected: number;
mecenat_count: number;
mecenat_total: number;
by_option: StatsPaymentOption[];
};
artist_wishes: Array<{ id: string; name: string; date: string; count: number }>;
needs: {
by_day: StatsNeedDay[];
by_position: StatsNeedPosition[];
volunteers_with_assignment: number;
};
attendance: StatsAttendance;
}
/** Pointage d'un ensemble d'affectations : `assigned` = `present` +
* `absent` + `unmarked`. */
export interface StatsAttendanceCounts {
assigned: number;
present: number;
absent: number;
unmarked: number;
}
/** Présents / absents pointés, sur les créneaux terminés (`past`) et en
* cours (`live`) à `as_of` ; les créneaux à venir ne comptent pas. */
export interface StatsAttendance {
as_of: string;
past: StatsAttendanceCounts;
live: StatsAttendanceCounts;
live_shifts: number;
by_position: Array<{
position_id: string;
name: string;
past: StatsAttendanceCounts;
live: StatsAttendanceCounts;
live_shifts: number;
}>;
}
+23 -15
View File
@@ -11,8 +11,6 @@ import type {
AssignmentValidateRequest, AssignmentValidateRequest,
AssignmentValidateResult, AssignmentValidateResult,
AssignmentWriteResult, AssignmentWriteResult,
AvailabilityRead,
AvailabilitySet,
BadgeFieldRead, BadgeFieldRead,
BadgeFieldsSet, BadgeFieldsSet,
BadgeGenerateRequest, BadgeGenerateRequest,
@@ -58,6 +56,10 @@ import type {
VolunteerPlanningEntry, VolunteerPlanningEntry,
VolunteerRead, VolunteerRead,
VolunteerUpdate, VolunteerUpdate,
EditionStats,
AttendanceUpdate,
TestEmailRequest,
TestEmailResult,
} from './schema'; } from './schema';
/** /**
@@ -89,8 +91,13 @@ export interface PlanningApi {
remove(editionId: string, id: string): Promise<void>; remove(editionId: string, id: string): Promise<void>;
/** Planning enrichi (poste, lieu, référent) — écran bénévole. */ /** Planning enrichi (poste, lieu, référent) — écran bénévole. */
planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>; planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>;
/** Photo transmise via le formulaire public, `null` si absente. */ /** Photo transmise via le formulaire public (ou ajoutée par la
* coordination), `null` si absente. */
photoBlob(editionId: string, id: string): Promise<Blob | null>; photoBlob(editionId: string, id: string): Promise<Blob | null>;
/** Ajoute ou remplace la photo (COORDINATION) — PNG/JPEG. */
setPhoto(editionId: string, id: string, file: File): Promise<VolunteerRead>;
/** Retire la photo (COORDINATION). */
removePhoto(editionId: string, id: string): Promise<VolunteerRead>;
/** Export Excel de la liste des bénévoles (COORDINATION). */ /** Export Excel de la liste des bénévoles (COORDINATION). */
exportXlsx(editionId: string): Promise<Blob>; exportXlsx(editionId: string): Promise<Blob>;
/** Reçus fiscaux (cerfa) des bénévoles ayant payé en mécénat, un PDF /** Reçus fiscaux (cerfa) des bénévoles ayant payé en mécénat, un PDF
@@ -104,18 +111,9 @@ export interface PlanningApi {
/** Changement de rôle seulement (LEAD ↔ VOLUNTEER) — voir `AccountUpdate`. */ /** Changement de rôle seulement (LEAD ↔ VOLUNTEER) — voir `AccountUpdate`. */
update(editionId: string, id: string, body: AccountUpdate): Promise<AccountRead>; update(editionId: string, id: string, body: AccountUpdate): Promise<AccountRead>;
remove(editionId: string, id: string): Promise<void>; remove(editionId: string, id: string): Promise<void>;
}; /** Révoque le lien de connexion permanent (récapitulatif de planning) :
availability: { * les liens déjà envoyés cessent de fonctionner. */
/** Fenêtres déclarées par un bénévole. */ resetLoginLink(editionId: string, id: string): Promise<AccountRead>;
list(editionId: string, volunteerId: string): Promise<AvailabilityRead[]>;
/** Remplace l'intégralité des fenêtres d'un bénévole (COORDINATION). */
replace(
editionId: string,
volunteerId: string,
body: AvailabilitySet,
): Promise<AvailabilityRead[]>;
/** Toutes les fenêtres de l'édition (grille des besoins). */
listEdition(editionId: string): Promise<AvailabilityRead[]>;
}; };
imports: { imports: {
/** Phase 1 : lit le fichier, propose un mapping des colonnes. */ /** Phase 1 : lit le fichier, propose un mapping des colonnes. */
@@ -148,6 +146,7 @@ export interface PlanningApi {
create(editionId: string, body: AssignmentCreate): Promise<AssignmentWriteResult>; create(editionId: string, body: AssignmentCreate): Promise<AssignmentWriteResult>;
update(editionId: string, id: string, body: AssignmentUpdate): Promise<AssignmentWriteResult>; update(editionId: string, id: string, body: AssignmentUpdate): Promise<AssignmentWriteResult>;
remove(editionId: string, id: string): Promise<void>; remove(editionId: string, id: string): Promise<void>;
setAttendance(editionId: string, id: string, body: AttendanceUpdate): Promise<AssignmentRead>;
validate( validate(
editionId: string, editionId: string,
body: AssignmentValidateRequest, body: AssignmentValidateRequest,
@@ -156,6 +155,10 @@ export interface PlanningApi {
matrix: { matrix: {
get(editionId: string): Promise<MatrixResponse>; get(editionId: string): Promise<MatrixResponse>;
}; };
stats: {
/** Statistiques de l'édition (COORDINATION uniquement). */
get(editionId: string): Promise<EditionStats>;
};
badges: { badges: {
/** Configuration du recto/verso (COORDINATION uniquement). */ /** Configuration du recto/verso (COORDINATION uniquement). */
getTemplate(editionId: string): Promise<BadgeTemplateRead>; getTemplate(editionId: string): Promise<BadgeTemplateRead>;
@@ -226,6 +229,8 @@ export interface PlanningApi {
editionId: string, editionId: string,
body: PlanningNotificationRequest, body: PlanningNotificationRequest,
): Promise<PlanningNotificationResult>; ): Promise<PlanningNotificationResult>;
/** Email de test, envoyé tout de suite (COORDINATION). */
sendTest(editionId: string, body: TestEmailRequest): Promise<TestEmailResult>;
}; };
auth: { auth: {
login(editionId: string, body: LoginRequest): Promise<TokenResponse>; login(editionId: string, body: LoginRequest): Promise<TokenResponse>;
@@ -242,6 +247,9 @@ export class ApiError extends Error {
public readonly status: number, public readonly status: number,
public readonly code: string, public readonly code: string,
public readonly violations: Violation[] = [], public readonly violations: Violation[] = [],
/** En-tête `X-Request-ID` de la réponse : référence à communiquer pour
* retrouver l'erreur dans les logs du serveur (`app/request_logging.py`). */
public readonly requestId: string | null = null,
) { ) {
super(code); super(code);
this.name = 'ApiError'; this.name = 'ApiError';
+112 -32
View File
@@ -1,4 +1,5 @@
import { NavLink, Outlet } from 'react-router-dom'; import { useEffect, useState } from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { API_MODE } from '@/api'; import { API_MODE } from '@/api';
import { useVolunteers } from '@/api/hooks'; import { useVolunteers } from '@/api/hooks';
@@ -17,20 +18,30 @@ export function AppShell() {
const initials = account const initials = account
? account.email.slice(0, 2).toUpperCase() ? account.email.slice(0, 2).toUpperCase()
: '··'; : '··';
const hasNav = tabs.length > 1;
// Sous `lg:`, les onglets ne tiennent plus à côté du titre : ils passent
// sur une seconde ligne (tablette) ou dans un menu déroulant (mobile),
// refermé à chaque changement d'écran.
const [menuOpen, setMenuOpen] = useState(false);
const { pathname } = useLocation();
useEffect(() => setMenuOpen(false), [pathname]);
const currentTab = tabs.find((tab) => tab.to === pathname);
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-20 flex items-stretch border-b-[3px] border-ink bg-yellow"> <header className="sticky top-0 z-30 border-b-[3px] border-ink bg-yellow">
<div className="flex flex-col justify-center border-r-[3px] border-ink px-4 py-2"> <div className="flex items-stretch">
<div className="font-display text-[14px] font-black tracking-tight"> <div className="flex min-w-0 flex-col justify-center border-r-[3px] border-ink px-3 py-2 sm:px-4">
<div className="truncate font-display text-[13px] font-black tracking-tight sm:text-[14px]">
{t('header.title')} {t('header.title')}
</div> </div>
{isCoordinator ? ( {isCoordinator ? (
<div className="font-mono text-[9.5px] font-medium opacity-70"> <div className="truncate font-mono text-[9.5px] font-medium opacity-70">
{t('header.meta', { count: volunteers.data?.length ?? 0 })} {t('header.meta', { count: volunteers.data?.length ?? 0 })}
</div> </div>
) : ( ) : (
<div className="font-mono text-[9.5px] font-medium opacity-70"> <div className="truncate font-mono text-[9.5px] font-medium opacity-70">
{account ? t(roleLabelKey(account.role)) : ''} {account ? t(roleLabelKey(account.role)) : ''}
</div> </div>
)} )}
@@ -38,40 +49,22 @@ export function AppShell() {
{/* Rien à choisir avec un seul écran (bénévole, référent) : la {/* Rien à choisir avec un seul écran (bénévole, référent) : la
barre d'onglets n'a de sens que pour la coordination. */} barre d'onglets n'a de sens que pour la coordination. */}
<nav className={cx('flex overflow-x-auto', tabs.length <= 1 && 'hidden')}> {hasNav ? (
<nav className="hidden min-w-0 overflow-x-auto lg:flex">
{tabs.map((tab) => ( {tabs.map((tab) => (
<NavLink <TabLink key={tab.to} tab={tab} />
key={tab.to}
to={tab.to}
className={({ isActive }) =>
cx(
'relative flex min-w-[112px] flex-col items-start justify-center gap-[3px] border-r-2 border-ink px-4',
'font-display text-[11.5px] font-bold uppercase tracking-wide',
isActive ? 'bg-yellow' : 'hover:bg-yellow-hover',
)
}
>
{({ isActive }) => (
<>
<span className="font-mono text-[8.5px] font-medium opacity-60">{tab.num}</span>
<span>{t(tab.labelKey)}</span>
{isActive ? (
<span className="absolute inset-x-0 bottom-0 h-[5px] bg-ink" />
) : null}
</>
)}
</NavLink>
))} ))}
</nav> </nav>
) : null}
<div className="ml-auto flex items-center gap-[10px] border-l-[3px] border-ink px-[14px]"> <div className="ml-auto flex flex-none items-center gap-2 border-l-[3px] border-ink px-2 sm:gap-[10px] sm:px-[14px]">
{API_MODE === 'mock' ? ( {API_MODE === 'mock' ? (
<div className="hidden border-2 border-ink bg-blue px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider sm:block"> <div className="hidden border-2 border-ink bg-blue px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider xl:block">
{t('header.mockMode')} {t('header.mockMode')}
</div> </div>
) : null} ) : null}
<div <div
className="flex h-[30px] w-[30px] items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold" className="hidden h-[30px] w-[30px] flex-none items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold sm:flex"
title={account?.email} title={account?.email}
> >
{initials} {initials}
@@ -79,11 +72,68 @@ export function AppShell() {
<button <button
type="button" type="button"
onClick={logout} onClick={logout}
className="cursor-pointer border-2 border-ink bg-cream px-[10px] py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider hover:bg-orange" className="cursor-pointer border-2 border-ink bg-cream px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider hover:bg-orange sm:px-[10px]"
> >
{t('auth.logout')} {t('auth.logout')}
</button> </button>
{hasNav ? (
<button
type="button"
onClick={() => setMenuOpen((open) => !open)}
aria-expanded={menuOpen}
aria-label={t(menuOpen ? 'header.closeMenu' : 'header.openMenu')}
className="flex h-[30px] w-[34px] flex-none cursor-pointer items-center justify-center border-2 border-ink bg-ink font-display text-[15px] font-black text-yellow md:hidden"
>
{menuOpen ? '✕' : '☰'}
</button>
) : null}
</div> </div>
</div>
{hasNav ? (
<>
{/* Tablette : seconde ligne, onglets à parts égales. */}
<nav className="hidden overflow-x-auto border-t-[3px] border-ink md:flex lg:hidden">
{tabs.map((tab) => (
<TabLink key={tab.to} tab={tab} fill />
))}
</nav>
{/* Mobile : écran courant rappelé + menu déroulant. */}
<button
type="button"
onClick={() => setMenuOpen((open) => !open)}
className="flex w-full cursor-pointer items-center gap-2 border-t-[3px] border-ink bg-ink px-3 py-[7px] text-left font-display text-[11px] font-extrabold uppercase tracking-wide text-cream md:hidden"
>
{currentTab ? (
<span className="font-mono text-[8.5px] font-medium text-yellow">{currentTab.num}</span>
) : null}
<span className="flex-1 truncate">
{currentTab ? t(currentTab.labelKey) : t('header.openMenu')}
</span>
<span aria-hidden className="text-yellow">{menuOpen ? '▲' : '▼'}</span>
</button>
{menuOpen ? (
<nav className="flex flex-col border-t-[3px] border-ink bg-yellow md:hidden">
{tabs.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
className={({ isActive }) =>
cx(
'flex items-center gap-3 border-b-2 border-ink px-4 py-3 last:border-b-0',
'font-display text-[12px] font-bold uppercase tracking-wide',
isActive ? 'bg-ink text-yellow' : 'hover:bg-yellow-hover',
)
}
>
<span className="font-mono text-[9px] font-medium opacity-60">{tab.num}</span>
<span>{t(tab.labelKey)}</span>
</NavLink>
))}
</nav>
) : null}
</>
) : null}
</header> </header>
<main className="flex flex-1 flex-col"> <main className="flex flex-1 flex-col">
@@ -92,3 +142,33 @@ export function AppShell() {
</div> </div>
); );
} }
function TabLink({
tab,
fill = false,
}: {
tab: { to: string; num: string; labelKey: string };
fill?: boolean;
}) {
return (
<NavLink
to={tab.to}
className={({ isActive }) =>
cx(
'relative flex flex-none flex-col items-start justify-center gap-[3px] border-r-2 border-ink px-4 last:border-r-0',
'font-display text-[11.5px] font-bold uppercase tracking-wide',
fill ? 'min-w-[104px] flex-1 py-2' : 'min-w-[112px]',
isActive ? 'bg-yellow' : 'hover:bg-yellow-hover',
)
}
>
{({ isActive }) => (
<>
<span className="font-mono text-[8.5px] font-medium opacity-60">{tab.num}</span>
<span className="whitespace-nowrap">{t(tab.labelKey)}</span>
{isActive ? <span className="absolute inset-x-0 bottom-0 h-[5px] bg-ink" /> : null}
</>
)}
</NavLink>
);
}
+2
View File
@@ -11,6 +11,7 @@ import { GrillePage } from '@/pages/GrillePage';
import { MobileLeadPage } from '@/pages/MobileLeadPage'; import { MobileLeadPage } from '@/pages/MobileLeadPage';
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage'; import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
import { PublicSignupPage } from '@/pages/PublicSignupPage'; import { PublicSignupPage } from '@/pages/PublicSignupPage';
import { StatsPage } from '@/pages/StatsPage';
import { VolunteerViewPage } from '@/pages/VolunteerViewPage'; import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
export const routes: RouteObject[] = [ export const routes: RouteObject[] = [
@@ -35,6 +36,7 @@ export const routes: RouteObject[] = [
{ path: 'configuration', element: <ConfigPage /> }, { path: 'configuration', element: <ConfigPage /> },
{ path: 'badges', element: <BadgesPage /> }, { path: 'badges', element: <BadgesPage /> },
{ path: 'inscriptions', element: <FormBuilderPage /> }, { path: 'inscriptions', element: <FormBuilderPage /> },
{ path: 'statistiques', element: <StatsPage /> },
], ],
}, },
], ],
+2
View File
@@ -11,6 +11,7 @@ export const ROUTE_ROLES: Record<string, Role[]> = {
'/configuration': ['COORDINATOR'], '/configuration': ['COORDINATOR'],
'/badges': ['COORDINATOR'], '/badges': ['COORDINATOR'],
'/inscriptions': ['COORDINATOR'], '/inscriptions': ['COORDINATOR'],
'/statistiques': ['COORDINATOR'],
'/mobile-benevole': ['VOLUNTEER'], '/mobile-benevole': ['VOLUNTEER'],
'/mobile-referent': ['LEAD'], '/mobile-referent': ['LEAD'],
}; };
@@ -23,6 +24,7 @@ export const NAV_TABS: Array<{ to: string; num: string; labelKey: string }> = [
{ to: '/configuration', num: '06', labelKey: 'tab.config' }, { to: '/configuration', num: '06', labelKey: 'tab.config' },
{ to: '/badges', num: '07', labelKey: 'tab.badges' }, { to: '/badges', num: '07', labelKey: 'tab.badges' },
{ to: '/inscriptions', num: '08', labelKey: 'tab.forms' }, { to: '/inscriptions', num: '08', labelKey: 'tab.forms' },
{ to: '/statistiques', num: '09', labelKey: 'tab.stats' },
]; ];
export function canAccess(pathname: string, role: Role): boolean { export function canAccess(pathname: string, role: Role): boolean {
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useState } from 'react';
import { ApiError } from '@/api';
import { MESSAGE_MAX_LENGTH } from '@/api/schema';
import { Btn } from '@/components/ui';
import { cx } from '@/lib/cx';
import { messageDateLabel } from '@/lib/slotTimeline';
import { useNowMinutes } from '@/lib/useNowMinutes';
import { t } from '@/i18n';
/**
* Saisie d'un message montré aux bénévoles — celui de l'organisation
* (coordination, `ConfigMessage`) ou celui d'un poste (référent,
* `MobileLeadPage`). Enregistré sur demande ; vider le champ retire le
* message.
*/
export function MessageEditor({
value,
updatedAt,
placeholder,
saving,
error,
onSave,
dark = false,
}: {
value: string | null;
updatedAt: string | null;
placeholder: string;
saving: boolean;
error: unknown;
onSave: (text: string) => Promise<unknown>;
/** Sur fond sombre (en-tête de l'écran référent). */
dark?: boolean;
}) {
const [draft, setDraft] = useState(value ?? '');
const [saved, setSaved] = useState(false);
const now = useNowMinutes();
// Le message enregistré change (autre poste choisi, autre appareil) :
// le brouillon repart de lui.
useEffect(() => setDraft(value ?? ''), [value]);
const dirty = draft.trim() !== (value ?? '');
const tooLong = draft.length > MESSAGE_MAX_LENGTH;
return (
<div className="flex flex-col gap-[7px]">
<textarea
value={draft}
onChange={(e) => {
setDraft(e.target.value);
setSaved(false);
}}
rows={4}
placeholder={placeholder}
className="w-full resize-y border-2 border-ink bg-cream p-2 font-display text-[14px] font-semibold leading-snug text-ink"
/>
<div className="flex flex-wrap items-center gap-2">
<Btn
disabled={!dirty || tooLong || saving}
onClick={() => {
void onSave(draft.trim()).then(() => setSaved(true), () => undefined);
}}
>
{saving ? t('message.saving') : draft.trim() ? t('message.save') : t('message.remove')}
</Btn>
<span
className={cx(
'font-mono text-[10px] leading-snug',
tooLong ? 'font-bold text-orange' : 'opacity-65',
)}
>
{saved && !dirty
? t('message.saved')
: value && updatedAt && !dirty
? t('message.updatedAt', { when: messageDateLabel(updatedAt, now) })
: t('message.length', { count: draft.length, max: MESSAGE_MAX_LENGTH })}
</span>
</div>
{error ? (
<div className={cx('font-mono text-[10.5px] font-bold', dark ? 'text-orange' : 'text-orange-dark')}>
{error instanceof ApiError ? t(error.code) : t('error.generic')}
</div>
) : null}
</div>
);
}
-16
View File
@@ -1,16 +0,0 @@
import { t } from '@/i18n';
/** Repère horizontal « MAINTENANT », à insérer dans une liste triée
* chronologiquement (créneaux d'un bénévole, créneaux d'un poste…) au point
* correspondant à l'heure réelle. Purement visuel. */
export function NowLine() {
return (
<div className="flex items-center gap-2 px-1" aria-hidden="true">
<div className="h-[3px] flex-1 bg-orange" />
<div className="bg-orange px-[7px] py-[3px] font-mono text-[10px] font-bold text-white">
{t('common.now')}
</div>
<div className="h-[3px] flex-1 bg-orange" />
</div>
);
}
+527
View File
@@ -0,0 +1,527 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { cx } from '@/lib/cx';
// Graphiques de l'écran Statistiques — SVG à la main (aucune bibliothèque de
// graphiques dans le projet). Règles suivies : marques fines (barres ≤ 24 px,
// bout arrondi de 4 px côté valeur, carré côté ligne de base ; courbes de
// 2 px), séparation par un vide de 2 px couleur de fond (jamais de bordure),
// quadrillage en filet plein discret, un seul axe par graphique, texte
// toujours à l'encre (jamais dans la couleur de la série), infobulle au
// survol et au focus — qui ne fait que compléter : chaque graphique a sa vue
// en tableau (`TableToggle`). Palette catégorielle vérifiée (daltonisme,
// séparation, contraste) sur le fond `paper` : l'aqua est sous 3:1, d'où
// libellés directs + tableau.
export const SERIES = {
blue: '#2a78d6',
orange: '#eb6834',
aqua: '#1baf7a',
/** « Rien / aucun » : gris de mise en retrait, jamais une série à part entière. */
muted: '#c9c3b3',
} as const;
const GRID = '#E0D8C4'; // sand-line : un cran au-dessus du fond paper
const SURFACE = '#FFFDF7'; // paper
const BAR_MAX_PX = 24;
const fr = (n: number, digits = 0) =>
n.toLocaleString('fr-FR', { maximumFractionDigits: digits, minimumFractionDigits: 0 });
/** Graduations « propres » (0, 5, 10… / 0, 20, 40…) couvrant `max`. */
function niceTicks(max: number, target = 4): number[] {
if (max <= 0) return [0, 1];
const raw = max / target;
const pow = 10 ** Math.floor(Math.log10(raw));
const step = [1, 2, 2.5, 5, 10].map((m) => m * pow).find((s) => s >= raw) ?? raw;
const ticks: number[] = [];
for (let v = 0; v <= max + step * 0.999; v += step) ticks.push(Math.round(v * 100) / 100);
return ticks;
}
/** Largeur réelle du conteneur (le SVG se dessine en pixels, pas en %). */
function useWidth<T extends HTMLElement>(fallback = 640) {
const ref = useRef<T>(null);
const [width, setWidth] = useState(fallback);
useLayoutEffect(() => {
const el = ref.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver(([entry]) => setWidth(Math.max(240, entry.contentRect.width)));
ro.observe(el);
return () => ro.disconnect();
}, []);
return { ref, width };
}
// --------------------------------------------------------------------------- //
// Infobulle (position: fixed, bornée à l'écran — même raison que //
// lib/useTooltipPosition.ts : jamais coincée par un conteneur défilant) //
// --------------------------------------------------------------------------- //
interface TipState {
x: number;
y: number;
content: ReactNode;
}
function useTip() {
const [tip, setTip] = useState<TipState | null>(null);
const show = (e: { clientX: number; clientY: number }, content: ReactNode) =>
setTip({ x: e.clientX, y: e.clientY, content });
const showAt = (el: Element, content: ReactNode) => {
const r = el.getBoundingClientRect();
setTip({ x: r.left + r.width / 2, y: r.top, content });
};
return { tip, show, showAt, hide: () => setTip(null) };
}
function Tooltip({ tip }: { tip: TipState | null }) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ left: -9999, top: -9999 });
useLayoutEffect(() => {
if (!tip || !ref.current) return;
const { width, height } = ref.current.getBoundingClientRect();
const left = Math.min(Math.max(tip.x + 12, 8), window.innerWidth - width - 8);
const top = tip.y - height - 12 < 8 ? tip.y + 16 : tip.y - height - 12;
setPos({ left, top });
}, [tip]);
if (!tip) return null;
return (
<div
ref={ref}
role="tooltip"
style={{ position: 'fixed', left: pos.left, top: pos.top, zIndex: 50 }}
className="pointer-events-none border-2 border-ink bg-paper px-[9px] py-[6px] font-mono text-[11px] shadow-brutal-sm"
>
{tip.content}
</div>
);
}
/** Ligne d'infobulle : la valeur d'abord (forte), puis la série (avec un
* court trait de sa couleur, pas une pastille pleine). */
function TipRow({ color, label, value }: { color?: string; label: string; value: string }) {
return (
<div className="flex items-center gap-[6px] whitespace-nowrap">
{color ? <span className="inline-block h-[2px] w-[10px]" style={{ background: color }} /> : null}
<b>{value}</b>
<span className="opacity-70">{label}</span>
</div>
);
}
// --------------------------------------------------------------------------- //
// Légende, vue tableau //
// --------------------------------------------------------------------------- //
export function Legend({ items }: { items: Array<{ label: string; color: string; value?: string }> }) {
return (
<div className="flex flex-wrap gap-x-[14px] gap-y-[4px] font-mono text-[10.5px]">
{items.map((item) => (
<span key={item.label} className="flex items-center gap-[6px]">
<span className="inline-block h-[10px] w-[10px] rounded-[2px]" style={{ background: item.color }} />
{item.label}
{item.value ? <b>{item.value}</b> : null}
</span>
))}
</div>
);
}
/** Équivalent tableau d'un graphique — toujours disponible, replié. */
export function TableToggle({ label, children }: { label: string; children: ReactNode }) {
return (
<details className="mt-2">
<summary className="cursor-pointer font-display text-[9.5px] font-extrabold uppercase tracking-wide opacity-70 hover:opacity-100">
{label}
</summary>
<div className="mt-2 overflow-x-auto">{children}</div>
</details>
);
}
export function DataTable({
head,
rows,
}: {
head: string[];
rows: Array<Array<ReactNode>>;
}) {
return (
<table className="w-full border-collapse font-mono text-[11px]">
<thead>
<tr>
{head.map((h, i) => (
<th
key={h}
className={cx(
'border-b-2 border-ink px-[6px] py-[4px] font-display text-[9.5px] font-extrabold uppercase tracking-wide',
i === 0 ? 'text-left' : 'text-right',
)}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, r) => (
<tr key={r} className="border-b border-sand-line">
{row.map((cell, c) => (
<td
key={c}
className={cx('px-[6px] py-[4px]', c === 0 ? 'text-left' : 'text-right tabular-nums')}
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
// --------------------------------------------------------------------------- //
// Courbe cumulée (une série : pas de légende, le titre la nomme) //
// --------------------------------------------------------------------------- //
export function CumulativeLineChart({
points,
dateLabel,
valueLabel,
dailyLabel,
}: {
points: Array<{ date: string; count: number; cumulative: number }>;
dateLabel: (date: string) => string;
valueLabel: string;
dailyLabel: string;
}) {
const { ref, width } = useWidth<HTMLDivElement>();
const { tip, show, hide } = useTip();
const [hover, setHover] = useState<number | null>(null);
const height = 220;
const pad = { top: 16, right: 48, bottom: 28, left: 40 };
const innerW = width - pad.left - pad.right;
const innerH = height - pad.top - pad.bottom;
const ticks = niceTicks(Math.max(...points.map((p) => p.cumulative), 1));
const yMax = ticks[ticks.length - 1];
const x = (i: number) => pad.left + (points.length <= 1 ? innerW / 2 : (i / (points.length - 1)) * innerW);
const y = (v: number) => pad.top + innerH - (v / yMax) * innerH;
const line = points.map((p, i) => `${i ? 'L' : 'M'}${x(i)},${y(p.cumulative)}`).join(' ');
const area = `${line} L${x(points.length - 1)},${y(0)} L${x(0)},${y(0)} Z`;
const last = points[points.length - 1];
// Quelques dates seulement sous l'axe, réparties du début à la fin — leur
// nombre suit la largeur (≈ une tous les 80 px), sinon elles se
// chevauchent sur un téléphone.
const slots = Math.max(2, Math.min(points.length, Math.floor(innerW / 80) + 1));
const labelled = new Set(
Array.from({ length: slots }, (_, k) =>
Math.round((k * (points.length - 1)) / Math.max(1, slots - 1)),
),
);
const pick = (clientX: number, rect: DOMRect) => {
const rel = clientX - rect.left - pad.left;
return Math.min(points.length - 1, Math.max(0, Math.round((rel / innerW) * (points.length - 1))));
};
const tipContent = (i: number) => (
<>
<div className="mb-[2px] font-display text-[10px] font-extrabold">{dateLabel(points[i].date)}</div>
<TipRow color={SERIES.blue} label={valueLabel} value={fr(points[i].cumulative)} />
<TipRow label={dailyLabel} value={`+${fr(points[i].count)}`} />
</>
);
return (
<div ref={ref} className="w-full min-w-0 overflow-hidden">
<svg
width={width}
height={height}
role="img"
tabIndex={0}
aria-label={`${valueLabel} : ${last ? fr(last.cumulative) : 0}`}
onPointerMove={(e) => {
const i = pick(e.clientX, e.currentTarget.getBoundingClientRect());
setHover(i);
show(e, tipContent(i));
}}
onPointerLeave={() => {
setHover(null);
hide();
}}
onBlur={() => {
setHover(null);
hide();
}}
onKeyDown={(e) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
const i = Math.min(points.length - 1, Math.max(0, (hover ?? points.length - 1) + (e.key === 'ArrowLeft' ? -1 : 1)));
setHover(i);
const rect = e.currentTarget.getBoundingClientRect();
show({ clientX: rect.left + x(i), clientY: rect.top + y(points[i].cumulative) }, tipContent(i));
}}
className="block outline-none focus-visible:outline-2 focus-visible:outline-ink"
>
{ticks.map((tk) => (
<g key={tk}>
<line x1={pad.left} x2={width - pad.right} y1={y(tk)} y2={y(tk)} stroke={GRID} strokeWidth={1} />
<text x={pad.left - 6} y={y(tk) + 3} textAnchor="end" className="fill-ink font-mono text-[10px] opacity-60">
{fr(tk)}
</text>
</g>
))}
{points.map((p, i) =>
labelled.has(i) ? (
<text
key={p.date}
x={x(i)}
y={height - 8}
textAnchor={i === 0 ? 'start' : i === points.length - 1 ? 'end' : 'middle'}
className="fill-ink font-mono text-[10px] opacity-60"
>
{dateLabel(p.date)}
</text>
) : null,
)}
<path d={area} fill={SERIES.blue} fillOpacity={0.1} />
<path d={line} fill="none" stroke={SERIES.blue} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
{hover !== null ? (
<>
<line x1={x(hover)} x2={x(hover)} y1={pad.top} y2={pad.top + innerH} stroke="#14110E" strokeOpacity={0.35} strokeWidth={1} />
<circle cx={x(hover)} cy={y(points[hover].cumulative)} r={4} fill={SERIES.blue} stroke={SURFACE} strokeWidth={2} />
</>
) : null}
{last ? (
<>
<circle cx={x(points.length - 1)} cy={y(last.cumulative)} r={4} fill={SERIES.blue} stroke={SURFACE} strokeWidth={2} />
<text x={x(points.length - 1) + 8} y={y(last.cumulative) + 4} className="fill-ink font-mono text-[11px] font-bold">
{fr(last.cumulative)}
</text>
</>
) : null}
</svg>
<Tooltip tip={tip} />
</div>
);
}
// --------------------------------------------------------------------------- //
// Barres horizontales (une série, une couleur pour toutes les barres) //
// --------------------------------------------------------------------------- //
export function BarList({
rows,
max,
color = SERIES.blue,
}: {
rows: Array<{ key: string; label: string; value: number; valueLabel: string; tip?: string }>;
/** Échelle commune (ex. 100 pour des pourcentages) — défaut : la plus grande valeur. */
max?: number;
color?: string;
}) {
const { tip, show, showAt, hide } = useTip();
const scale = max ?? Math.max(...rows.map((r) => r.value), 1);
return (
<div className="flex flex-col gap-[6px]">
{rows.map((r) => (
<div
key={r.key}
tabIndex={0}
onPointerMove={(e) => show(e, <TipRow color={color} label={r.label} value={r.tip ?? r.valueLabel} />)}
onPointerLeave={hide}
onFocus={(e) => showAt(e.currentTarget, <TipRow color={color} label={r.label} value={r.tip ?? r.valueLabel} />)}
onBlur={hide}
className="grid items-center gap-[8px] outline-none hover:bg-cream focus-visible:bg-cream"
style={{ gridTemplateColumns: 'minmax(90px, 34%) 1fr' }}
>
<span className="truncate font-mono text-[11px]" title={r.label}>
{r.label}
</span>
<span className="flex items-center gap-[6px]">
<span
className="block h-[14px] rounded-r-[4px]"
style={{
width: `${Math.max(r.value > 0 ? 1.5 : 0, (r.value / scale) * 82)}%`,
background: color,
}}
/>
<span className="whitespace-nowrap font-mono text-[10.5px] font-bold">{r.valueLabel}</span>
</span>
</div>
))}
<Tooltip tip={tip} />
</div>
);
}
// --------------------------------------------------------------------------- //
// Colonnes groupées (plusieurs séries, même unité, un seul axe) //
// --------------------------------------------------------------------------- //
export function GroupedColumns({
groups,
series,
unit,
}: {
groups: Array<{ key: string; label: string; values: number[] }>;
series: Array<{ label: string; color: string }>;
unit: string;
}) {
const { ref, width } = useWidth<HTMLDivElement>();
const { tip, show, hide, showAt } = useTip();
const [hover, setHover] = useState<number | null>(null);
const height = 240;
const pad = { top: 18, right: 8, bottom: 30, left: 44 };
const innerW = width - pad.left - pad.right;
const innerH = height - pad.top - pad.bottom;
const ticks = niceTicks(Math.max(...groups.flatMap((g) => g.values), 1));
const yMax = ticks[ticks.length - 1];
const band = innerW / Math.max(groups.length, 1);
const barW = Math.min(BAR_MAX_PX, (band * 0.7 - 2 * (series.length - 1)) / series.length);
const groupW = barW * series.length + 2 * (series.length - 1);
const y = (v: number) => pad.top + innerH - (v / yMax) * innerH;
const content = (g: (typeof groups)[number]) => (
<>
<div className="mb-[2px] font-display text-[10px] font-extrabold">{g.label}</div>
{series.map((s, i) => (
<TipRow key={s.label} color={s.color} label={s.label} value={`${fr(g.values[i], 1)} ${unit}`} />
))}
</>
);
// Barre au bout arrondi côté valeur (haut), carré côté ligne de base.
const bar = (x0: number, v: number) => {
const top = y(v);
const h = y(0) - top;
if (h <= 0) return '';
const r = Math.min(4, h, barW / 2);
return `M${x0},${y(0)} V${top + r} Q${x0},${top} ${x0 + r},${top} H${x0 + barW - r} Q${x0 + barW},${top} ${x0 + barW},${top + r} V${y(0)} Z`;
};
return (
<div ref={ref} className="w-full min-w-0 overflow-hidden">
<svg width={width} height={height} role="img" aria-label={series.map((s) => s.label).join(', ')} className="block">
{ticks.map((tk) => (
<g key={tk}>
<line x1={pad.left} x2={width - pad.right} y1={y(tk)} y2={y(tk)} stroke={GRID} strokeWidth={1} />
<text x={pad.left - 6} y={y(tk) + 3} textAnchor="end" className="fill-ink font-mono text-[10px] opacity-60">
{fr(tk)}
</text>
</g>
))}
{groups.map((g, gi) => {
const x0 = pad.left + gi * band + (band - groupW) / 2;
return (
<g
key={g.key}
tabIndex={0}
onPointerMove={(e) => {
setHover(gi);
show(e, content(g));
}}
onPointerLeave={() => {
setHover(null);
hide();
}}
onFocus={(e) => {
setHover(gi);
showAt(e.currentTarget, content(g));
}}
onBlur={() => {
setHover(null);
hide();
}}
className="outline-none"
>
{/* Zone de survol : toute la bande du jour, pas seulement les barres. */}
<rect
x={pad.left + gi * band}
y={pad.top}
width={band}
height={innerH}
fill={hover === gi ? '#14110E' : 'transparent'}
fillOpacity={hover === gi ? 0.04 : 0}
/>
{series.map((s, si) => (
<path key={s.label} d={bar(x0 + si * (barW + 2), g.values[si])} fill={s.color} />
))}
<text x={pad.left + gi * band + band / 2} y={height - 10} textAnchor="middle" className="fill-ink font-mono text-[10.5px]">
{g.label}
</text>
</g>
);
})}
<line x1={pad.left} x2={width - pad.right} y1={y(0)} y2={y(0)} stroke="#14110E" strokeOpacity={0.4} strokeWidth={1} />
</svg>
<Tooltip tip={tip} />
</div>
);
}
// --------------------------------------------------------------------------- //
// Répartition (partie d'un tout) — une barre, segments séparés par 2 px //
// --------------------------------------------------------------------------- //
export function ShareBar({
segments,
}: {
segments: Array<{ label: string; value: number; color: string }>;
}) {
const { tip, show, showAt, hide } = useTip();
const total = segments.reduce((t, s) => t + s.value, 0);
const visible = segments.filter((s) => s.value > 0);
const pct = (v: number) => (total ? Math.round((v / total) * 100) : 0);
return (
<div>
<div className="flex h-[22px] w-full gap-[2px]">
{visible.map((s, i) => (
<div
key={s.label}
tabIndex={0}
onPointerMove={(e) => show(e, <TipRow color={s.color} label={s.label} value={`${fr(s.value)} (${pct(s.value)} %)`} />)}
onPointerLeave={hide}
onFocus={(e) => showAt(e.currentTarget, <TipRow color={s.color} label={s.label} value={`${fr(s.value)} (${pct(s.value)} %)`} />)}
onBlur={hide}
className={cx(
'h-full outline-none hover:brightness-110 focus-visible:brightness-110',
i === 0 && 'rounded-l-[4px]',
i === visible.length - 1 && 'rounded-r-[4px]',
)}
style={{ flexGrow: s.value, flexBasis: 0, background: s.color }}
/>
))}
</div>
<div className="mt-2">
<Legend items={segments.map((s) => ({ label: s.label, color: s.color, value: `${fr(s.value)} · ${pct(s.value)} %` }))} />
</div>
<Tooltip tip={tip} />
</div>
);
}
// --------------------------------------------------------------------------- //
// Jauge : une valeur rapportée à un maximum (piste = même teinte, plus claire) //
// --------------------------------------------------------------------------- //
export function Meter({
value,
max,
label,
extra,
title,
}: {
value: number;
max: number;
label: string;
/** Complément court après le libellé (ex. « +2 »), dans une colonne fixe
* pour que les jauges restent alignées d'une ligne à l'autre. */
extra?: string;
title?: string;
}) {
const ratio = max > 0 ? Math.min(1, value / max) : 0;
return (
<span className="flex items-center gap-[6px]" title={title ?? label}>
<span className="relative block h-[8px] w-[72px] rounded-[4px]" style={{ background: '#cde2fb' }}>
<span
className="absolute inset-y-0 left-0 rounded-[4px]"
style={{ width: `${ratio * 100}%`, background: SERIES.blue }}
/>
</span>
<span className="w-[64px] text-left font-mono text-[10.5px] font-bold">{label}</span>
<span className="w-[26px] text-left font-mono text-[10px] opacity-60">{extra ?? ''}</span>
</span>
);
}
+5
View File
@@ -35,6 +35,11 @@ export function ErrorNote({ error }: { error: unknown }) {
<span className="mt-1 block font-mono text-[10px] font-normal opacity-80"> <span className="mt-1 block font-mono text-[10px] font-normal opacity-80">
{known ? t(code) : code} {known ? t(code) : code}
</span> </span>
{error instanceof ApiError && error.requestId ? (
<span className="mt-1 block select-all font-mono text-[10px] font-normal opacity-80">
{t('app.error.reference', { id: error.requestId })}
</span>
) : null}
</div> </div>
); );
} }
+348 -43
View File
@@ -5,9 +5,10 @@
"action.remove": "Retirer", "action.remove": "Retirer",
"action.add": "Ajouter", "action.add": "Ajouter",
"app.loading": "Chargement…", "app.loading": "Chargement…",
"common.now": "MAINTENANT",
"app.error.generic": "Une erreur est survenue", "app.error.generic": "Une erreur est survenue",
"app.error.reference": "Référence à communiquer : {id}",
"error.generic": "Erreur inattendue", "error.generic": "Erreur inattendue",
"error.internal": "Erreur interne du serveur",
"error.not_found": "Ressource introuvable", "error.not_found": "Ressource introuvable",
"error.forbidden": "Accès refusé", "error.forbidden": "Accès refusé",
"error.unauthorized": "Authentification requise", "error.unauthorized": "Authentification requise",
@@ -52,6 +53,8 @@
"header.title": "GESTION DES BÉNÉVOLES", "header.title": "GESTION DES BÉNÉVOLES",
"header.meta": "BÉNÉVOLAT · {count} INSCRITS", "header.meta": "BÉNÉVOLAT · {count} INSCRITS",
"header.mockMode": "DONNÉES DE DÉMO", "header.mockMode": "DONNÉES DE DÉMO",
"header.openMenu": "Menu",
"header.closeMenu": "Fermer le menu",
"tab.grille": "Grille des besoins", "tab.grille": "Grille des besoins",
"tab.volunteers": "Vue par bénévole", "tab.volunteers": "Vue par bénévole",
"tab.mobileVolunteer": "Bénévole · mobile", "tab.mobileVolunteer": "Bénévole · mobile",
@@ -69,13 +72,7 @@
"position.category.other": "Autre", "position.category.other": "Autre",
"validation.SHIFT_OVERLAP": "Se chevauche avec un autre créneau", "validation.SHIFT_OVERLAP": "Se chevauche avec un autre créneau",
"validation.SHIFT_OVERLAP.at": "Déjà affecté sur {position} au même moment", "validation.SHIFT_OVERLAP.at": "Déjà affecté sur {position} au même moment",
"validation.SHIFT_OVERLAP.rule": "Deux créneaux d'un même bénévole se chevauchent.", "validation.SHIFT_OVERLAP.rule": "Deux créneaux d'un même bénévole se chevauchent (avertissement, non bloquant).",
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées ({gap} non couvertes).",
"validation.OUTSIDE_AVAILABILITY.rule": "Le créneau n'est pas entièrement couvert par les disponibilités déclarées.",
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h sur la journée ({hours})",
"validation.DAILY_HOURS_EXCEEDED.rule": "Plus de 8 h 00 d'affectations cumulées sur une même journée.",
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux ({rest})",
"validation.INSUFFICIENT_REST.rule": "Moins de 8 h 00 entre la fin d'un créneau et le début du suivant.",
"grille.filter.day": "JOUR", "grille.filter.day": "JOUR",
"grille.filter.position": "POSTE", "grille.filter.position": "POSTE",
"grille.legend.critical": "SOUS-EFFECTIF CRITIQUE", "grille.legend.critical": "SOUS-EFFECTIF CRITIQUE",
@@ -107,32 +104,91 @@
"grille.panel.warningsTitle": "⚠ AVERTISSEMENTS — AFFECTATION POSSIBLE", "grille.panel.warningsTitle": "⚠ AVERTISSEMENTS — AFFECTATION POSSIBLE",
"volunteers.filter.show": "AFFICHER", "volunteers.filter.show": "AFFICHER",
"volunteers.filter.all": "TOUS", "volunteers.filter.all": "TOUS",
"volunteers.filter.overloaded": "SURCHARGÉS",
"volunteers.filter.unassigned": "SANS AFFECTATION", "volunteers.filter.unassigned": "SANS AFFECTATION",
"volunteers.legend.assigned": "AFFECTATION", "volunteers.legend.assigned": "AFFECTATION",
"volunteers.legend.overlap": "DEUX POSTES EN MÊME TEMPS",
"volunteers.legend.now": "MAINTENANT",
"volunteers.bar.title": "{position} · {time}",
"volunteers.bar.overlapTitle": "{position} · {time} — en même temps qu'un autre poste",
"volunteers.search.placeholder": "Rechercher un bénévole ou un poste…",
"volunteers.search.label": "Rechercher un bénévole ou un poste",
"volunteers.search.empty": "Aucun bénévole ni poste ne correspond à « {query} ».",
"volunteer.unnamed": "Sans nom",
"volunteers.count": "{count} BÉNÉVOLES", "volunteers.count": "{count} BÉNÉVOLES",
"volunteers.totalHours": "TOTAL H", "volunteers.totalHours": "TOTAL H",
"volunteers.sort.label": "TRIER PAR",
"volunteers.sort.name": "NOM",
"volunteers.sort.hoursAsc": "HEURES ↑",
"volunteers.sort.hoursDesc": "HEURES ↓",
"volunteers.sort.toggle": "Trier par total d'heures (croissant / décroissant)",
"volunteers.name": "NOM", "volunteers.name": "NOM",
"volunteers.cumulative": "CUMUL", "volunteers.cumulative": "CUMUL",
"volunteers.badge.overloaded": "▲ SURCHARGE",
"volunteers.badge.unassigned": "○ OUBLIÉ", "volunteers.badge.unassigned": "○ OUBLIÉ",
"volunteers.empty": "Aucun bénévole ne correspond à ce filtre — bonne nouvelle. Repassez sur « TOUS » pour revoir l'ensemble de l'équipe.", "volunteers.empty": "Aucun bénévole ne correspond à ce filtre — bonne nouvelle. Repassez sur « TOUS » pour revoir l'ensemble de l'équipe.",
"volunteers.noData": "Aucun bénévole pour le moment. Ajoutez-les dans Configuration → « Bénévoles & import ».", "volunteers.noData": "Aucun bénévole pour le moment. Ajoutez-les dans Configuration → « Bénévoles & import ».",
"volunteers.footnote": "Surcharge = plus de 8 h sur une même journée ou moins de 8 h entre deux créneaux. Oublié = aucune affectation.", "volunteers.footnote": "Oublié = aucune affectation.",
"mobileVol.kicker": "MON PLANNING BÉNÉVOLE", "mobileVol.greeting": "Salut {name}",
"mobileVol.greeting": "SALUT {name}", "mobileVol.summary.one": "{count} créneau · {duration} sur {days} jour",
"mobileVol.slots": "{count} CRÉNEAUX", "mobileVol.summary.other": "{count} créneaux · {duration} sur {days} jour(s)",
"mobileVol.hours": "{count} HEURES", "mobileVol.summary.none": "Pas encore de créneau.",
"mobileVol.location": "📍 {location}", "mobileVol.featured.now": "● EN COURS",
"mobileVol.referent": "👤 Responsable : {name}", "mobileVol.featured.next": "PROCHAIN CRÉNEAU",
"mobileVol.phone": "📞 {phone}", "mobileVol.featured.overlap": "⚠ Tu es aussi sur {position} de {start} à {end}. Vérifie avec la coordination.",
"mobileVol.featured.referent": "TON RÉFÉRENT",
"mobileVol.featured.call": "APPELER",
"mobileVol.allDone": "Festival terminé. Merci {name}, tu as assuré !",
"mobileVol.day.one": "{count} créneau · {duration}",
"mobileVol.day.other": "{count} créneaux · {duration}",
"mobileVol.slot.done": "TERMINÉ",
"mobileVol.slot.now": "EN COURS",
"mobileVol.slot.referent": " · réf. {name}",
"mobileVol.slot.overlap": "⚠ Chevauche {position} de {start} à {end}. Vérifie avec la coordination où tu dois être.",
"slot.duration.hours": "{h} h",
"slot.duration.hoursMinutes": "{h} h {m}",
"slot.countdown.endsAt": "FIN À {time}",
"slot.countdown.minutes": "DANS {count} MIN",
"slot.countdown.in": "DANS {duration}",
"slot.countdown.days": "DANS {count} J",
"mobileVol.orgMessage.title": "MESSAGE DE L'ORGANISATION",
"mobileVol.orgMessage.signature": "Coordination · {when}",
"message.save": "Enregistrer",
"message.remove": "Retirer le message",
"message.saving": "Enregistrement…",
"message.saved": "✓ Enregistré",
"message.updatedAt": "Publié {when}",
"message.length": "{count} / {max} caractères",
"message.date.today": "aujourd'hui {time}",
"message.date.yesterday": "hier {time}",
"message.date.other": "{day} · {time}",
"mobileVol.noSlots": "Aucun créneau pour l'instant. La coordination t'affectera bientôt.", "mobileVol.noSlots": "Aucun créneau pour l'instant. La coordination t'affectera bientôt.",
"mobileVol.noAccount": "Ce compte n'est rattaché à aucun bénévole. Contactez la coordination.", "mobileVol.noAccount": "Ce compte n'est rattaché à aucun bénévole. Contactez la coordination.",
"mobileLead.kicker": "RÉFÉRENT DE POSTE", "mobileLead.kicker": "Ton équipe",
"mobileLead.positionsCount": "{count} POSTE(S) DONT VOUS ÊTES RÉFÉRENT", "mobileLead.kickerNamed": "{name} · ton équipe",
"mobileLead.status.done": "terminé",
"mobileLead.status.now": "en cours",
"mobileLead.status.next": "à venir",
"mobileLead.count.present": "PRÉSENTS",
"mobileLead.count.waiting": "ATTENDUS",
"mobileLead.count.absent": "ABSENTS",
"mobileLead.absentAlert.one": "⚠ 1 absent sur le créneau — il manque du monde",
"mobileLead.absentAlert.other": "⚠ {count} absents sur le créneau — il manque du monde",
"mobileLead.roll": "POINTAGE · {count} BÉNÉVOLE(S)",
"mobileLead.when.now": "Créneau en cours",
"mobileLead.when.next": "Début {time}",
"mobileLead.when.done": "Créneau terminé",
"mobileLead.call": "APPEL",
"mobileLead.callLabel": "Appeler {name}",
"mobileLead.message.title": "MESSAGE À TON ÉQUIPE",
"mobileLead.message.audience": "{count} bénévole(s) le verront",
"mobileLead.message.help": "Consignes du poste (tenue, matériel, point de rendez-vous…), affichées sur l'écran de chaque bénévole affecté au poste.",
"mobileLead.message.placeholder": "Ex. Gilet jaune fourni à la base bénévoles. Rendez-vous 10 min avant le début.",
"mobileLead.view.team": "Mon équipe",
"mobileLead.view.planning": "Mon planning · {count}",
"mobileLead.you": "TOI",
"mobileLead.markedAt": "pointé à {time}",
"mobileLead.attendanceError": "Le pointage n'a pas pu être enregistré (réseau ?). Réessaie.",
"mobileLead.noPosition": "Vous n'êtes actuellement référent d'aucun poste.", "mobileLead.noPosition": "Vous n'êtes actuellement référent d'aucun poste.",
"mobileLead.noShift": "Aucun créneau sur ce poste.", "mobileLead.noShift": "Aucun créneau sur ce poste.",
"mobileLead.present": "{present}/{total} POINTÉS PRÉSENTS",
"mobileLead.badgePresent": "✓ PRÉSENT", "mobileLead.badgePresent": "✓ PRÉSENT",
"mobileLead.badgeAbsent": "✕ ABSENT", "mobileLead.badgeAbsent": "✕ ABSENT",
"mobileLead.markPresent": "PRÉSENT", "mobileLead.markPresent": "PRÉSENT",
@@ -142,6 +198,31 @@
"config.reserved": "RÉSERVÉ À LA COORDINATION", "config.reserved": "RÉSERVÉ À LA COORDINATION",
"config.tab.grid": "POSTES & CRÉNEAUX", "config.tab.grid": "POSTES & CRÉNEAUX",
"config.tab.volunteers": "BÉNÉVOLES & IMPORT", "config.tab.volunteers": "BÉNÉVOLES & IMPORT",
"config.mail.test.title": "Tester l'envoi d'emails",
"config.mail.test.help": "Envoie tout de suite à l'adresse de votre choix le récapitulatif ci-contre tel que les bénévoles le recevront : sujet (précédé de « [Test] ») et texte en cours de saisie, variables remplies avec le planning d'un bénévole. Le lien de connexion y mène à la page de connexion, sans ouvrir le compte de ce bénévole.",
"config.mail.test.example": "Rempli avec le planning de",
"config.mail.test.exampleAuto": "Automatique (premier bénévole avec un créneau)",
"config.mail.test.withLink": "Avec le lien de connexion (comme « Envoyer avec lien de connexion »)",
"config.mail.test.filledWith": "Contenu rempli avec le planning de {name}.",
"config.mail.test.filledWithExample": "Aucun bénévole : contenu rempli avec un exemple fictif.",
"config.mail.test.email": "Adresse de test",
"config.mail.test.placeholder": "moi@exemple.fr",
"config.mail.test.send": "Envoyer un email de test",
"config.mail.test.sending": "Envoi en cours…",
"config.mail.test.result.sent": "✓ Email envoyé. Vérifiez la boîte de réception (et les indésirables).",
"config.mail.test.result.disabled": "L'envoi d'emails est désactivé sur ce serveur (EMAIL_ENABLED=false) : rien n'a été envoyé.",
"config.mail.test.result.connection_failed": "✕ Connexion au serveur d'envoi impossible (adresse, port ou identifiants SMTP).",
"config.mail.test.result.rejected": "✕ Le serveur d'envoi a refusé l'email (souvent : expéditeur non autorisé).",
"config.mail.test.serverSaid": "Réponse du serveur : {detail}",
"config.mail.test.config": "Serveur {host} · expéditeur {from}",
"notifications.invalid_email": "Adresse email invalide.",
"config.tab.message": "MESSAGE AUX BÉNÉVOLES",
"config.message.orgTitle": "Message de l'organisation",
"config.message.orgHelp": "Le même pour tous les bénévoles, affiché en bas de leur écran planning. Laissez vide pour ne rien afficher.",
"config.message.orgPlaceholder": "Ex. Badges et t-shirts à récupérer à l'accueil bénévoles dès 17:00.",
"config.message.positionsTitle": "Messages des postes",
"config.message.positionsHelp": "Saisis par chaque référent depuis son écran et montrés aux bénévoles du poste. Vous pouvez aussi les modifier ici.",
"config.message.none": "Pas de message",
"config.tab.mail": "MESSAGES", "config.tab.mail": "MESSAGES",
"config.tab.rules": "RÈGLES & ÉDITION", "config.tab.rules": "RÈGLES & ÉDITION",
"config.grid.positionsHeader": "POSTES · {count} CRÉNEAUX", "config.grid.positionsHeader": "POSTES · {count} CRÉNEAUX",
@@ -160,6 +241,7 @@
"config.grid.shiftStart": "Début du créneau", "config.grid.shiftStart": "Début du créneau",
"config.grid.shiftEnd": "Fin du créneau", "config.grid.shiftEnd": "Fin du créneau",
"config.grid.addShift": "+ AJOUTER CE CRÉNEAU", "config.grid.addShift": "+ AJOUTER CE CRÉNEAU",
"config.grid.nightHint": "Une heure de début avant {boundary} compte pour la nuit qui suit (ex. 00:00 → après minuit, à la suite de la soirée de ce jour).",
"config.grid.note": "Un poste ne crée aucun créneau à sa création : ajoutez-les à la main (heure de début et de fin) sur chaque jour, ou utilisez « Générer les créneaux » pour repartir des créneaux types de l'édition. Effectifs, créneaux, nom et catégorie du poste sont enregistrés immédiatement via l'API. Pour ajouter ou retirer des jours, modifiez les dates de l'édition dans l'onglet « Règles & édition ».", "config.grid.note": "Un poste ne crée aucun créneau à sa création : ajoutez-les à la main (heure de début et de fin) sur chaque jour, ou utilisez « Générer les créneaux » pour repartir des créneaux types de l'édition. Effectifs, créneaux, nom et catégorie du poste sont enregistrés immédiatement via l'API. Pour ajouter ou retirer des jours, modifiez les dates de l'édition dans l'onglet « Règles & édition ».",
"config.vol.importTitle": "IMPORTER DES BÉNÉVOLES", "config.vol.importTitle": "IMPORTER DES BÉNÉVOLES",
"config.vol.importHint": "Trois façons d'ajouter des bénévoles : saisie manuelle, import d'un fichier CSV / Excel, ou (à venir) le formulaire d'inscription en ligne.", "config.vol.importHint": "Trois façons d'ajouter des bénévoles : saisie manuelle, import d'un fichier CSV / Excel, ou (à venir) le formulaire d'inscription en ligne.",
@@ -173,7 +255,7 @@
"config.vol.phone": "TÉLÉPHONE", "config.vol.phone": "TÉLÉPHONE",
"config.vol.email": "EMAIL (LIEN MAGIQUE)", "config.vol.email": "EMAIL (LIEN MAGIQUE)",
"config.vol.delete": "SUPPRIMER", "config.vol.delete": "SUPPRIMER",
"config.vol.deleteConfirm": "Supprimer définitivement ce bénévole et toutes ses données (disponibilités, affectations) ?", "config.vol.deleteConfirm": "Supprimer définitivement ce bénévole et toutes ses données (affectations, vœux) ?",
"config.vol.note": "Modifications enregistrées immédiatement via l'API. La suppression est un effacement complet en cascade.", "config.vol.note": "Modifications enregistrées immédiatement via l'API. La suppression est un effacement complet en cascade.",
"config.mail.smtpBanner": "Envoie un récapitulatif de planning à chaque bénévole. L'email part si le serveur SMTP est configuré (EMAIL_ENABLED) ; sinon il est seulement journalisé.", "config.mail.smtpBanner": "Envoie un récapitulatif de planning à chaque bénévole. L'email part si le serveur SMTP est configuré (EMAIL_ENABLED) ; sinon il est seulement journalisé.",
"config.mail.templateTitle": "MODÈLE DE MESSAGE", "config.mail.templateTitle": "MODÈLE DE MESSAGE",
@@ -181,7 +263,7 @@
"config.mail.subject": "OBJET", "config.mail.subject": "OBJET",
"config.mail.body": "CORPS DU MESSAGE", "config.mail.body": "CORPS DU MESSAGE",
"config.mail.variables": "VARIABLES DISPONIBLES", "config.mail.variables": "VARIABLES DISPONIBLES",
"config.mail.variablesHint": "Cliquer une variable l'ajoute au corps. {lien_connexion} insère un lien de connexion personnel.", "config.mail.variablesHint": "Cliquer une variable l'ajoute au corps. {lien_connexion} insère le lien de connexion directe permanent du bénévole (un clic suffit, sans limite de durée ; crée son accès s'il n'en a pas encore) — révocable depuis sa fiche. {creneaux} insère le tableau de ses créneaux.",
"config.mail.send": "ENVOYER LE RÉCAPITULATIF", "config.mail.send": "ENVOYER LE RÉCAPITULATIF",
"config.mail.sendWithLink": "ENVOYER AVEC LIEN DE CONNEXION", "config.mail.sendWithLink": "ENVOYER AVEC LIEN DE CONNEXION",
"config.mail.sending": "ENVOI EN COURS…", "config.mail.sending": "ENVOI EN COURS…",
@@ -208,9 +290,7 @@
"config.rules.slotAdd": "AJOUTER", "config.rules.slotAdd": "AJOUTER",
"config.rules.slotsNote": "Ces créneaux sont ajoutés à besoin 0/0 sur chaque jour à la création d'un poste. Après un ajout ici (ou une extension des dates), utilisez « Générer les créneaux » sur le poste.", "config.rules.slotsNote": "Ces créneaux sont ajoutés à besoin 0/0 sur chaque jour à la création d'un poste. Après un ajout ici (ou une extension des dates), utilisez « Générer les créneaux » sur le poste.",
"config.rules.thresholdsTitle": "MOTEUR DE VALIDATION", "config.rules.thresholdsTitle": "MOTEUR DE VALIDATION",
"config.rules.maxDaily": "Maximum d'heures par journée", "config.rules.thresholdsNote": "Seule règle : le double emploi au même moment donne un avertissement, sans bloquer (un bénévole peut tenir deux postes à la fois) ; le solveur évite de le proposer. Aucune limite d'heures par journée ni de temps de repos.",
"config.rules.minRest": "Repos minimum entre deux créneaux",
"config.rules.thresholdsNote": "Seuils figés côté back-end (CLAUDE.md §3) : non paramétrables. Le double emploi au même créneau est toujours bloquant.",
"config.rules.rulesTitle": "RÈGLES APPLIQUÉES", "config.rules.rulesTitle": "RÈGLES APPLIQUÉES",
"config.rules.rolesTitle2": "RÔLES ET ACCÈS", "config.rules.rolesTitle2": "RÔLES ET ACCÈS",
"config.rules.roleCoordinator": "Coordination", "config.rules.roleCoordinator": "Coordination",
@@ -235,12 +315,27 @@
"config.vol.field.email": "Email (lien magique)", "config.vol.field.email": "Email (lien magique)",
"config.vol.field.phone": "Téléphone", "config.vol.field.phone": "Téléphone",
"config.vol.field.comment": "Commentaire", "config.vol.field.comment": "Commentaire",
"config.vol.field.badgeText": "Texte du badge",
"config.vol.field.badgeCount": "Nombre de badges",
"config.vol.field.badgeCount.help": "Nombre d'exemplaires de son badge imprimés sur les planches (1 à 100). 1 par défaut.",
"config.vol.field.badgeText.help": "Remplace, pour ce bénévole seulement, le texte libre commun saisi dans l'écran Badges. Vide = texte commun.",
"config.vol.photo.title": "Photo",
"config.vol.photo.hint": "Imprimée sur le badge (champ « Photo du bénévole ») et affichée sur les écrans bénévole et référent. Celle envoyée au formulaire d'inscription, ou ajoutée ici. PNG ou JPEG.",
"config.vol.photo.none": "Aucune photo",
"config.vol.photo.add": "Ajouter une photo",
"config.vol.photo.replace": "Remplacer la photo",
"config.vol.photo.remove": "Retirer",
"config.vol.photo.removeConfirm": "Retirer la photo de ce bénévole ?",
"config.vol.access.title": "Accès de connexion", "config.vol.access.title": "Accès de connexion",
"config.vol.access.hint": "Un email sur la fiche ne suffit pas à lui seul : sans un accès donné ici, le bénévole ne reçoit jamais de lien de connexion.", "config.vol.access.hint": "Un email sur la fiche ne suffit pas à lui seul : il faut un accès donné ici, ou l'envoi d'un récapitulatif de planning avec {lien_connexion} (qui en crée un).",
"config.vol.access.grant": "Donner un accès de connexion", "config.vol.access.grant": "Donner un accès de connexion",
"config.vol.access.active": "Accès actif : {email}", "config.vol.access.active": "Accès actif : {email}",
"config.vol.access.revoke": "Révoquer", "config.vol.access.revoke": "Révoquer",
"config.vol.access.revokeConfirm": "Révoquer l'accès de connexion de ce bénévole ?", "config.vol.access.revokeConfirm": "Révoquer l'accès de connexion de ce bénévole ?",
"config.vol.access.resetLink": "Renouveler le lien de connexion",
"config.vol.access.resetLinkHint": "Le lien de connexion des récapitulatifs de planning est permanent. Le renouveler rend inutilisables tous les liens déjà envoyés (email transféré, téléphone perdu…) ; le prochain récapitulatif en portera un nouveau.",
"config.vol.access.resetLinkConfirm": "Rendre inutilisables les liens de connexion déjà envoyés à ce bénévole ?",
"config.vol.access.resetLinkDone": "✓ Anciens liens révoqués",
"config.vol.access.needEmail": "Renseignez un email ci-dessus pour pouvoir donner un accès.", "config.vol.access.needEmail": "Renseignez un email ci-dessus pour pouvoir donner un accès.",
"config.vol.field.referentPositions": "Référent des postes", "config.vol.field.referentPositions": "Référent des postes",
"config.vol.referentHint": "Cocher un poste crée ou met à niveau son accès de connexion en référent : à sa prochaine connexion, il verra l'écran référent (son équipe, le pointage) au lieu de l'écran bénévole. Décocher retire seulement le poste, sans jamais rétrograder l'accès automatiquement.", "config.vol.referentHint": "Cocher un poste crée ou met à niveau son accès de connexion en référent : à sa prochaine connexion, il verra l'écran référent (son équipe, le pointage) au lieu de l'écran bénévole. Décocher retire seulement le poste, sans jamais rétrograder l'accès automatiquement.",
@@ -249,7 +344,6 @@
"config.vol.addTitle": "NOUVEAU BÉNÉVOLE", "config.vol.addTitle": "NOUVEAU BÉNÉVOLE",
"config.vol.addHint": "Renseigner un email ne donne pas d'accès à lui seul : une fois le bénévole créé, utilisez « Donner un accès de connexion » sur sa fiche.", "config.vol.addHint": "Renseigner un email ne donne pas d'accès à lui seul : une fois le bénévole créé, utilisez « Donner un accès de connexion » sur sa fiche.",
"config.vol.create": "CRÉER LE BÉNÉVOLE", "config.vol.create": "CRÉER LE BÉNÉVOLE",
"config.vol.requiredNames": "Le prénom et le nom sont obligatoires.",
"config.vol.wiz.title": "IMPORTER UN FICHIER", "config.vol.wiz.title": "IMPORTER UN FICHIER",
"config.vol.wiz.step1": "1 · FICHIER", "config.vol.wiz.step1": "1 · FICHIER",
"config.vol.wiz.step2": "2 · COLONNES", "config.vol.wiz.step2": "2 · COLONNES",
@@ -310,18 +404,25 @@
"config.vol.publicForm.title": "Formulaire public", "config.vol.publicForm.title": "Formulaire public",
"config.vol.publicForm.hint": "Réponses saisies par le bénévole lui-même via le formulaire public d'inscription.", "config.vol.publicForm.hint": "Réponses saisies par le bénévole lui-même via le formulaire public d'inscription.",
"config.vol.colWishes": "VŒUX", "config.vol.colWishes": "VŒUX",
"config.vol.colFee": "COTISATION",
"config.vol.fee.title": "Cotisation",
"config.vol.fee.online": "Paiement en ligne (SumUp)",
"config.vol.fee.mecenat": "Mécénat — reçu fiscal",
"config.vol.fee.onSite": "Paiera sur place",
"config.vol.fee.none": "aucune cotisation déclarée",
"config.vol.fee.paidAt": "Payée le",
"config.vol.fee.transaction": "Transaction SumUp",
"config.vol.fee.transactionHint": "Code de la transaction tel qu'affiché dans le tableau de bord SumUp : recherchez-le là-bas pour retrouver ce paiement. Côté SumUp, la description de la transaction porte aussi le nom du bénévole.",
"config.vol.fee.transactionUnknown": "non enregistrée (paiement antérieur à ce suivi)",
"config.vol.fee.transactionId": "Identifiant",
"config.vol.fee.onSiteShort": "Sur place",
"config.vol.fee.onlineShort": "En ligne",
"config.vol.fee.mecenatShort": "Mécénat",
"config.vol.fee.short.pending": "en attente",
"config.vol.fee.short.paid": "payée",
"config.vol.fee.short.failed": "échouée",
"config.vol.fee.short.expired": "expirée",
"config.vol.wishesCount": "{count} vœu(x)", "config.vol.wishesCount": "{count} vœu(x)",
"config.vol.addAvailabilityHint": "Les disponibilités se renseignent sur la fiche du bénévole, une fois celui-ci créé.",
"config.vol.avail.title": "Disponibilités",
"config.vol.avail.hint": "Plages horaires où le bénévole peut venir. Une heure de fin ≤ début signifie que la plage se termine le lendemain.",
"config.vol.avail.none": "Aucune disponibilité déclarée",
"config.vol.avail.add": "Ajouter une plage",
"config.vol.avail.save": "Enregistrer les disponibilités",
"grille.panel.coverage.full": "Disponible",
"grille.panel.coverage.partial": "Partiellement dispo",
"grille.panel.coverage.none": "Dispo non déclarée",
"availability.date_outside_edition": "Cette date est hors des dates de l'édition.",
"availability.empty_window": "Une plage doit avoir un début et une fin différents.",
"grille.solver.run": "SUGGESTIONS (SOLVEUR)", "grille.solver.run": "SUGGESTIONS (SOLVEUR)",
"grille.solver.running": "CALCUL EN COURS…", "grille.solver.running": "CALCUL EN COURS…",
"grille.solver.result": "{count} suggestion(s)", "grille.solver.result": "{count} suggestion(s)",
@@ -338,18 +439,29 @@
"badges.tab.customize": "PERSONNALISER", "badges.tab.customize": "PERSONNALISER",
"badges.tab.generate": "GÉNÉRER", "badges.tab.generate": "GÉNÉRER",
"badges.front": "Recto", "badges.front": "Recto",
"badges.front.help": "Fond personnalisable + champs positionnables (nom, postes avec horaires, catégorie, rôle). Le format du badge est celui de cette image (300 dpi supposés). Propre à cette édition.", "badges.front.help": "Fond personnalisable + champs positionnables (nom, postes avec horaires, catégorie, rôle, photo, texte libre). Le format du badge est celui de cette image (300 dpi supposés). Propre à cette édition.",
"badges.back": "Verso", "badges.back": "Verso",
"badges.back.help": "Une seule image, la même pour tous les badges de cette édition — ajustée au format défini par le recto.", "badges.back.help": "Une seule image, la même pour tous les badges de cette édition — ajustée au format défini par le recto.",
"badges.fields": "Champs du recto", "badges.fields": "Champs du recto",
"badges.field.fullName": "Prénom + nom", "badges.field.fullName": "Prénom + nom",
"badges.field.fullName.placeholder": "Prénom Nom", "badges.field.fullName.placeholder": "Prénom Nom",
"badges.field.positions": "Poste(s) affecté(s)", "badges.field.positions": "Créneaux affectés (tableau)",
"badges.field.positions.placeholder": "Bar central 19:00–22:00\nAccueil 09:00–13:00 (Référent : Sam Nuit)", "badges.field.positions.placeholder": "Bar central 19:00–22:00\nAccueil 09:00–13:00 (Référent : Sam Nuit)",
"badges.field.positions.sample1.day": "Vendredi 10",
"badges.field.positions.sample1.rows": "18:00–22:00|Bar central",
"badges.field.positions.sample2.day": "Samedi 11",
"badges.field.positions.sample2.rows": "09:00–13:00|Accueil\n23:00–01:00|Bar central",
"badges.field.category": "Catégorie", "badges.field.category": "Catégorie",
"badges.field.category.placeholder": "Bar", "badges.field.category.placeholder": "Bar",
"badges.field.role": "Rôle (référent / bénévole)", "badges.field.role": "Rôle (référent / bénévole)",
"badges.field.role.placeholder": "Référent", "badges.field.role.placeholder": "Référent",
"badges.field.photo": "Photo du bénévole",
"badges.field.photo.placeholder": "Photo",
"badges.field.photo.help": "La photo du bénévole (envoyée avec le formulaire d'inscription ou ajoutée sur sa fiche), recadrée au format du cadre. Rien n'est imprimé pour un bénévole sans photo.",
"badges.field.freeText": "Texte libre",
"badges.field.freeText.placeholder": "Accès backstage",
"badges.field.freeText.help": "Texte saisi ci-dessous, imprimé sur tous les badges. Pour un bénévole en particulier, le « Texte du badge » de sa fiche (Configuration › Bénévoles) le remplace.",
"badges.field.freeText.input": "Texte à imprimer (ex. STAFF 2026)",
"badges.field.size": "Taille", "badges.field.size": "Taille",
"badges.editor.hint": "Badge {width} × {height} mm. Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.", "badges.editor.hint": "Badge {width} × {height} mm. Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.",
"badges.size.width": "Largeur du badge (mm)", "badges.size.width": "Largeur du badge (mm)",
@@ -362,18 +474,210 @@
"badges.saved": "Enregistré ✓", "badges.saved": "Enregistré ✓",
"badges.generate.title": "Génération de la planche PDF", "badges.generate.title": "Génération de la planche PDF",
"badges.generate.all": "Tous les bénévoles ({count})", "badges.generate.all": "Tous les bénévoles ({count})",
"badges.generate.total": "{count} badge(s) à imprimer",
"badges.generate.pick": "Choisir ({count} sélectionné(s))", "badges.generate.pick": "Choisir ({count} sélectionné(s))",
"badges.generate.action": "Générer le PDF", "badges.generate.action": "Générer le PDF",
"badges.generating": "Génération…", "badges.generating": "Génération…",
"badges.face.front": "Recto",
"badges.face.back": "Verso",
"badges.face.front.short": "recto",
"badges.face.back.short": "verso",
"badges.dims": "{width} × {height} mm",
"badges.drop.front": "Déposez ici le visuel du recto (PNG ou JPEG)",
"badges.drop.back": "Déposez ici le visuel du verso (PNG ou JPEG)",
"badges.format.title": "Format du badge",
"badges.format.current": "Format actuel",
"badges.preset.cr80Portrait": "Carte CR80 portrait",
"badges.preset.cr80Landscape": "Carte CR80 paysage",
"badges.preset.a7": "A7 tour de cou",
"badges.fieldsOf": "Champs du {face}",
"badges.fieldsShown": "{count} affiché(s)",
"badges.layer.toggle": "Afficher / masquer",
"badges.layer.hidden": "masqué",
"badges.layer.meta": "{size} pt · {weight}",
"badges.layer.bold": "gras",
"badges.layer.regular": "normal",
"badges.layer.rect": "rectangle",
"badges.layer.oval": "ovale",
"badges.layer.issueCount": "▲ {count}",
"badges.layer.issueTruncated.one": "{count} badge avec texte coupé",
"badges.layer.issueTruncated.other": "{count} badges avec texte coupé",
"badges.layer.issueReduced.one": "{count} badge réduit automatiquement",
"badges.layer.issueReduced.other": "{count} badges réduits automatiquement",
"badges.keys.hint": "Clic sur un champ pour le régler. Flèches : déplacer de 0,5 mm. Maj + flèches : 2 mm. Échap : désélectionner. Ctrl/Cmd + Z : annuler.",
"badges.undo": "↶ Annuler",
"badges.undo.title": "Ctrl/Cmd + Z",
"badges.dirty": "Modifications non enregistrées",
"badges.clean": "✓ Enregistré",
"badges.savedAt": "✓ Enregistré à {time}",
"badges.sample.firstName": "Prénom",
"badges.sample.lastName": "Nom",
"badges.preview.with": "Aperçu avec",
"badges.preview.prev": "Bénévole précédent",
"badges.preview.next": "Bénévole suivant",
"badges.preview.counter": "Badge {index} / {total} · {role}",
"badges.preview.noVolunteer": "Aucun bénévole : exemple fictif",
"badges.preview.longest": "Tester le nom le plus long",
"badges.preview.guides": "Repères",
"badges.preview.guides.help": "Affiche la marge de découpe (1,5 mm) et le cadre de chaque champ",
"badges.preview.zoomIn": "Agrandir l'aperçu",
"badges.preview.zoomOut": "Réduire l'aperçu",
"badges.preview.zoom": "{percent} %",
"badges.preview.noPhoto": "sans photo",
"badges.preview.truncated": "▲ TEXTE COUPÉ",
"badges.preview.dims": "{width} × {height} mm",
"badges.preview.reducedTo": " · réduit à {size} pt",
"badges.pt": "{size} pt",
"badges.color.white": "Blanc",
"badges.color.black": "Noir",
"badges.color.yellow": "Jaune",
"badges.color.coral": "Corail",
"badges.color.none": "Aucun",
"badges.color.custom": "Autre",
"badges.align.left": "Gauche",
"badges.align.center": "Centre",
"badges.align.right": "Droite",
"badges.inspector.kicker": "{face} · champ sélectionné",
"badges.inspector.content": "Contenu",
"badges.inspector.contentHelp": "Identique sur tous les badges — sauf pour un bénévole dont la fiche porte un « Texte du badge », qui le remplace.",
"badges.inspector.fontSize": "Taille du texte",
"badges.inspector.reducedHere": "Ici réduit à {size} pt",
"badges.inspector.capHeight": "Hauteur ≈ {mm} mm",
"badges.inspector.smaller": "Diminuer la taille",
"badges.inspector.larger": "Augmenter la taille",
"badges.inspector.autoFit": "Réduire automatiquement si le texte est trop long",
"badges.inspector.align": "Alignement",
"badges.inspector.weight": "Graisse",
"badges.inspector.regular": "Normal",
"badges.inspector.bold": "Gras",
"badges.inspector.textColor": "Couleur du texte",
"badges.inspector.background": "Fond de la zone",
"badges.inspector.lowContrast": "⚠ Contraste faible : le texte risque d'être illisible à l'impression.",
"badges.inspector.photoShape": "Forme de la photo",
"badges.inspector.photoRect": "▢ Rectangle",
"badges.inspector.photoOval": "◯ Ovale",
"badges.inspector.photoStat": "{count} photo(s) reçue(s) sur {total}. Sans photo, le cadre reste vide.",
"badges.inspector.geometry": "Position et taille (mm)",
"badges.inspector.x": "X",
"badges.inspector.y": "Y",
"badges.inspector.w": "L",
"badges.inspector.h": "H",
"badges.inspector.center": "↔ Centrer",
"badges.inspector.fullWidth": "⇤⇥ Pleine largeur",
"badges.inspector.stack": "Superposition",
"badges.inspector.stack.position": "{rank} / {total} · 1 = dessus",
"badges.inspector.stack.front": "⤒ Premier plan",
"badges.inspector.stack.forward": "↑ Avancer",
"badges.inspector.stack.backward": "↓ Reculer",
"badges.inspector.stack.back": "⤓ Arrière-plan",
"badges.inspector.stack.help": "Quand des champs se chevauchent, celui du dessus recouvre les autres, sur l'aperçu comme à l'impression.",
"badges.inspector.hide": "Masquer ce champ",
"badges.inspector.empty.title": "Sélectionnez un champ sur le badge ou dans la liste de gauche.",
"badges.inspector.empty.hint": "Faites-le glisser pour le déplacer, tirez le carré jaune pour le redimensionner. Ses réglages apparaissent ici.",
"badges.inspector.empty.drop": "Pour changer le visuel de fond, déposez une image directement sur le recto ou le verso.",
"badges.check.title": "Contrôle des {count} badges",
"badges.check.subtitle": "Avant génération des planches",
"badges.check.critical": "⛔ À corriger",
"badges.check.warning": "⚠ À vérifier",
"badges.check.fixAutoFit": "Activer la réduction auto",
"badges.check.showExample": "Voir un exemple",
"badges.check.ok": "Les {count} badges s'affichent sans texte coupé ni champ hors zone de découpe.",
"badges.check.estimate": "Estimation faite dans le navigateur : le PDF généré reste la référence.",
"badges.issue.where": "{field} ({face})",
"badges.issue.truncated.one": "{where} : texte coupé sur {count} badge.",
"badges.issue.truncated.other": "{where} : texte coupé sur {count} badges.",
"badges.issue.reduced.one": "{where} : réduit automatiquement sur {count} badge, jusqu'à {size} pt.",
"badges.issue.reduced.other": "{where} : réduit automatiquement sur {count} badges, jusqu'à {size} pt.",
"badges.issue.margin": "{where} : touche la marge de découpe (1,5 mm), risque d'être rogné.",
"badges.issue.contrast": "{where} : texte et fond de zone trop proches, illisible.",
"badges.issue.noPhoto.one": "{count} bénévole n'a pas de photo : le cadre restera vide sur son badge.",
"badges.issue.noPhoto.other": "{count} bénévoles n'ont pas de photo : le cadre restera vide sur leur badge.",
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.", "badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
"badge.empty_image": "L'image est vide.", "badge.empty_image": "L'image est vide.",
"badge.image_too_large": "L'image est trop volumineuse.", "badge.image_too_large": "L'image est trop volumineuse.",
"badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois.", "badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois par face.",
"badge.invalid_color": "Couleur invalide (format attendu : #rrggbb).",
"badge.invalid_size": "La taille du badge doit être strictement positive.", "badge.invalid_size": "La taille du badge doit être strictement positive.",
"badge.no_fields": "Activez au moins un champ sur le recto avant de générer.", "badge.no_fields": "Activez au moins un champ sur le recto avant de générer.",
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.", "badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
"tab.forms": "Inscriptions", "tab.forms": "Inscriptions",
"tab.stats": "Statistiques",
"stats.kpi.registered": "Bénévoles inscrits · +{count} en 7 jours",
"stats.kpi.collected": "Cotisations encaissées en ligne",
"stats.kpi.coverage": "Places pourvues · {assigned} / {ideal}",
"stats.kpi.withAssignment": "Ont au moins une affectation · sur {total}",
"stats.attendance.title": "Présence",
"stats.attendance.help": "Présents et absents pointés par les référents sur leur écran, par poste. « En ce moment » : créneaux commencés et pas encore finis ; « Créneaux terminés » : tout ce qui est fini. Les créneaux à venir ne comptent pas. Mis à jour toutes les 30 secondes.",
"stats.attendance.live": "En ce moment",
"stats.attendance.past": "Créneaux terminés",
"stats.attendance.asOf": "mis à jour à {time}",
"stats.attendance.noLive": "Aucun créneau en cours.",
"stats.attendance.noPast": "Aucun créneau terminé pour l'instant.",
"stats.attendance.liveSummary": "{shifts} créneau(x) en cours · {expected} bénévole(s) attendu(s)",
"stats.attendance.shiftsCount": "{count} en cours",
"stats.attendance.expected": "Attendus",
"stats.attendance.present": "Présents",
"stats.attendance.absent": "Absents",
"stats.attendance.unmarked": "Non pointés",
"stats.attendance.rate": "Présence",
"stats.attendance.rateTip": "{present} présent(s) sur {marked} pointé(s)",
"stats.attendance.unmarkedHint": "{count} affectation(s) sur des créneaux terminés n'ont jamais été pointées : le taux de présence ne porte que sur les bénévoles pointés.",
"stats.empty": "Aucun bénévole inscrit pour l'instant.",
"stats.showTable": "Voir le tableau",
"stats.unit.hours": "h",
"stats.registrations.title": "Évolution des inscriptions",
"stats.registrations.help": "Nombre cumulé de bénévoles en base, par jour d'ajout (formulaire public, import ou ajout à la main).",
"stats.registrations.cumulative": "inscrits au total",
"stats.registrations.daily": "nouveaux ce jour",
"stats.answers.title": "Réponses",
"stats.answers.help": "Part des bénévoles ayant renseigné chaque information (formulaire public ou fiche).",
"stats.answers.email": "Email",
"stats.answers.phone": "Téléphone",
"stats.answers.available": "Case « disponible »",
"stats.answers.positionWishes": "Vœux de poste",
"stats.answers.artistWishes": "Vœux d'artiste",
"stats.answers.companionWishes": "Vœux de compagnon",
"stats.answers.companionNote": "Compagnon (texte libre)",
"stats.answers.photo": "Photo",
"stats.answers.comment": "Commentaire",
"stats.artists.title": "Artistes que les bénévoles veulent voir",
"stats.payments.title": "Cotisations",
"stats.payments.help": "En ligne : paiement SumUp confirmé. Sur place : « je paierai sur place » ou case cotisation cochée sans paiement en ligne.",
"stats.payments.online": "En ligne",
"stats.payments.onSite": "Sur place",
"stats.payments.none": "Aucune",
"stats.payments.byOption": "Paiements en ligne par type",
"stats.payments.noOnline": "Aucun paiement en ligne pour l'instant.",
"stats.payments.mecenatTag": "mécénat",
"stats.payments.total": "Total",
"stats.payments.mecenat": "Dont mécénat : {count} don(s), {amount} (reçus fiscaux).",
"stats.payments.unfinished": "{count} formulaire(s) rempli(s) sans paiement abouti : ces personnes ne sont pas comptées parmi les bénévoles (paiement refusé, abandonné ou expiré).",
"stats.needs.title": "Besoins et bénévoles",
"stats.needs.help": "Heures × personnes demandées par les créneaux, face aux heures déjà affectées — par journée de festival (une soirée qui passe minuit compte pour le jour où elle commence).",
"stats.needs.empty": "Aucun créneau défini pour l'instant.",
"stats.needs.byDay": "Par jour (heures × personnes)",
"stats.needs.byPosition": "Par poste (places = personnes × créneaux)",
"stats.needs.min": "Besoin minimal",
"stats.needs.ideal": "Besoin idéal",
"stats.needs.assigned": "Déjà affecté",
"stats.needs.filledWithExtra": "{filled} / {ideal} (+{extra} en sureffectif)",
"stats.needs.wishesHint": "Pourvues : places occupées, plafonnées à l'effectif idéal de chaque créneau (« +2 » = bénévoles en sureffectif, non comptés). Vœux / place : nombre de bénévoles qui ont demandé le poste, rapporté aux places idéales. En dessous de 1, le poste aura besoin de bénévoles qui ne l'ont pas choisi.",
"stats.col.date": "Date",
"stats.col.day": "Jour",
"stats.col.answer": "Information",
"stats.col.volunteers": "Bénévoles",
"stats.col.share": "Part",
"stats.col.artist": "Artiste",
"stats.col.paymentType": "Type de paiement",
"stats.col.amount": "Montant",
"stats.col.shifts": "Créneaux",
"stats.col.position": "Poste",
"stats.col.minSlots": "Places min.",
"stats.col.idealSlots": "Places idéales",
"stats.col.filled": "Pourvues",
"stats.col.wishes": "Vœux",
"stats.col.wishesPerSlot": "Vœux / place",
"form.field.email": "Email", "form.field.email": "Email",
"form.field.phone": "Téléphone", "form.field.phone": "Téléphone",
"form.field.positionWishes": "Vœux de poste", "form.field.positionWishes": "Vœux de poste",
@@ -383,7 +687,7 @@
"form.field.companionWish": "Collègue apprécié", "form.field.companionWish": "Collègue apprécié",
"form.field.companionWish.help": "Texte libre : la personne visée n'est pas forcément déjà inscrite. À résoudre ensuite depuis sa fiche.", "form.field.companionWish.help": "Texte libre : la personne visée n'est pas forcément déjà inscrite. À résoudre ensuite depuis sa fiche.",
"form.field.available": "Case « je suis disponible »", "form.field.available": "Case « je suis disponible »",
"form.field.available.help": "Purement informatif — n'a aucun lien avec les disponibilités détaillées de la fiche bénévole.", "form.field.available.help": "Purement informatif — simple case à cocher, sans effet sur les affectations.",
"form.field.willingToPayFee": "Case « je veux bien payer une cotisation »", "form.field.willingToPayFee": "Case « je veux bien payer une cotisation »",
"config.vol.feeStatus.pending": "Cotisation : paiement en attente", "config.vol.feeStatus.pending": "Cotisation : paiement en attente",
"config.vol.feeStatus.paid": "Cotisation payée ✓", "config.vol.feeStatus.paid": "Cotisation payée ✓",
@@ -473,6 +777,7 @@
"form.captcha_failed": "Vérification anti-robot invalide, réessaie.", "form.captcha_failed": "Vérification anti-robot invalide, réessaie.",
"form.invalid_payload": "Données du formulaire invalides.", "form.invalid_payload": "Données du formulaire invalides.",
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.", "form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
"volunteer.empty_photo": "Le fichier de la photo est vide.",
"form.photo_too_large": "La photo est trop volumineuse.", "form.photo_too_large": "La photo est trop volumineuse.",
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.", "form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
"form.logo_empty": "Le logo est vide.", "form.logo_empty": "Le logo est vide.",
+18
View File
@@ -0,0 +1,18 @@
import type { VolunteerRead } from '@/api/schema';
/** Comment un bénévole règle sa cotisation — déduit des champs existants,
* même règle que `app/exporting/xlsx.py:_payment_mode` :
* - `ONLINE` : un paiement SumUp a été lancé (type, montant et statut sur
* `fee_payment_*`) ;
* - `ON_SITE` : « je participe à la cotisation » sans paiement en ligne
* (« Je paierai sur place » au formulaire, ou case cochée à la main) ;
* - `NONE` : rien de déclaré. */
export type FeeMode = 'ONLINE' | 'ON_SITE' | 'NONE';
export function feeMode(
volunteer: Pick<VolunteerRead, 'fee_payment_status' | 'willing_to_pay_fee'>,
): FeeMode {
if (volunteer.fee_payment_status !== null) return 'ONLINE';
if (volunteer.willing_to_pay_fee) return 'ON_SITE';
return 'NONE';
}
+69 -200
View File
@@ -1,9 +1,7 @@
import type { import type {
ArtistRead, ArtistRead,
AssignmentRead, AssignmentRead,
AvailabilityRead,
DayPartRead, DayPartRead,
MatrixResponse,
PositionCategory, PositionCategory,
ShiftRead, ShiftRead,
Violation, Violation,
@@ -25,71 +23,6 @@ export function foldAccents(value: string): string {
.toLowerCase(); .toLowerCase();
} }
// --------------------------------------------------------------------------- //
// Disponibilités : couverture d'un créneau par les fenêtres déclarées //
// (portage de `backend/app/validation/engine.py`). //
// --------------------------------------------------------------------------- //
const _dayNum = (iso: string): number => {
const [y, m, d] = iso.split('-').map(Number);
return Math.floor(Date.UTC(y, m - 1, d) / 86_400_000);
};
/** Minutes absolues (depuis une origine arbitraire) d'un datetime naïf ISO. */
function absMinutes(iso: string): number {
const [date, time] = iso.split('T');
const [h, min] = time.split(':').map(Number);
return _dayNum(date) * 1440 + h * 60 + min;
}
function windowInterval(w: AvailabilityRead): [number, number] {
const [sh, sm] = w.start_time.split(':').map(Number);
const [eh, em] = w.end_time.split(':').map(Number);
const start = _dayNum(w.date) * 1440 + sh * 60 + sm;
let end = _dayNum(w.date) * 1440 + eh * 60 + em;
if (end <= start) end += 1440; // fenêtre à cheval sur minuit
return [start, end];
}
/** Minutes du créneau `[startIso, endIso)` hors de l'union des fenêtres. */
export function uncoveredMinutes(
windows: AvailabilityRead[],
startIso: string,
endIso: string,
): number {
const shiftStart = absMinutes(startIso);
const shiftEnd = absMinutes(endIso);
const intervals = windows
.map(windowInterval)
.filter(([a, b]) => b > a)
.sort((a, b) => a[0] - b[0]);
let cursor = shiftStart;
let gap = 0;
for (const [s, e] of intervals) {
if (e <= cursor) continue;
if (s > shiftEnd) break;
if (s > cursor) gap += Math.min(s, shiftEnd) - cursor;
cursor = Math.max(cursor, e);
if (cursor >= shiftEnd) return gap;
}
if (cursor < shiftEnd) gap += shiftEnd - cursor;
return gap;
}
export type Coverage = 'full' | 'partial' | 'none';
/** État de couverture d'un créneau par les fenêtres d'un bénévole. */
export function availabilityCoverage(
windows: AvailabilityRead[],
startIso: string,
endIso: string,
): Coverage {
if (windows.length === 0) return 'none';
const gap = uncoveredMinutes(windows, startIso, endIso);
if (gap === 0) return 'full';
const total = absMinutes(endIso) - absMinutes(startIso);
return gap >= total ? 'none' : 'partial';
}
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// Score de chance d'un candidat pour un créneau — portage simplifié du // // Score de chance d'un candidat pour un créneau — portage simplifié du //
// solveur (`backend/app/solver/engine.py`) : sert à trier la liste des // // solveur (`backend/app/solver/engine.py`) : sert à trier la liste des //
@@ -145,121 +78,78 @@ export function buildEffectiveCompanionIds(
} }
const CHANCE_WISH_POSITION_BONUS = 30; const CHANCE_WISH_POSITION_BONUS = 30;
const CHANCE_AVAIL_PENALTY_PER_HALF_HOUR = 5;
const CHANCE_ARTIST_CONFLICT_PENALTY = 80; const CHANCE_ARTIST_CONFLICT_PENALTY = 80;
const CHANCE_FAIRNESS_PENALTY_PER_HOUR = 2; const CHANCE_FAIRNESS_PENALTY_PER_HOUR = 2;
const CHANCE_COMPANION_BONUS_PER_MATCH = 25; const CHANCE_COMPANION_BONUS_PER_MATCH = 25;
// Déjà affecté à un autre poste au même moment : possible, mais en fin de liste.
const CHANCE_ALREADY_BUSY_PENALTY = 300;
/** /**
* Score de chance d'un bénévole pour un créneau : vœu de poste, disponibilité * Score de chance d'un bénévole pour un créneau : vœu de poste, conflit avec
* (y compris le conflit avec un artiste qu'il veut voir), compagnons * un artiste qu'il veut voir, compagnons souhaités déjà affectés au créneau,
* souhaités déjà affectés au créneau, charge déjà assignée. Plus haut = * déjà affecté ailleurs au même moment, charge déjà assignée. Plus haut =
* meilleur candidat. Sert uniquement à trier la liste affichée ; la * meilleur candidat. Sert uniquement à trier la liste affichée ; la
* validation (blocage / avertissements) reste seule juge. * validation (blocage / avertissements) reste seule juge.
*/ */
export function candidateChanceScore(params: { export function candidateChanceScore(params: {
wishesPosition: boolean; wishesPosition: boolean;
uncoveredMinutes: number;
conflictsWishedArtist: boolean; conflictsWishedArtist: boolean;
companionMatches: number; companionMatches: number;
busyElsewhere: boolean;
assignedHours: number; assignedHours: number;
}): number { }): number {
let score = 0; let score = 0;
if (params.busyElsewhere) score -= CHANCE_ALREADY_BUSY_PENALTY;
if (params.wishesPosition) score += CHANCE_WISH_POSITION_BONUS; if (params.wishesPosition) score += CHANCE_WISH_POSITION_BONUS;
score -= Math.floor(params.uncoveredMinutes / 30) * CHANCE_AVAIL_PENALTY_PER_HALF_HOUR;
if (params.conflictsWishedArtist) score -= CHANCE_ARTIST_CONFLICT_PENALTY; if (params.conflictsWishedArtist) score -= CHANCE_ARTIST_CONFLICT_PENALTY;
score += CHANCE_COMPANION_BONUS_PER_MATCH * params.companionMatches; score += CHANCE_COMPANION_BONUS_PER_MATCH * params.companionMatches;
score -= CHANCE_FAIRNESS_PENALTY_PER_HOUR * params.assignedHours; score -= CHANCE_FAIRNESS_PENALTY_PER_HOUR * params.assignedHours;
return score; return score;
} }
/** Colonne de la grille : un créneau distinct (date, heure de début, heure de /** Frontière du jour (« journée » au sens § 3 du CLAUDE.md) : début du plus
* fin) — pas seulement une heure de début : deux créneaux qui démarrent à la * long creux laissé par les créneaux types sur le cadran de 24 h (l'heure où
* même heure mais durent différemment ont chacun leur colonne. */ * le dernier de la journée se termine) — même règle que
export interface LatticeColumn { * `validation.engine.day_boundary` côté backend. Indépendant de `order`, qui
key: string; // `YYYY-MM-DD|HH:MM|HH:MM` * ne suit pas les heures quand on les modifie. Sans creux, début du premier
date: string; * par `order` ; sans créneau type, minuit. */
time: string;
endTime: string;
label: string; // « 17:00 → 22:00 »
}
export interface LatticeDay {
date: string;
columns: LatticeColumn[];
}
/** Frontière du jour (« journée » au sens § 3 du CLAUDE.md) : début du
* créneau type de plus petit `order` — même règle que
* `validation.engine._day_boundary` côté backend. Sans créneau type, minuit. */
export function dayBoundaryFromDayParts( export function dayBoundaryFromDayParts(
dayParts: Array<Pick<DayPartRead, 'start_time' | 'order'>>, dayParts: Array<Pick<DayPartRead, 'start_time' | 'end_time' | 'order'>>,
): string { ): string {
if (dayParts.length === 0) return '00:00'; if (dayParts.length === 0) return '00:00';
const DAY = 24 * 60;
const covered = new Array<boolean>(DAY).fill(false);
for (const part of dayParts) {
const start = minutesOfDay(part.start_time.slice(0, 5));
const end = minutesOfDay(part.end_time.slice(0, 5));
const length = (end - start + DAY) % DAY || DAY;
for (let offset = 0; offset < length; offset++) covered[(start + offset) % DAY] = true;
}
const origin = covered.indexOf(true);
if (covered.every(Boolean)) {
const first = dayParts.reduce((min, part) => (part.order < min.order ? part : min)); const first = dayParts.reduce((min, part) => (part.order < min.order ? part : min));
return first.start_time.slice(0, 5); return first.start_time.slice(0, 5);
} }
// Parcours circulaire depuis une minute couverte : un creux qui enjambe
/** Construit le treillis de colonnes à partir de créneaux (grille alignée). // minuit est vu d'un seul tenant.
* `dayBoundary` (`HH:MM`, défaut minuit) regroupe et trie les colonnes par let bestStart = 0;
* « journée de festival » plutôt que par date calendaire brute : un créneau let bestLength = 0;
* commençant avant cette heure (ex. 02:00, suite d'une soirée qui a passé let runStart = 0;
* minuit) est rattaché au jour précédent et trié après 23:00, pas avant let runLength = 0;
* 19:00 — voir CLAUDE.md § 3 (« journée » pour le cumul des 8 h). */ for (let offset = 1; offset <= DAY; offset++) {
export function buildLattice( const minute = (origin + offset) % DAY;
starts: Array<{ starts_at: string; ends_at: string }>, if (covered[minute]) {
dayBoundary = '00:00', runLength = 0;
): { continue;
days: LatticeDay[]; }
columns: LatticeColumn[]; if (runLength === 0) runStart = minute;
} { runLength += 1;
const boundaryMinutes = minutesOfDay(dayBoundary); if (runLength > bestLength) {
const seen = new Map<string, LatticeColumn & { festivalDay: string; sortMinutes: number }>(); bestStart = runStart;
for (const s of starts) { bestLength = runLength;
const date = datePart(s.starts_at);
const time = timePart(s.starts_at);
const endTime = timePart(s.ends_at);
const key = `${date}|${time}|${endTime}`;
if (!seen.has(key)) {
const minutes = minutesOfDay(time);
const festivalDay = minutes < boundaryMinutes ? addDays(date, -1) : date;
const sortMinutes = (minutes - boundaryMinutes + 24 * 60) % (24 * 60);
seen.set(key, { key, date, time, endTime, label: `${time}–${endTime}`, festivalDay, sortMinutes });
} }
} }
const ordered = [...seen.values()].sort( return clockLabel(bestStart);
(a, b) => a.festivalDay.localeCompare(b.festivalDay) || a.sortMinutes - b.sortMinutes,
);
const columns: LatticeColumn[] = ordered.map(({ key, date, time, endTime, label }) => ({
key,
date,
time,
endTime,
label,
}));
const days: LatticeDay[] = [];
for (const column of ordered) {
let day = days.find((d) => d.date === column.festivalDay);
if (!day) {
day = { date: column.festivalDay, columns: [] };
days.push(day);
}
day.columns.push({
key: column.key,
date: column.date,
time: column.time,
endTime: column.endTime,
label: column.label,
});
}
return { days, columns };
}
export function latticeFromMatrix(matrix: MatrixResponse, dayBoundary?: string) {
return buildLattice(
matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at, ends_at: s.ends_at }))),
dayBoundary,
);
} }
/** /**
@@ -278,9 +168,8 @@ export function columnWidthPx(slotsInDay: number): number {
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// Grille des besoins : une colonne = 30 min (§ 4 CLAUDE.md). Les créneaux, // // Grille des besoins : une colonne = 30 min (§ 4 CLAUDE.md). Les créneaux, //
// eux, gardent des horaires libres (voir `ShiftCreate`) — ils sont replacés // // eux, gardent des horaires libres (voir `ShiftCreate`) — ils sont replacés //
// dans cette grille fixe en fusionnant les cases qu'ils couvrent. // // dans cette grille fixe en fusionnant les cases qu'ils couvrent. Même axe //
// (Distinct de `buildLattice` ci-dessus, gardé pour la « Vue bénévoles », // // pour la « Vue par bénévole » (`VolunteerViewPage.tsx`). //
// une colonne par créneau distinct plutôt qu'un axe temporel continu.) //
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
const TIME_GRID_SLOT_MINUTES = 30; const TIME_GRID_SLOT_MINUTES = 30;
@@ -312,6 +201,30 @@ function festivalDayOf(iso: string, boundaryMinutes: number): string {
return clock < boundaryMinutes ? addDays(date, -1) : date; return clock < boundaryMinutes ? addDays(date, -1) : date;
} }
/** Jour de festival d'un datetime naïf (`dayBoundary` = `HH:MM`, voir
* `dayBoundaryFromDayParts`) : avant cette heure, c'est encore la nuit de la
* veille. Même règle que `app/validation/engine.py:festival_day`. */
export function festivalDay(iso: string, dayBoundary = '00:00'): string {
return festivalDayOf(iso, minutesOfDay(dayBoundary));
}
/**
* Datetimes d'un créneau saisi `start`–`end` (`HH:MM`) sur la journée de
* festival `day` : une heure de début avant `dayBoundary` désigne la nuit
* qui suit (00:00 le samedi ⇒ dimanche 00:00, après 23:00 le samedi), pas
* le petit matin du même jour calendaire ; une fin `<=` début passe minuit.
*/
export function shiftDatesForFestivalDay(
day: string,
start: string,
end: string,
dayBoundary = '00:00',
): { starts_at: string; ends_at: string } {
const startDay = minutesOfDay(start) < minutesOfDay(dayBoundary) ? addDays(day, 1) : day;
const endDay = end <= start ? addDays(startDay, 1) : startDay;
return { starts_at: `${startDay}T${start}:00`, ends_at: `${endDay}T${end}:00` };
}
/** /**
* Construit, pour chaque jour de festival couvert par `shifts`, l'axe des * Construit, pour chaque jour de festival couvert par `shifts`, l'axe des
* tranches de 30 min à afficher — bornée à la plage réellement utilisée ce * tranches de 30 min à afficher — bornée à la plage réellement utilisée ce
@@ -402,32 +315,6 @@ export function hoursByVolunteer(
return totals; return totals;
} }
/** Surcharge : > 8 h sur une journée civile ou < 8 h de repos entre créneaux. */
export function isOverloaded(
volunteerId: string,
assignments: AssignmentRead[],
shiftsById: Map<string, Pick<ShiftRead, 'starts_at' | 'ends_at'>>,
): boolean {
const shifts = assignments
.filter((a) => a.volunteer_id === volunteerId)
.map((a) => shiftsById.get(a.shift_id))
.filter((s): s is ShiftRead => Boolean(s))
.sort((a, b) => a.starts_at.localeCompare(b.starts_at));
const perDay = new Map<string, number>();
for (const s of shifts) {
const day = datePart(s.starts_at);
perDay.set(day, (perDay.get(day) ?? 0) + hoursBetween(s.starts_at, s.ends_at));
}
for (const hours of perDay.values()) if (hours > 8) return true;
for (let i = 1; i < shifts.length; i += 1) {
const gap = naiveMinutes(shifts[i].starts_at) - naiveMinutes(shifts[i - 1].ends_at);
if (gap > 0 && gap < 8 * 60) return true;
}
return false;
}
/** /**
* Rend une violation en texte lisible. Les `params` suivent exactement le * Rend une violation en texte lisible. Les `params` suivent exactement le
* contrat du moteur back-end (`backend/app/validation/engine.py`). * contrat du moteur back-end (`backend/app/validation/engine.py`).
@@ -444,25 +331,7 @@ export function violationText(
? t('validation.SHIFT_OVERLAP.at', { position: where }) ? t('validation.SHIFT_OVERLAP.at', { position: where })
: t('validation.SHIFT_OVERLAP'); : t('validation.SHIFT_OVERLAP');
} }
case 'OUTSIDE_AVAILABILITY':
return t('validation.OUTSIDE_AVAILABILITY', {
gap: formatMinutes(Number(p.uncovered_minutes)),
});
case 'DAILY_HOURS_EXCEEDED':
return t('validation.DAILY_HOURS_EXCEEDED', {
hours: formatMinutes(Number(p.total_minutes)),
});
case 'INSUFFICIENT_REST':
return t('validation.INSUFFICIENT_REST', {
rest: formatMinutes(Number(p.gap_minutes)),
});
default: default:
return violation.code; return violation.code;
} }
} }
function formatMinutes(total: number): string {
const h = Math.floor(total / 60);
const m = total % 60;
return m === 0 ? `${h} h` : `${h} h ${String(m).padStart(2, '0')}`;
}
+115
View File
@@ -0,0 +1,115 @@
// Aides des écrans bénévole et référent (`VolunteerHomeView`,
// `MobileLeadPage`) : statut d'un créneau par rapport à maintenant, durées,
// compte à rebours et frise horaire d'une journée. Tout en minutes
// « murales » (`naiveMinutes` / `nowNaiveMinutes`), jamais en `Date` locale.
import { t } from '@/i18n';
import { longDayLabel, naiveMinutes } from '@/lib/time';
export type SlotStatus = 'done' | 'now' | 'next';
export function slotStatus(start: number, end: number, now: number): SlotStatus {
if (now >= end) return 'done';
return now >= start ? 'now' : 'next';
}
/** `HH:MM` d'un instant en minutes naïves (passe minuit sans broncher). */
export function clockLabel(minutes: number): string {
const m = ((minutes % 1440) + 1440) % 1440;
return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
}
/** « 9 h », « 2 h 30 ». */
export function durationLabel(minutes: number): string {
const total = Math.round(minutes);
const h = Math.floor(total / 60);
const m = total % 60;
return m ? t('slot.duration.hoursMinutes', { h, m: String(m).padStart(2, '0') }) : t('slot.duration.hours', { h });
}
/** Compte à rebours du créneau mis en avant : « FIN À 03:00 » s'il est en
* cours, sinon « DANS 20 MIN », « DANS 2 h 30 » ou « DANS 3 J ». */
export function countdownLabel(start: number, end: number, now: number): string {
if (now >= start) return t('slot.countdown.endsAt', { time: clockLabel(end) });
const diff = start - now;
if (diff < 60) return t('slot.countdown.minutes', { count: Math.max(1, Math.round(diff)) });
if (diff < 24 * 60) return t('slot.countdown.in', { duration: durationLabel(diff) });
return t('slot.countdown.days', { count: Math.round(diff / (24 * 60)) });
}
export interface Interval {
start: number;
end: number;
}
/** Plage commune à deux créneaux qui se chevauchent (`null` sinon) —
* jointifs (fin = début) : pas de chevauchement, comme `SHIFT_OVERLAP`. */
export function overlapOf(a: Interval, b: Interval): Interval | null {
const start = Math.max(a.start, b.start);
const end = Math.min(a.end, b.end);
return start < end ? { start, end } : null;
}
export interface RibbonBar {
/** Position et largeur en % de la frise. */
left: number;
width: number;
/** Ligne de la frise : deux créneaux qui se chevauchent ne se masquent
* pas, le second descend d'une ligne. */
lane: number;
}
export interface Ribbon {
bars: RibbonBar[];
lanes: number;
/** Repères horaires, en % de la frise. */
ticks: Array<{ left: number; label: string }>;
/** Nombre d'heures couvertes (une graduation par heure). */
hours: number;
/** Position de maintenant en %, `null` hors de la frise. */
now: number | null;
}
const MIN_RIBBON_HOURS = 4;
const MAX_TICKS = 4;
/** Frise d'une journée : de l'heure pleine qui précède le premier créneau à
* celle qui suit le dernier (4 h au moins), `slots` triés par début. */
export function buildRibbon(slots: Interval[], now: number): Ribbon {
const first = Math.floor(Math.min(...slots.map((s) => s.start)) / 60) * 60;
const last = Math.ceil(Math.max(...slots.map((s) => s.end)) / 60) * 60;
const hours = Math.max(MIN_RIBBON_HOURS, (last - first) / 60);
const span = hours * 60;
const pct = (minutes: number) => ((minutes - first) / span) * 100;
const laneEnds: number[] = [];
const bars = slots.map((s) => {
let lane = 0;
while (laneEnds[lane] !== undefined && laneEnds[lane] > s.start) lane++;
laneEnds[lane] = s.end;
return { left: pct(s.start), width: ((s.end - s.start) / span) * 100, lane };
});
const step = Math.ceil(hours / (MAX_TICKS - 1));
const ticks = [];
for (let h = 0; h <= hours; h += step) ticks.push({ left: (h / hours) * 100, label: clockLabel(first + h * 60) });
return {
bars,
lanes: Math.max(1, laneEnds.length),
ticks,
hours,
now: now >= first && now <= first + span ? pct(now) : null,
};
}
/** Date d'un message (organisation, poste) : « AUJOURD'HUI 18:42 »,
* « HIER 18:42 », sinon « Samedi 31 · 18:42 ». */
export function messageDateLabel(iso: string, now: number): string {
const at = naiveMinutes(iso);
const days = Math.floor(now / 1440) - Math.floor(at / 1440);
const time = clockLabel(at);
if (days === 0) return t('message.date.today', { time });
if (days === 1) return t('message.date.yesterday', { time });
return t('message.date.other', { day: longDayLabel(iso), time });
}
+17
View File
@@ -0,0 +1,17 @@
// Nom et initiales d'un bénévole à l'écran. Prénom et nom ne sont pas
// obligatoires à l'ajout manuel (étape 66) : un bénévole peut n'avoir ni
// l'un ni l'autre, il doit rester visible et sélectionnable partout.
import { t } from '@/i18n';
type Named = { first_name: string; last_name: string };
/** « Prénom Nom », ou « Sans nom » si les deux sont vides. */
export function volunteerName(v: Named): string {
return `${v.first_name} ${v.last_name}`.trim() || t('volunteer.unnamed');
}
/** Initiales (prénom puis nom), « ? » si les deux sont vides. */
export function volunteerInitials(v: Named): string {
return `${v.first_name.trim().charAt(0)}${v.last_name.trim().charAt(0)}`.toUpperCase() || '?';
}
+36 -503
View File
@@ -1,94 +1,22 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useMemo, useState } from 'react';
import { import { useGenerateBadges, useVolunteers } from '@/api/hooks';
useBadgeImageUrl,
useBadgeTemplate,
useGenerateBadges,
useSetBadgeFields,
useSetBadgeSize,
useUploadBadgeBackImage,
useUploadBadgeFrontImage,
useVolunteers,
} from '@/api/hooks';
import type { BadgeAlign, BadgeFieldIn, BadgeFieldType } from '@/api/schema';
import { ApiError } from '@/api'; import { ApiError } from '@/api';
import { EmptyNote, ErrorNote, Loading } from '@/components/state'; import { EmptyNote, ErrorNote, Loading } from '@/components/state';
import { Btn, Chip, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui'; import { Btn, Chip, Panel, PanelHeader } from '@/components/ui';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { cx } from '@/lib/cx'; import { volunteerName } from '@/lib/volunteer';
// Le format du badge est dérivé de l'image de fond du recto (voir import { BadgeCustomizeTab } from './badges/BadgeCustomizeTab';
// `backend/app/badges/pdf.py:image_size_mm`) — CR80 (85,6 × 54 mm) tant
// qu'aucune image n'est importée. `SCALE` fixe la taille d'édition à
// l'écran ; les coordonnées enregistrées restent en mm.
const DEFAULT_BADGE_W_MM = 85.6;
const DEFAULT_BADGE_H_MM = 54;
const SCALE = 4.6; // px par mm à l'écran
type FieldState = BadgeFieldIn & { enabled: boolean }; // L'onglet « Personnaliser » (éditeur recto/verso, aperçu avec les vraies
// données, contrôle avant génération) vit dans `./badges/`.
const FIELD_TYPES: BadgeFieldType[] = ['FULL_NAME', 'POSITIONS', 'CATEGORY', 'ROLE'];
const FIELD_LABEL_KEY: Record<BadgeFieldType, string> = {
FULL_NAME: 'badges.field.fullName',
POSITIONS: 'badges.field.positions',
CATEGORY: 'badges.field.category',
ROLE: 'badges.field.role',
};
const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
FULL_NAME: 'badges.field.fullName.placeholder',
POSITIONS: 'badges.field.positions.placeholder',
CATEGORY: 'badges.field.category.placeholder',
ROLE: 'badges.field.role.placeholder',
};
// Position/taille par défaut à l'activation d'un champ — empilés
// verticalement pour ne pas se superposer (repères pour un badge CR80 ;
// l'utilisateur ajuste ensuite à la souris pour son propre format). Postes
// affectés sur plusieurs lignes (un par créneau, avec horaire) : plus haut
// que les autres champs.
const FIELD_DEFAULTS: Record<BadgeFieldType, Omit<BadgeFieldIn, 'field_type'>> = {
FULL_NAME: {
x_mm: 5, y_mm: 4, width_mm: 70, height_mm: 10,
font_size: 13, align: 'LEFT', bold: true, color: '#000000',
},
POSITIONS: {
x_mm: 5, y_mm: 15, width_mm: 70, height_mm: 22,
font_size: 8, align: 'LEFT', bold: false, color: '#000000',
},
CATEGORY: {
x_mm: 5, y_mm: 39, width_mm: 70, height_mm: 7,
font_size: 8, align: 'LEFT', bold: false, color: '#000000',
},
ROLE: {
x_mm: 5, y_mm: 47, width_mm: 70, height_mm: 6,
font_size: 8, align: 'LEFT', bold: true, color: '#a1442b',
},
};
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function fieldsFromServer(saved: BadgeFieldIn[]): Record<BadgeFieldType, FieldState> {
const byType = new Map(saved.map((f) => [f.field_type, f]));
const out = {} as Record<BadgeFieldType, FieldState>;
for (const type of FIELD_TYPES) {
const existing = byType.get(type);
out[type] = existing
? { ...existing, enabled: true }
: { field_type: type, enabled: false, ...FIELD_DEFAULTS[type] };
}
return out;
}
export function BadgesPage() { export function BadgesPage() {
const [tab, setTab] = useState<'customize' | 'generate'>('customize'); const [tab, setTab] = useState<'customize' | 'generate'>('customize');
return ( const tabBar = (
<div className="flex min-h-0 flex-1 flex-col"> <>
<div className="flex flex-wrap items-center gap-2 border-b-2 border-ink bg-sand px-4 py-[9px]">
<span className="mr-1 font-mono text-[9px] opacity-60">{t('badges.admin')}</span> <span className="mr-1 font-mono text-[9px] opacity-60">{t('badges.admin')}</span>
<Chip active={tab === 'customize'} onClick={() => setTab('customize')}> <Chip active={tab === 'customize'} onClick={() => setTab('customize')}>
{t('badges.tab.customize')} {t('badges.tab.customize')}
@@ -96,431 +24,21 @@ export function BadgesPage() {
<Chip active={tab === 'generate'} onClick={() => setTab('generate')}> <Chip active={tab === 'generate'} onClick={() => setTab('generate')}>
{t('badges.tab.generate')} {t('badges.tab.generate')}
</Chip> </Chip>
</div> </>
{tab === 'customize' ? <BadgeCustomizeTab /> : <BadgeGenerateTab />}
</div>
); );
}
// --------------------------------------------------------------------------- //
// Personnalisation //
// --------------------------------------------------------------------------- //
function BadgeCustomizeTab() {
const template = useBadgeTemplate();
const frontUrl = useBadgeImageUrl(
'front', template.data?.has_front_image ?? false, template.dataUpdatedAt,
);
const backUrl = useBadgeImageUrl(
'back', template.data?.has_back_image ?? false, template.dataUpdatedAt,
);
const uploadFront = useUploadBadgeFrontImage();
const uploadBack = useUploadBadgeBackImage();
const setFields = useSetBadgeFields();
const setSize = useSetBadgeSize();
const [fields, setFieldsState] = useState<Record<BadgeFieldType, FieldState> | null>(null);
const [selected, setSelected] = useState<BadgeFieldType | null>(null);
useEffect(() => {
if (template.data) setFieldsState(fieldsFromServer(template.data.fields));
}, [template.data]);
if (template.isLoading || !fields) return <Loading />;
if (template.error) return <ErrorNote error={template.error} />;
const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM;
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
setFieldsState((prev) => (prev ? { ...prev, [type]: { ...prev[type], ...patch } } : prev));
};
const save = () => {
const payload: BadgeFieldIn[] = FIELD_TYPES.filter((type) => fields[type].enabled).map(
(type) => {
const f = fields[type];
return {
field_type: f.field_type,
x_mm: f.x_mm,
y_mm: f.y_mm,
width_mm: f.width_mm,
height_mm: f.height_mm,
font_size: f.font_size,
align: f.align,
bold: f.bold,
color: f.color,
};
},
);
setFields.mutate({ fields: payload });
};
return ( return (
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-4 lg:flex-row lg:items-start"> <div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-none flex-col gap-4"> {tab === 'customize' ? (
<Panel> <BadgeCustomizeTab tabBar={tabBar} />
<PanelHeader> ) : (
{t('badges.front')} <>
<HelpTip text={t('badges.front.help')} className="ml-[6px]" /> <div className="flex flex-wrap items-center gap-2 border-b-[3px] border-ink bg-sand px-3 py-2 sm:px-4">
</PanelHeader> {tabBar}
<div className="p-3">
<ImageUploadRow
url={frontUrl}
pending={uploadFront.isPending}
error={uploadFront.error}
onPick={(file) => uploadFront.mutate(file)}
/>
<BadgeSizeControl
badgeW={badgeW}
badgeH={badgeH}
pending={setSize.isPending}
onApply={(width_mm, height_mm) =>
setSize.mutate({ badge_width_mm: width_mm, badge_height_mm: height_mm })
}
/>
<div
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
style={{
width: badgeW * SCALE,
height: badgeH * SCALE,
backgroundImage: frontUrl ? `url(${frontUrl})` : undefined,
}}
onPointerDown={() => setSelected(null)}
>
{FIELD_TYPES.filter((type) => fields[type].enabled).map((type) => (
<FieldBox
key={type}
field={fields[type]}
badgeW={badgeW}
badgeH={badgeH}
selected={selected === type}
onSelect={() => setSelected(type)}
onChange={(patch) => updateField(type, patch)}
/>
))}
</div> </div>
<p className="mt-2 font-mono text-[9px] leading-relaxed opacity-60"> <BadgeGenerateTab />
{t('badges.editor.hint', { </>
width: Math.round(badgeW * 10) / 10,
height: Math.round(badgeH * 10) / 10,
})}
</p>
</div>
</Panel>
<Panel>
<PanelHeader>
{t('badges.back')}
<HelpTip text={t('badges.back.help')} className="ml-[6px]" />
</PanelHeader>
<div className="p-3">
<ImageUploadRow
url={backUrl}
pending={uploadBack.isPending}
error={uploadBack.error}
onPick={(file) => uploadBack.mutate(file)}
/>
<div
className="mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center"
style={{ width: badgeW * SCALE, height: badgeH * SCALE, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
/>
</div>
</Panel>
</div>
<Panel className="min-w-[320px] flex-1">
<PanelHeader>{t('badges.fields')}</PanelHeader>
<div className="flex flex-col divide-y-2 divide-ink/10">
{FIELD_TYPES.map((type) => (
<FieldEditorRow
key={type}
type={type}
field={fields[type]}
active={selected === type}
onFocus={() => setSelected(type)}
onChange={(patch) => updateField(type, patch)}
/>
))}
</div>
<div className="flex items-center gap-3 border-t-2 border-ink p-3">
<Btn onClick={save} disabled={setFields.isPending}>
{setFields.isPending ? t('badges.saving') : t('badges.save')}
</Btn>
{setFields.isSuccess ? (
<span className="font-mono text-[9.5px] font-bold text-green">{t('badges.saved')}</span>
) : null}
{setFields.isError ? (
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
{errorMessage(setFields.error)}
</span>
) : null}
</div>
</Panel>
</div>
);
}
function ImageUploadRow({
url,
pending,
error,
onPick,
}: {
url: string | null;
pending: boolean;
error: unknown;
onPick: (file: File) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<div className="flex items-center gap-[10px]">
<Btn variant="ghost" onClick={() => inputRef.current?.click()} disabled={pending}>
{pending ? t('badges.uploading') : url ? t('badges.image.replace') : t('badges.image.choose')}
</Btn>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onPick(file);
e.target.value = '';
}}
/>
{error ? (
<span className="font-mono text-[9.5px] font-bold text-orange-dark">{errorMessage(error)}</span>
) : null}
</div>
);
}
/** Largeur du badge en mm — la hauteur suit proportionnellement (ratio de
* l'image importée). Sert à agrandir un badge devenu minuscule parce que
* l'image de fond n'était pas à une résolution d'impression (300 dpi
* supposés à l'import, voir `useBadgeTemplate`). */
function BadgeSizeControl({
badgeW,
badgeH,
pending,
onApply,
}: {
badgeW: number;
badgeH: number;
pending: boolean;
onApply: (width_mm: number, height_mm: number) => void;
}) {
const rounded = Math.round(badgeW * 10) / 10;
const [width, setWidth] = useState(String(rounded));
useEffect(() => setWidth(String(rounded)), [rounded]);
const apply = () => {
const w = Number(width);
if (!Number.isFinite(w) || w <= 0) {
setWidth(String(rounded));
return;
}
const ratio = badgeH / badgeW;
onApply(w, Math.round(w * ratio * 10) / 10);
};
return (
<div className="mt-2 flex items-center gap-[8px]">
<FieldLabel>{t('badges.size.width')}</FieldLabel>
<input
type="number"
min={5}
step={1}
value={width}
disabled={pending}
onChange={(e) => setWidth(e.target.value)}
onBlur={apply}
onKeyDown={(e) => {
if (e.key === 'Enter') apply();
}}
className="w-[64px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]"
/>
<span className="font-mono text-[9.5px] opacity-60">
{t('badges.size.height', { height: Math.round(badgeH * 10) / 10 })}
</span>
</div>
);
}
function FieldBox({
field,
badgeW,
badgeH,
selected,
onSelect,
onChange,
}: {
field: FieldState;
badgeW: number;
badgeH: number;
selected: boolean;
onSelect: () => void;
onChange: (patch: Partial<FieldState>) => void;
}) {
const dragRef = useRef<{ startXMm: number; startYMm: number; pointerXPx: number; pointerYPx: number } | null>(
null,
);
const resizeRef = useRef<{ startWMm: number; startHMm: number; pointerXPx: number; pointerYPx: number } | null>(
null,
);
const startDrag = (e: React.PointerEvent) => {
e.stopPropagation();
onSelect();
(e.target as Element).setPointerCapture(e.pointerId);
dragRef.current = { startXMm: field.x_mm, startYMm: field.y_mm, pointerXPx: e.clientX, pointerYPx: e.clientY };
};
const onDragMove = (e: React.PointerEvent) => {
if (!dragRef.current) return;
const d = dragRef.current;
const dxMm = (e.clientX - d.pointerXPx) / SCALE;
const dyMm = (e.clientY - d.pointerYPx) / SCALE;
onChange({
x_mm: clamp(d.startXMm + dxMm, 0, badgeW - field.width_mm),
y_mm: clamp(d.startYMm + dyMm, 0, badgeH - field.height_mm),
});
};
const startResize = (e: React.PointerEvent) => {
e.stopPropagation();
onSelect();
(e.target as Element).setPointerCapture(e.pointerId);
resizeRef.current = { startWMm: field.width_mm, startHMm: field.height_mm, pointerXPx: e.clientX, pointerYPx: e.clientY };
};
const onResizeMove = (e: React.PointerEvent) => {
if (!resizeRef.current) return;
const d = resizeRef.current;
const dwMm = (e.clientX - d.pointerXPx) / SCALE;
const dhMm = (e.clientY - d.pointerYPx) / SCALE;
onChange({
width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm),
height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm),
});
};
return (
<div
onPointerDown={startDrag}
onPointerMove={onDragMove}
onPointerUp={() => (dragRef.current = null)}
className={cx(
'absolute flex cursor-move items-start overflow-hidden border-[1.5px] bg-white/55 px-1 py-[1px]',
selected ? 'border-blue-strong' : 'border-ink/40',
)} )}
style={{
left: field.x_mm * SCALE,
top: field.y_mm * SCALE,
width: field.width_mm * SCALE,
height: field.height_mm * SCALE,
}}
>
<span
className="pointer-events-none whitespace-pre-line font-sans leading-tight"
style={{
fontSize: field.font_size * (SCALE / 2.83), // pt -> px approx. à l'échelle du badge
fontWeight: field.bold ? 800 : 400,
color: field.color,
width: '100%',
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
}}
>
{t(FIELD_PLACEHOLDER_KEY[field.field_type])}
</span>
<div
onPointerDown={startResize}
onPointerMove={onResizeMove}
onPointerUp={() => (resizeRef.current = null)}
className="absolute bottom-0 right-0 h-[10px] w-[10px] cursor-nwse-resize border-l-[1.5px] border-t-[1.5px] border-ink bg-yellow"
/>
</div>
);
}
function FieldEditorRow({
type,
field,
active,
onFocus,
onChange,
}: {
type: BadgeFieldType;
field: FieldState;
active: boolean;
onFocus: () => void;
onChange: (patch: Partial<FieldState>) => void;
}) {
return (
<div className={cx('flex flex-col gap-2 p-3', active && 'bg-yellow-soft')} onClick={onFocus}>
<label className="flex items-center gap-[8px]">
<input
type="checkbox"
checked={field.enabled}
onChange={(e) => onChange({ enabled: e.target.checked })}
className="h-[16px] w-[16px] accent-ink"
/>
<span className="font-display text-[12px] font-extrabold uppercase tracking-wide">
{t(FIELD_LABEL_KEY[type])}
</span>
</label>
{field.enabled ? (
<div className="ml-[24px] flex flex-wrap items-center gap-[10px]">
<label className="flex items-center gap-[5px]">
<FieldLabel>{t('badges.field.size')}</FieldLabel>
<input
type="number"
min={6}
max={36}
value={field.font_size}
onChange={(e) => onChange({ font_size: Number(e.target.value) })}
className="w-[52px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]"
/>
</label>
<div className="flex items-center gap-[4px]">
{(['LEFT', 'CENTER', 'RIGHT'] as BadgeAlign[]).map((align) => (
<button
key={align}
type="button"
onClick={() => onChange({ align })}
className={cx(
'h-[24px] w-[24px] cursor-pointer border-2 border-ink font-mono text-[10px] font-bold',
field.align === align ? 'bg-ink text-cream' : 'bg-cream hover:bg-yellow',
)}
>
{align === 'LEFT' ? '⟵' : align === 'CENTER' ? '↔' : '⟶'}
</button>
))}
</div>
<button
type="button"
onClick={() => onChange({ bold: !field.bold })}
className={cx(
'h-[24px] w-[24px] cursor-pointer border-2 border-ink font-display text-[11px] font-black',
field.bold ? 'bg-ink text-cream' : 'bg-cream hover:bg-yellow',
)}
>
G
</button>
<input
type="color"
value={field.color}
onChange={(e) => onChange({ color: e.target.value })}
className="h-[24px] w-[30px] cursor-pointer border-2 border-ink p-0"
/>
</div>
) : null}
</div> </div>
); );
} }
@@ -570,7 +88,7 @@ function BadgeGenerateTab() {
const disabled = generate.isPending || (mode === 'pick' && picked.size === 0); const disabled = generate.isPending || (mode === 'pick' && picked.size === 0);
return ( return (
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-4"> <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-3 sm:p-4">
<Panel className="max-w-[560px]"> <Panel className="max-w-[560px]">
<PanelHeader>{t('badges.generate.title')}</PanelHeader> <PanelHeader>{t('badges.generate.title')}</PanelHeader>
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-3 p-3">
@@ -583,6 +101,18 @@ function BadgeGenerateTab() {
</Chip> </Chip>
</div> </div>
{/* Un bénévole peut demander plusieurs exemplaires (fiche,
« Nombre de badges ») : le total imprimé peut dépasser le
nombre de bénévoles. */}
<span className="font-mono text-[10px] opacity-70">
{t('badges.generate.total', {
count: (mode === 'pick' ? sorted.filter((v) => picked.has(v.id)) : sorted).reduce(
(sum, v) => sum + v.badge_count,
0,
),
})}
</span>
{mode === 'pick' ? ( {mode === 'pick' ? (
<div className="max-h-[320px] overflow-auto border-2 border-ink"> <div className="max-h-[320px] overflow-auto border-2 border-ink">
{sorted.map((v) => ( {sorted.map((v) => (
@@ -596,7 +126,10 @@ function BadgeGenerateTab() {
onChange={() => toggle(v.id)} onChange={() => toggle(v.id)}
className="h-[14px] w-[14px] accent-ink" className="h-[14px] w-[14px] accent-ink"
/> />
{v.first_name} {v.last_name} {volunteerName(v)}
{v.badge_count > 1 ? (
<span className="ml-auto opacity-60">× {v.badge_count}</span>
) : null}
</label> </label>
))} ))}
</div> </div>
+4 -1
View File
@@ -4,16 +4,18 @@ import { Chip } from '@/components/ui';
import { ConfigArtists } from '@/pages/config/ConfigArtists'; import { ConfigArtists } from '@/pages/config/ConfigArtists';
import { ConfigGrid } from '@/pages/config/ConfigGrid'; import { ConfigGrid } from '@/pages/config/ConfigGrid';
import { ConfigMail } from '@/pages/config/ConfigMail'; import { ConfigMail } from '@/pages/config/ConfigMail';
import { ConfigMessage } from '@/pages/config/ConfigMessage';
import { ConfigRules } from '@/pages/config/ConfigRules'; import { ConfigRules } from '@/pages/config/ConfigRules';
import { ConfigVolunteers } from '@/pages/config/ConfigVolunteers'; import { ConfigVolunteers } from '@/pages/config/ConfigVolunteers';
import { t } from '@/i18n'; import { t } from '@/i18n';
type ConfigTab = 'grid' | 'volunteers' | 'artists' | 'mail' | 'rules'; type ConfigTab = 'grid' | 'volunteers' | 'artists' | 'message' | 'mail' | 'rules';
const TABS: Array<{ id: ConfigTab; labelKey: string }> = [ const TABS: Array<{ id: ConfigTab; labelKey: string }> = [
{ id: 'grid', labelKey: 'config.tab.grid' }, { id: 'grid', labelKey: 'config.tab.grid' },
{ id: 'volunteers', labelKey: 'config.tab.volunteers' }, { id: 'volunteers', labelKey: 'config.tab.volunteers' },
{ id: 'artists', labelKey: 'config.tab.artists' }, { id: 'artists', labelKey: 'config.tab.artists' },
{ id: 'message', labelKey: 'config.tab.message' },
{ id: 'mail', labelKey: 'config.tab.mail' }, { id: 'mail', labelKey: 'config.tab.mail' },
{ id: 'rules', labelKey: 'config.tab.rules' }, { id: 'rules', labelKey: 'config.tab.rules' },
]; ];
@@ -38,6 +40,7 @@ export function ConfigPage() {
{tab === 'grid' ? <ConfigGrid /> : null} {tab === 'grid' ? <ConfigGrid /> : null}
{tab === 'volunteers' ? <ConfigVolunteers /> : null} {tab === 'volunteers' ? <ConfigVolunteers /> : null}
{tab === 'artists' ? <ConfigArtists /> : null} {tab === 'artists' ? <ConfigArtists /> : null}
{tab === 'message' ? <ConfigMessage /> : null}
{tab === 'mail' ? <ConfigMail /> : null} {tab === 'mail' ? <ConfigMail /> : null}
{tab === 'rules' ? <ConfigRules /> : null} {tab === 'rules' ? <ConfigRules /> : null}
</div> </div>
+9 -9
View File
@@ -117,9 +117,9 @@ export function FormBuilderPage() {
}; };
return ( return (
<div className="flex min-h-0 flex-1 flex-col overflow-auto p-4"> <div className="flex min-h-0 flex-1 flex-col overflow-auto p-3 sm:p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start"> <div className="flex flex-col gap-4 lg:flex-row lg:items-start">
<Panel className="max-w-[420px] flex-1"> <Panel className="flex-1 lg:max-w-[420px]">
<PanelHeader>{t('form.link.title')}</PanelHeader> <PanelHeader>{t('form.link.title')}</PanelHeader>
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-3 p-3">
<p className="font-mono text-[10.5px] leading-relaxed opacity-70"> <p className="font-mono text-[10.5px] leading-relaxed opacity-70">
@@ -172,7 +172,7 @@ export function FormBuilderPage() {
</div> </div>
</Panel> </Panel>
<Panel className="min-w-[320px] flex-1"> <Panel className="min-w-0 flex-1 lg:min-w-[320px]">
<PanelHeader>{t('form.fields.title')}</PanelHeader> <PanelHeader>{t('form.fields.title')}</PanelHeader>
<div className="flex flex-col divide-y-2 divide-ink/10"> <div className="flex flex-col divide-y-2 divide-ink/10">
{FIELD_TYPES.map((type) => ( {FIELD_TYPES.map((type) => (
@@ -398,14 +398,14 @@ function PaymentOptionsForm() {
return ( return (
<div className="mt-2 flex flex-col gap-[6px]"> <div className="mt-2 flex flex-col gap-[6px]">
{rows.map((o) => ( {rows.map((o) => (
<div key={o.id} className="flex items-center gap-1"> <div key={o.id} className="flex flex-wrap items-center gap-1 border-b border-sand-line pb-2 sm:flex-nowrap sm:border-b-0 sm:pb-0">
<input <input
defaultValue={o.label} defaultValue={o.label}
onBlur={(e) => { onBlur={(e) => {
const v = e.target.value.trim(); const v = e.target.value.trim();
if (v && v !== o.label) update.mutate({ id: o.id, body: { label: v } }); if (v && v !== o.label) update.mutate({ id: o.id, body: { label: v } });
}} }}
className="min-w-0 flex-1 border-2 border-ink bg-paper px-2 py-1 font-display text-[12px] font-bold" className="min-w-0 flex-1 basis-full border-2 border-ink bg-paper px-2 py-1 font-display text-[12px] font-bold sm:basis-0"
/> />
<input <input
type="number" type="number"
@@ -449,19 +449,19 @@ function PaymentOptionsForm() {
type="button" type="button"
onClick={() => remove.mutate(o.id)} onClick={() => remove.mutate(o.id)}
aria-label={t('action.remove')} aria-label={t('action.remove')}
className="h-[26px] w-[26px] flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange" className="ml-auto h-[26px] w-[26px] flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange sm:ml-0"
> >
✕ ✕
</button> </button>
</div> </div>
))} ))}
<div className="mt-1 flex items-center gap-1 border-t-2 border-ink pt-2"> <div className="mt-1 flex flex-wrap items-center gap-1 border-t-2 border-ink pt-2 sm:flex-nowrap">
<input <input
value={label} value={label}
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
placeholder={t('form.customize.paymentOptions.label')} placeholder={t('form.customize.paymentOptions.label')}
className="min-w-0 flex-1 border-2 border-ink bg-yellow px-2 py-1 font-display text-[12px] font-bold" className="min-w-0 flex-1 basis-full border-2 border-ink bg-yellow px-2 py-1 font-display text-[12px] font-bold sm:basis-0"
/> />
<input <input
type="number" type="number"
@@ -519,7 +519,7 @@ function PaymentOptionsForm() {
}, },
) )
} }
className="h-[26px] flex-none cursor-pointer border-2 border-ink bg-ink px-2 font-display text-[10px] font-extrabold uppercase text-cream hover:bg-yellow hover:text-ink disabled:opacity-50" className="ml-auto h-[26px] flex-none cursor-pointer border-2 border-ink bg-ink px-2 sm:ml-0 font-display text-[10px] font-extrabold uppercase text-cream hover:bg-yellow hover:text-ink disabled:opacity-50"
> >
{t('form.customize.paymentOptions.add')} {t('form.customize.paymentOptions.add')}
</button> </button>
+27 -7
View File
@@ -88,7 +88,9 @@ export function GrillePage() {
: matrix.data.positions.filter((p) => p.category === categoryFilter); : matrix.data.positions.filter((p) => p.category === categoryFilter);
const gridTemplateColumns = [ const gridTemplateColumns = [
'230px', // Colonne des postes rétrécie sur petit écran, pour laisser voir au
// moins quelques tranches horaires à côté.
'clamp(132px, 36vw, 230px)',
...shownDays.flatMap((day) => { ...shownDays.flatMap((day) => {
const width = columnWidthPx(day.slotLabels.length); const width = columnWidthPx(day.slotLabels.length);
return day.slotLabels.map(() => `${width}px`); return day.slotLabels.map(() => `${width}px`);
@@ -134,8 +136,8 @@ export function GrillePage() {
return ( return (
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-wrap items-center gap-[14px] border-b-2 border-ink bg-sand px-4 py-[9px]"> <div className="flex flex-wrap items-center gap-[10px] border-b-2 border-ink bg-sand px-3 py-[9px] sm:gap-[14px] sm:px-4">
<div className="flex items-center gap-[6px]"> <div className="flex flex-wrap items-center gap-[6px]">
<span className="font-mono text-[9px] opacity-60">{t('grille.filter.day')}</span> <span className="font-mono text-[9px] opacity-60">{t('grille.filter.day')}</span>
<Chip active={dayFilter === 'all'} onClick={() => setDayFilter('all')}> <Chip active={dayFilter === 'all'} onClick={() => setDayFilter('all')}>
{t('filter.all')} {t('filter.all')}
@@ -163,7 +165,7 @@ export function GrillePage() {
))} ))}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<button <button
type="button" type="button"
onClick={() => runSolver.mutate()} onClick={() => runSolver.mutate()}
@@ -199,7 +201,7 @@ export function GrillePage() {
) : null} ) : null}
</div> </div>
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-2 border-ink bg-cream px-[9px] py-[5px] lg:ml-auto">
<span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span> <span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
<LegendItem swatch="bg-hatch-orange" glyph="▲"> <LegendItem swatch="bg-hatch-orange" glyph="▲">
{t('grille.legend.critical')} {t('grille.legend.critical')}
@@ -220,7 +222,7 @@ export function GrillePage() {
</div> </div>
<div className="flex min-h-0 flex-1 items-stretch"> <div className="flex min-h-0 flex-1 items-stretch">
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto"> <div className="max-h-[calc(100dvh-150px)] min-w-0 flex-1 overflow-auto lg:max-h-[calc(100vh-118px)]">
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}> <div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}>
<div <div
style={{ gridColumn: '1 / span 1', gridRow: 1 }} style={{ gridColumn: '1 / span 1', gridRow: 1 }}
@@ -278,7 +280,25 @@ export function GrillePage() {
</div> </div>
</div> </div>
<aside className="max-h-[calc(100vh-118px)] w-[392px] flex-none overflow-auto border-l-[3px] border-ink bg-paper"> {/* Sous `lg:`, pas la place d'un panneau à côté de la grille : il
s'ouvre par-dessus (plein écran sur mobile, tiroir à droite sur
tablette) seulement quand un créneau est choisi. */}
{selected ? (
<div
aria-hidden
onClick={() => setSelectedShiftId(null)}
className="fixed inset-0 z-40 bg-ink/40 lg:hidden"
/>
) : null}
<aside
className={cx(
'overflow-auto border-ink bg-paper',
'lg:static lg:z-auto lg:block lg:max-h-[calc(100vh-118px)] lg:w-[392px] lg:flex-none lg:border-l-[3px]',
selected
? 'fixed inset-y-0 right-0 z-50 block w-full sm:w-[420px] sm:border-l-[3px]'
: 'hidden',
)}
>
{selected ? ( {selected ? (
<AssignmentPanel <AssignmentPanel
key={selected.shift.shift_id} key={selected.shift.shift_id}
+356 -118
View File
@@ -1,33 +1,61 @@
import { Fragment, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { ShiftRead } from '@/api/schema'; import type { PositionRead, ShiftRead } from '@/api/schema';
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks'; import {
useAssignments,
useDayParts,
usePositions,
useSetAttendance,
useShifts,
useUpdatePosition,
useVolunteer,
useVolunteerPlanning,
useVolunteers,
} from '@/api/hooks';
import { useAuth } from '@/auth/auth-context'; import { useAuth } from '@/auth/auth-context';
import { MessageEditor } from '@/components/MessageEditor';
import { ErrorNote, Loading } from '@/components/state'; import { ErrorNote, Loading } from '@/components/state';
import { NowLine } from '@/components/NowLine';
import { VolunteerAvatar } from '@/components/VolunteerAvatar'; import { VolunteerAvatar } from '@/components/VolunteerAvatar';
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time'; import { cx } from '@/lib/cx';
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
import { clockLabel, slotStatus, type SlotStatus } from '@/lib/slotTimeline';
import { longDayLabel, naiveMinutes } from '@/lib/time';
import { useNowMinutes } from '@/lib/useNowMinutes'; import { useNowMinutes } from '@/lib/useNowMinutes';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { cx } from '@/lib/cx';
import { VolunteerHomeView } from '@/pages/VolunteerHomeView'; import { VolunteerHomeView } from '@/pages/VolunteerHomeView';
import { volunteerInitials, volunteerName } from '@/lib/volunteer';
// Page réellement consultée par le référent (accueil de son rôle LEAD, cf. // Page réellement consultée par le référent (accueil de son rôle LEAD, cf.
// `auth/roles.ts:homePath`) — page responsive (mobile d'abord, élargie à // `auth/roles.ts:homePath`), d'après la maquette `design/Planning bénévole
// partir de `sm:`), plus de maquette de téléphone. Un compte peut être // mobile.dc.html` : son poste, ses créneaux à choisir (celui en cours ou le
// référent de plusieurs postes (`Position.lead_account_id`) : chacun a sa // prochain par défaut), les compteurs présents / attendus / absents, puis
// propre carte, listant TOUS ses créneaux (pas seulement le plus proche) avec // l'équipe du créneau à pointer. Pensée pour le téléphone ; sur un écran
// un repère « MAINTENANT » pour se situer. Aucun repli vers « le premier // plus large, la même colonne, centrée. Un compte peut être référent de
// poste de l'édition » si le compte n'est référent d'aucun poste — ce serait // plusieurs postes (`Position.lead_account_id`) : on passe de l'un à l'autre
// montrer un poste qui n'est pas le sien ; s'il est par ailleurs rattaché à // par des onglets. Aucun repli vers « le premier poste de l'édition » si le
// une fiche bénévole (cas courant : « Donner un accès de connexion » promu // compte n'est référent d'aucun poste — ce serait montrer un poste qui n'est
// en référent), il voit alors sa vue de bénévole classique plutôt qu'un // pas le sien ; s'il est par ailleurs rattaché à une fiche bénévole (cas
// écran référent vide (`VolunteerHomeView`). // courant : « Donner un accès de connexion » promu en référent), il voit
// alors sa vue de bénévole classique (`VolunteerHomeView`).
// //
// Le pointage présent/absent reste local à l'écran (état React, jamais // Un référent peut aussi être affecté comme bénévole, y compris la même
// envoyé au serveur) : le pointage sur site est hors périmètre (CLAUDE.md §5). // soirée : s'il est rattaché à une fiche bénévole, deux onglets — « Mon
// équipe » (cet écran) et « Mon planning » (`VolunteerHomeView`, ses
// propres créneaux). On ouvre sur « Mon planning » quand il tient en ce
// moment un créneau de bénévole hors de ses postes et qu'aucun de ses
// postes n'a de créneau en cours ; sinon sur « Mon équipe ».
//
// Le pointage présent/absent est enregistré sur l'affectation
// (`Assignment.attendance`, `PUT .../assignments/{id}/attendance`) : il
// survit à un rechargement et se retrouve sur un autre téléphone.
type Presence = Record<string, 'present' | 'absent'>; type View = 'team' | 'planning';
const STATUS_KEY: Record<SlotStatus, string> = {
done: 'mobileLead.status.done',
now: 'mobileLead.status.now',
next: 'mobileLead.status.next',
};
export function MobileLeadPage() { export function MobileLeadPage() {
const { account } = useAuth(); const { account } = useAuth();
@@ -35,26 +63,25 @@ export function MobileLeadPage() {
const shifts = useShifts(); const shifts = useShifts();
const volunteers = useVolunteers(); const volunteers = useVolunteers();
const assignments = useAssignments(); const assignments = useAssignments();
const nowMinutes = useNowMinutes(); const dayParts = useDayParts();
const [presence, setPresence] = useState<Presence>({}); const me = useVolunteer(account?.volunteer_id);
const myPlanning = useVolunteerPlanning(account?.volunteer_id);
const myPositions = (positions.data ?? []).filter( const [view, setView] = useState<View | null>(null);
(p) => account && p.lead_account_id === account.id, const updatePosition = useUpdatePosition();
); const now = useNowMinutes();
const setAttendance = useSetAttendance();
const [positionId, setPositionId] = useState<string | null>(null);
const [shiftId, setShiftId] = useState<string | null>(null);
const boundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
const shiftsByPosition = useMemo(() => { const shiftsByPosition = useMemo(() => {
const map = new Map<string, ShiftRead[]>(); const map = new Map<string, ShiftRead[]>();
for (const s of shifts.data ?? []) { for (const s of shifts.data ?? []) map.set(s.position_id, [...(map.get(s.position_id) ?? []), s]);
const list = map.get(s.position_id) ?? [];
list.push(s);
map.set(s.position_id, list);
}
for (const list of map.values()) { for (const list of map.values()) {
list.sort((a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at)); list.sort((a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at));
} }
return map; return map;
}, [shifts.data]); }, [shifts.data]);
const volunteersById = useMemo( const volunteersById = useMemo(
() => new Map((volunteers.data ?? []).map((v) => [v.id, v])), () => new Map((volunteers.data ?? []).map((v) => [v.id, v])),
[volunteers.data], [volunteers.data],
@@ -64,6 +91,10 @@ export function MobileLeadPage() {
const error = positions.error ?? shifts.error ?? volunteers.error ?? assignments.error; const error = positions.error ?? shifts.error ?? volunteers.error ?? assignments.error;
if (error) return <ErrorNote error={error} />; if (error) return <ErrorNote error={error} />;
const myPositions = (positions.data ?? []).filter(
(p) => account && p.lead_account_id === account.id,
);
if (myPositions.length === 0) { if (myPositions.length === 0) {
if (account?.volunteer_id) { if (account?.volunteer_id) {
return ( return (
@@ -82,149 +113,356 @@ export function MobileLeadPage() {
); );
} }
return ( // Par défaut : le poste dont un créneau est en cours ou arrive le plus tôt.
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col"> const nextEnd = (p: PositionRead) =>
<div className="bg-ink px-4 py-5 text-cream sm:px-6 sm:py-7"> (shiftsByPosition.get(p.id) ?? []).find((s) => now < naiveMinutes(s.ends_at));
<div className="font-mono text-[10px] text-yellow">{t('mobileLead.kicker')}</div> const position =
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]"> myPositions.find((p) => p.id === positionId) ??
{t('mobileLead.positionsCount', { count: myPositions.length })} [...myPositions].sort((a, b) => {
</div> const na = nextEnd(a);
</div> const nb = nextEnd(b);
if (!na || !nb) return na ? -1 : nb ? 1 : 0;
<div className="flex flex-1 flex-col gap-[22px] p-4 sm:p-6"> return naiveMinutes(na.starts_at) - naiveMinutes(nb.starts_at);
{myPositions.map((position) => { })[0];
const positionShifts = shiftsByPosition.get(position.id) ?? []; const positionShifts = shiftsByPosition.get(position.id) ?? [];
const nowIndex = positionShifts.findIndex((s) => naiveMinutes(s.starts_at) > nowMinutes); // Par défaut : le créneau en cours ou le prochain, sinon le dernier.
const nowInsertAt = nowIndex === -1 ? positionShifts.length : nowIndex; const shift =
positionShifts.find((s) => s.id === shiftId) ??
nextEnd(position) ??
positionShifts[positionShifts.length - 1] ??
null;
return ( const team = shift
<div key={position.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg"> ? (assignments.data ?? [])
<div className="bg-ink px-[14px] pb-[11px] pt-[11px] text-cream">
<div className="font-display text-[19px] font-black leading-tight tracking-tight">
{position.name.toUpperCase()}
</div>
{position.location ? (
<div className="font-display text-[13px] font-semibold opacity-80">
{position.location}
</div>
) : null}
</div>
<div className="flex flex-col gap-[14px] px-[14px] pb-[18px] pt-[13px]">
{positionShifts.length === 0 ? (
<div className="font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.noShift')}
</div>
) : null}
{positionShifts.map((shift, i) => {
const team = (assignments.data ?? [])
.filter((a) => a.shift_id === shift.id) .filter((a) => a.shift_id === shift.id)
.map((a) => { .map((a) => {
const volunteer = volunteersById.get(a.volunteer_id); const volunteer = volunteersById.get(a.volunteer_id);
return { return {
id: a.id, id: a.id,
volunteerId: a.volunteer_id, volunteerId: a.volunteer_id,
name: volunteer name: volunteer ? volunteerName(volunteer) : a.volunteer_id,
? `${volunteer.first_name} ${volunteer.last_name}` initials: volunteer ? volunteerInitials(volunteer) : '?',
: a.volunteer_id,
initials: volunteer
? `${volunteer.first_name[0]}${volunteer.last_name[0]}`
: '?',
hasPhoto: volunteer?.has_photo ?? false, hasPhoto: volunteer?.has_photo ?? false,
phone: volunteer?.phone ?? '', phone: volunteer?.phone ?? '',
present: presence[a.id] === 'present', state: a.attendance === 'PRESENT' ? 'present' : a.attendance === 'ABSENT' ? 'absent' : null,
absent: presence[a.id] === 'absent', markedAt: a.attendance_marked_at,
}; };
})
.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
: [];
const presentCount = team.filter((m) => m.state === 'present').length;
const absentCount = team.filter((m) => m.state === 'absent').length;
// Un second appui sur le même bouton annule le pointage.
const mark = (member: (typeof team)[number], value: 'present' | 'absent') =>
setAttendance.mutate({
id: member.id,
attendance: member.state === value ? null : value === 'present' ? 'PRESENT' : 'ABSENT',
}); });
const presentCount = team.filter((m) => m.present).length;
// Bénévoles distincts affectés à au moins un créneau du poste.
const positionShiftIds = new Set(positionShifts.map((s) => s.id));
const teamSize = new Set(
(assignments.data ?? []).filter((a) => positionShiftIds.has(a.shift_id)).map((a) => a.volunteer_id),
).size;
const shiftStatus = shift
? slotStatus(naiveMinutes(shift.starts_at), naiveMinutes(shift.ends_at), now)
: null;
const myName = me.data ? `${me.data.first_name} ${me.data.last_name}` : null;
const inProgress = (start: string, end: string) =>
slotStatus(naiveMinutes(start), naiveMinutes(end), now) === 'now';
const ledShiftIds = new Set(myPositions.flatMap((p) => (shiftsByPosition.get(p.id) ?? []).map((s) => s.id)));
const leadingNow = (shifts.data ?? []).some((s) => ledShiftIds.has(s.id) && inProgress(s.starts_at, s.ends_at));
const myEntries = myPlanning.data ?? [];
const volunteeringNow = myEntries.some(
(e) => !ledShiftIds.has(e.shift_id) && inProgress(e.starts_at, e.ends_at),
);
const currentView: View = view ?? (volunteeringNow && !leadingNow ? 'planning' : 'team');
const tabs = account?.volunteer_id ? (
<div className="mx-auto grid w-full max-w-[640px] grid-cols-2 border-b-[3px] border-ink">
<ViewTab active={currentView === 'team'} live={leadingNow} onClick={() => setView('team')}>
{t('mobileLead.view.team')}
</ViewTab>
<ViewTab active={currentView === 'planning'} live={volunteeringNow} onClick={() => setView('planning')}>
{t('mobileLead.view.planning', { count: myEntries.length })}
</ViewTab>
</div>
) : null;
if (currentView === 'planning' && account?.volunteer_id) {
return (
<div className="flex flex-1 flex-col">
{tabs}
<VolunteerHomeView volunteerId={account.volunteer_id} fallbackName={account.email.split('@')[0]} />
</div>
);
}
return ( return (
<Fragment key={shift.id}> <div className="flex flex-1 flex-col">
{i === nowInsertAt ? <NowLine /> : null} {tabs}
<div className="border-2 border-ink"> <div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
<div className="flex flex-wrap items-stretch gap-2 border-b-2 border-ink bg-sand px-[11px] py-[9px]"> <div className="bg-ink px-4 pb-4 pt-[15px] text-cream sm:px-6">
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink"> {myPositions.length > 1 ? (
{longDayLabel(shift.starts_at)} ·{' '} <div className="mb-3 flex flex-wrap gap-[6px]">
{hourRangeLabel(shift.starts_at, shift.ends_at)} {myPositions.map((p) => (
<button
key={p.id}
type="button"
onClick={() => {
setPositionId(p.id);
setShiftId(null);
}}
className={cx(
'min-h-[36px] cursor-pointer border-2 border-cream px-[10px] font-display text-[11.5px] font-extrabold uppercase',
p.id === position.id ? 'bg-yellow text-ink' : 'bg-transparent text-cream',
)}
>
{p.name}
</button>
))}
</div> </div>
<div className="border-2 border-ink bg-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold"> ) : null}
{t('mobileLead.present', { present: presentCount, total: team.length })} <div className="flex items-center gap-3">
{me.data ? (
<VolunteerAvatar
volunteerId={me.data.id}
hasPhoto={me.data.has_photo}
initials={volunteerInitials(me.data)}
className="h-[60px] w-[60px] !border-blue text-[19px]"
/>
) : null}
<div className="min-w-0 flex-1">
<div className="font-mono text-[10.5px] font-medium uppercase leading-none text-blue">
{myName ? t('mobileLead.kickerNamed', { name: myName }) : t('mobileLead.kicker')}
</div>
<div className="mt-[5px] break-words font-display text-[27px] font-black leading-[1.05] tracking-tight">
{position.name.toUpperCase()}
</div>
{position.location ? (
<div className="mt-[3px] font-display text-[13px] font-semibold leading-snug text-sand-dark">
{position.location}
</div>
) : null}
</div> </div>
</div> </div>
<div className="flex flex-col gap-[11px] p-[11px]"> {positionShifts.length ? (
<div className="mt-[13px] grid grid-cols-2 gap-[6px]">
{positionShifts.map((s) => {
const on = s.id === shift?.id;
const status = slotStatus(naiveMinutes(s.starts_at), naiveMinutes(s.ends_at), now);
return (
<button
key={s.id}
type="button"
onClick={() => setShiftId(s.id)}
aria-pressed={on}
className={cx(
'min-h-[50px] cursor-pointer border-2 border-cream px-2 py-[6px] text-left',
on ? 'bg-blue text-ink' : 'bg-transparent text-cream',
)}
>
<span className="block font-display text-[12px] font-black leading-tight">
{longDayLabel(`${festivalDay(s.starts_at, boundary)}T12:00`)}
</span>
<span className="block font-mono text-[11px] font-semibold leading-snug">
{clockLabel(naiveMinutes(s.starts_at))}–{clockLabel(naiveMinutes(s.ends_at))} ·{' '}
{t(STATUS_KEY[status])}
</span>
</button>
);
})}
</div>
) : (
<div className="mt-3 font-mono text-[11px] leading-relaxed opacity-70">{t('mobileLead.noShift')}</div>
)}
</div>
{shift ? (
<>
<div className="grid grid-cols-3 border-b-[3px] border-ink">
<Counter value={presentCount} label={t('mobileLead.count.present')} className="border-r-2 bg-mint" />
<Counter
value={team.length - presentCount - absentCount}
label={t('mobileLead.count.waiting')}
className="border-r-2 bg-paper"
/>
<Counter
value={absentCount}
label={t('mobileLead.count.absent')}
className={absentCount ? 'bg-orange' : 'bg-paper'}
/>
</div>
{absentCount ? (
<div className="border-b-[3px] border-ink bg-[repeating-linear-gradient(45deg,#FF5C39_0_9px,#E0421F_9px_18px)] px-[14px] py-[11px] font-display text-[15px] font-black leading-snug">
{t(absentCount > 1 ? 'mobileLead.absentAlert.other' : 'mobileLead.absentAlert.one', {
count: absentCount,
})}
</div>
) : null}
<div className="flex flex-col gap-[11px] px-[14px] pb-7 pt-[14px] sm:px-6">
{setAttendance.isError ? (
<div className="border-[3px] border-ink bg-orange px-3 py-2 font-display text-[12.5px] font-bold leading-snug">
{t('mobileLead.attendanceError')}
</div>
) : null}
<div className="flex items-baseline gap-2">
<span className="font-display text-[12px] font-black tracking-wider">
{t('mobileLead.roll', { count: team.length })}
</span>
<span className="ml-auto font-mono text-[10px] font-medium uppercase opacity-65">
{shiftStatus === 'now'
? t('mobileLead.when.now')
: shiftStatus === 'next'
? t('mobileLead.when.next', { time: clockLabel(naiveMinutes(shift.starts_at)) })
: t('mobileLead.when.done')}
</span>
</div>
{team.map((member) => ( {team.map((member) => (
<div <div
key={member.id} key={member.id}
className="border-[3px] border-ink bg-cream shadow-brutal-sm" className={cx(
'border-[3px] border-ink shadow-brutal-md',
member.state === 'absent' ? 'bg-orange-blush' : member.state === 'present' ? 'bg-mint-pale' : 'bg-paper',
)}
> >
<div className="flex items-center gap-[10px] px-[11px] py-[10px]"> <div className="flex items-center gap-[10px] px-[10px] py-[9px]">
<VolunteerAvatar <VolunteerAvatar
volunteerId={member.volunteerId} volunteerId={member.volunteerId}
hasPhoto={member.hasPhoto} hasPhoto={member.hasPhoto}
initials={member.initials} initials={member.initials}
className="h-[38px] w-[38px] text-[13px]" className="h-12 w-12 !border-2 text-[15px]"
/> />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block font-display text-[17px] font-extrabold leading-tight"> <span className="block break-words font-display text-[16px] font-extrabold leading-tight">
{member.name} {member.name}
</span> {member.volunteerId === account?.volunteer_id ? (
<span className="block font-mono text-[10.5px] opacity-65"> <span className="ml-[6px] inline-block border-2 border-ink bg-blue px-[5px] py-[2px] align-middle font-display text-[9.5px] font-black leading-none">
{member.phone} {t('mobileLead.you')}
</span>
</span>
{member.present ? (
<span className="flex-none border-2 border-ink bg-green px-[7px] py-[5px] font-display text-[11px] font-extrabold text-white">
{t('mobileLead.badgePresent')}
</span> </span>
) : null} ) : null}
{member.absent ? (
<span className="flex-none border-2 border-ink bg-orange px-[7px] py-[5px] font-display text-[11px] font-extrabold">
{t('mobileLead.badgeAbsent')}
</span> </span>
{member.phone ? (
<span className="block font-mono text-[11px] font-medium leading-snug opacity-70">
{member.phone}
</span>
) : null}
{member.state && member.markedAt ? (
<span className="block font-mono text-[10px] font-medium leading-snug opacity-60">
{t('mobileLead.markedAt', { time: clockLabel(naiveMinutes(member.markedAt)) })}
</span>
) : null}
</span>
{member.phone ? (
<a
href={`tel:${member.phone.replace(/\s+/g, '')}`}
aria-label={t('mobileLead.callLabel', { name: member.name })}
className="flex h-[46px] w-[46px] flex-none items-center justify-center border-2 border-ink bg-cream font-display text-[10px] font-black"
>
{t('mobileLead.call')}
</a>
) : null} ) : null}
</div> </div>
<div className="grid grid-cols-2 border-t-[3px] border-ink"> <div className="grid grid-cols-2 border-t-[3px] border-ink">
<button <button
type="button" type="button"
onClick={() => onClick={() => mark(member, 'present')}
setPresence((p) => ({ ...p, [member.id]: 'present' })) aria-pressed={member.state === 'present'}
}
className={cx( className={cx(
'min-h-[52px] cursor-pointer border-r-[3px] border-ink bg-paper font-display text-[14px] font-black hover:bg-green hover:text-white', 'min-h-[52px] cursor-pointer border-r-[3px] border-ink font-display text-[15px] font-black',
member.state === 'present' ? 'bg-green text-white' : 'bg-cream',
)} )}
> >
{t('mobileLead.markPresent')} {t(member.state === 'present' ? 'mobileLead.badgePresent' : 'mobileLead.markPresent')}
</button> </button>
<button <button
type="button" type="button"
onClick={() => onClick={() => mark(member, 'absent')}
setPresence((p) => ({ ...p, [member.id]: 'absent' })) aria-pressed={member.state === 'absent'}
} className={cx(
className="min-h-[52px] cursor-pointer bg-paper font-display text-[14px] font-black hover:bg-orange" 'min-h-[52px] cursor-pointer font-display text-[15px] font-black',
member.state === 'absent' ? 'bg-orange' : 'bg-cream',
)}
> >
{t('mobileLead.markAbsent')} {t(member.state === 'absent' ? 'mobileLead.badgeAbsent' : 'mobileLead.markAbsent')}
</button> </button>
</div> </div>
</div> </div>
))} ))}
{team.length === 0 ? ( {team.length === 0 ? (
<div className="font-mono text-[11px] leading-relaxed opacity-70"> <div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.emptyTeam')} {t('mobileLead.emptyTeam')}
</div> </div>
) : null} ) : null}
</div> </div>
</div> </>
</Fragment>
);
})}
{nowInsertAt === positionShifts.length && positionShifts.length > 0 ? (
<NowLine />
) : null} ) : null}
{/* Message du poste : montré à chaque bénévole affecté, sur son
écran (créneau mis en avant et liste des créneaux). */}
<div className="border-t-[3px] border-ink bg-sand px-[14px] pb-7 pt-[14px] sm:px-6">
<div className="flex items-baseline gap-2">
<span className="font-display text-[12px] font-black tracking-wider">
{t('mobileLead.message.title')}
</span>
<span className="ml-auto font-mono text-[10px] font-medium opacity-65">
{t('mobileLead.message.audience', { count: teamSize })}
</span>
</div> </div>
<p className="mb-[9px] mt-1 font-mono text-[10.5px] leading-relaxed opacity-70">
{t('mobileLead.message.help')}
</p>
<MessageEditor
key={position.id}
value={position.message}
updatedAt={position.message_updated_at}
placeholder={t('mobileLead.message.placeholder')}
saving={updatePosition.isPending}
error={updatePosition.error}
onSave={(text) => updatePosition.mutateAsync({ id: position.id, body: { message: text } })}
/>
</div> </div>
);
})}
</div> </div>
</div> </div>
); );
} }
function Counter({ value, label, className }: { value: number; label: string; className: string }) {
return (
<div className={cx('border-ink px-3 py-[10px]', className)}>
<div className="font-mono text-[24px] font-bold leading-none">{value}</div>
<div className="mt-[3px] font-display text-[9.5px] font-extrabold leading-tight tracking-wider">{label}</div>
</div>
);
}
function ViewTab({
active,
live,
onClick,
children,
}: {
active: boolean;
/** Un créneau de cette vue est en cours : pastille orange. */
live: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cx(
'flex min-h-[46px] cursor-pointer items-center justify-center gap-[7px] border-r-[3px] border-ink px-2 font-display text-[12px] font-black uppercase tracking-wide last:border-r-0',
active ? 'bg-ink text-yellow' : 'bg-paper text-ink hover:bg-yellow-soft',
)}
>
{live ? <span className="h-[9px] w-[9px] flex-none border-2 border-ink bg-orange" aria-hidden="true" /> : null}
{children}
</button>
);
}
+6 -6
View File
@@ -191,7 +191,7 @@ function PaymentConfirmationView({ token }: { token: string }) {
}; };
return ( return (
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6"> <div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
<Panel> <Panel>
<PanelHeader>{t('publicForm.confirmation.title')}</PanelHeader> <PanelHeader>{t('publicForm.confirmation.title')}</PanelHeader>
<div className="flex flex-col gap-3 p-4"> <div className="flex flex-col gap-3 p-4">
@@ -273,7 +273,7 @@ function MecenatInfoStep({
}; };
return ( return (
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6"> <div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
<FormLogo url={logoUrl} /> <FormLogo url={logoUrl} />
<Panel> <Panel>
<PanelHeader>{t('publicForm.mecenat.title')}</PanelHeader> <PanelHeader>{t('publicForm.mecenat.title')}</PanelHeader>
@@ -333,7 +333,7 @@ function CheckoutRetryPanel({ token, logoUrl }: { token: string; logoUrl: string
}; };
return ( return (
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6"> <div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
<FormLogo url={logoUrl} /> <FormLogo url={logoUrl} />
<Panel> <Panel>
<PanelHeader>{t('publicForm.retry.title')}</PanelHeader> <PanelHeader>{t('publicForm.retry.title')}</PanelHeader>
@@ -406,7 +406,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
if (volunteerId) { if (volunteerId) {
return ( return (
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6"> <div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
<FormLogo url={logoUrl} /> <FormLogo url={logoUrl} />
<Panel> <Panel>
<PanelHeader>{t('publicForm.success.title')}</PanelHeader> <PanelHeader>{t('publicForm.success.title')}</PanelHeader>
@@ -437,7 +437,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
if (!config.data.is_open) { if (!config.data.is_open) {
return ( return (
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6"> <div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
<FormLogo url={logoUrl} /> <FormLogo url={logoUrl} />
<Panel> <Panel>
<PanelHeader>{t('publicForm.closed.title')}</PanelHeader> <PanelHeader>{t('publicForm.closed.title')}</PanelHeader>
@@ -524,7 +524,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
}; };
return ( return (
<div className="mx-auto flex max-w-[560px] flex-col gap-4 p-4"> <div className="mx-auto flex max-w-[560px] flex-col gap-4 p-3 sm:p-4">
<FormLogo url={logoUrl} /> <FormLogo url={logoUrl} />
<Panel> <Panel>
<PanelHeader>{t('publicForm.title')}</PanelHeader> <PanelHeader>{t('publicForm.title')}</PanelHeader>

Some files were not shown because too many files have changed in this diff Show More