Compare commits

...
79 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
AdamThomasandClaude Opus 5.5 c60bc11795 Cerfa mécénat : mode de versement coché et dates repositionnées
ci-cd / build-backend (push) Successful in 28s
ci-cd / build-frontend (push) Successful in 17s
ci-cd / deploy (push) Successful in 12s
- nouveau modèle PDF du cerfa
- case « Virement, prélèvement, carte bancaire » cochée (paiement SumUp)
- date du versement et date de signature repositionnées
- message de confirmation du paiement : annonce l'email de confirmation
  et le reçu fiscal pour le mécénat

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:34:22 +02:00
AdamThomasandClaude Opus 5.5 770a12e3aa Écran Inscriptions : signale quand les types de paiement sont inactifs
ci-cd / build-backend (push) Successful in 7s
ci-cd / build-frontend (push) Successful in 17s
ci-cd / deploy (push) Successful in 11s
Sans identifiants SumUp (code marchand + clé API) ou sans aucun type de
paiement, le formulaire public n'affiche qu'une simple case « cotisation »
et crée le bénévole directement : ni choix du type de paiement, ni étape
d'adresse mécénat, ni paiement en ligne. Rien ne le signalait, si bien
qu'une option « Mécénat » configurée semblait ne pas fonctionner.
L'écran affiche désormais l'état Actif / Inactif sous « Types de
paiement », avec la raison.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:22:46 +02:00
AdamThomasandClaude Opus 5.5 c49ce520e3 Type de paiement à montant libre (pensé pour le mécénat)
ci-cd / build-backend (push) Successful in 33s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s
FormPaymentOption.free_amount (migration 0021) : case « Montant libre »
sur l'écran Inscriptions ; le montant configuré devient le minimum et le
bénévole saisit le sien sur le formulaire public (payment_amount, virgule
ou point, 2 décimales max, plafond 10 000 € contre une faute de frappe —
422 form.invalid_free_amount sinon). Validé côté client avant l'envoi,
le serveur restant seul juge. Le montant choisi est celui du checkout
SumUp, recopié sur la fiche, l'email de confirmation et le cerfa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:49:27 +02:00
AdamThomasandClaude Opus 5.5 ccd769d04e Numéro d'ordre unique sur chaque reçu fiscal mécénat
Volunteer.mecenat_receipt_number (migration 0020, unique) : année du
paiement + rang sur 5 chiffres (202600001…), une seule numérotation pour
toutes les éditions, repartant à 1 chaque année. Attribué à la
confirmation du paiement, ou au premier export pour les bénévoles payés
avant (dans l'ordre des paiements), puis jamais réattribué — un reçu
regénéré garde son numéro. Attribution sérialisée par un verrou
pg_advisory_xact_lock (webhook et sondage peuvent confirmer deux
paiements à la fois). Imprimé centré dans la case « Numéro d'ordre du
reçu » en page 1 du cerfa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:48:05 +02:00
AdamThomasandClaude Opus 5.5 5a0209ce43 Cerfa mécénat rempli envoyé par email à la confirmation du paiement
Quand un paiement « mécénat » est confirmé, l'email de confirmation porte
en pièce jointe le reçu fiscal (cerfa 11580*05) rempli du bénévole, avec
une phrase l'annonçant dans le corps. Généré en tâche de fond : un échec
de génération envoie l'email sans pièce jointe plutôt que rien.

- build_message/send acceptent des pièces jointes
- app/mecenat/receipts.py : passage Volunteer → MecenatReceipt, partagé
  avec l'export coordination
- modèle cerfa remplacé par une version pré-remplie avec l'association
  (organisme bénéficiaire, cases 200/978, signature) ; position de la
  date du versement ajustée

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:47:45 +02:00
AdamThomasandClaude Opus 5.5 5675e1d220 Formulaire public : astérisque rouge sur les champs obligatoires
Composant RequiredMark et prop `required` sur FieldLabel ; marqués
uniquement les champs réellement exigés à la soumission (nom, prénom,
email/téléphone/disponibilité/cotisation s'ils sont activés, étape
d'adresse mécénat), avec une légende « * champ obligatoire » en tête du
formulaire. Champ photo déplacé avant la disponibilité, libellé précisé
(« pour ton badge »).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:47:24 +02:00
AdamThomasandClaude Sonnet 5 096b7cd26a Lien d'inscription protégé par jeton, reçus fiscaux mécénat, infobulles et revue de sécurité
ci-cd / build-backend (push) Successful in 1m12s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s
- Lien d'inscription protégé par un jeton (FormTemplate.access_token,
  /inscription/<token>), avec régénération depuis l'écran de configuration.
- Reçus fiscaux mécénat (cerfa 11580*05) : un PDF par bénévole ayant payé en
  mécénat, donateur/montant (chiffres et toutes lettres en français,
  app/mecenat/number_words.py)/date complétés par-dessus le modèle officiel.
- Jeu de démonstration : bénévoles avec cotisation classique ou mécénat payée.
- Infobulles (HelpTip et détail de cellule de la grille des besoins)
  repositionnées en position: fixed via JS, bornées à l'écran.
- Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une
  URL (fuyait le jeton d'inscription), sujet retiré du log de remise des
  emails (pouvait porter un nom de bénévole), photo du formulaire public
  revalidée par décodage réel avant stockage, export Excel protégé contre
  l'injection de formule, sondage du statut de paiement limité en
  fréquence, pyjwt mis à jour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 07:47:17 +02:00
AdamThomasandClaude Sonnet 5 a2b748566b Inscription en deux temps, emails de confirmation et export Excel des bénévoles
ci-cd / build-backend (push) Successful in 30s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s
Formulaire public — paiement en ligne :
- Le bénévole n'est plus compté en base tant qu'un paiement en ligne choisi
  n'est pas confirmé (PendingFormSubmission, promu en Volunteer à la
  confirmation, webhook ou vérification en direct).
- Type de paiement « mécénat » : étape d'adresse postale avant le paiement,
  conservée sur le bénévole pour un reçu fiscal (cerfa) ultérieur.
- Page de confirmation au retour de SumUp, avec relance possible après
  échec/expiration sans ressaisir le formulaire.
- « Je paierai sur place » remplace « je ne participe pas » ; tous les
  champs activés par la coordination sont désormais obligatoires.
- La fiche bénévole n'affiche plus l'état des cases à cocher, seulement le
  résultat réel du paiement (libellé, montant, statut).

Emails :
- Email de confirmation d'inscription à la création du bénévole (tient lieu
  de reçu de paiement quand une cotisation est payée en ligne).
- Mise en forme HTML au thème de l'application (logo de l'édition en
  en-tête) pour tous les emails envoyés, texte brut conservé en repli.

Export Excel des bénévoles (COORDINATION) : coordonnées, vœux résolus en
noms lisibles, cotisation et mécénat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 07:41:58 +02:00
AdamThomasandClaude Sonnet 5 68d42ee303 fix: réduit la concurrence uv pour le build Docker du backend
ci-cd / build-backend (push) Successful in 1m13s
ci-cd / build-frontend (push) Successful in 24s
ci-cd / deploy (push) Successful in 21s
L'échec persistait malgré un timeout à 180s : le vrai problème est la bande
passante limitée du runner CI, saturée par les 50 téléchargements uv en
parallèle (défaut) — chaque paquet n'obtenait qu'une fraction du débit et
finissait par expirer (constaté sur sqlalchemy, après 180s). Réduit
UV_CONCURRENT_DOWNLOADS à 4 pour que chaque téléchargement avance
réellement, et remonte le délai à 600s par sécurité.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 20:52:56 +02:00
AdamThomasandClaude Sonnet 5 ac5710ffaf fix: augmente UV_HTTP_TIMEOUT pour le build Docker du backend
ci-cd / build-backend (push) Failing after 7m39s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped
Le build CI échouait par intermittence sur le téléchargement de
charset-normalizer (dépendance de reportlab) : délai de 30s par défaut
trop court sur le runner. Porté à 180s.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 20:09:30 +02:00
AdamThomasandClaude Sonnet 5 0bd38a6465 Formulaire public : paiement de la cotisation via l'API SumUp (remplace Yapla)
ci-cd / build-backend (push) Failing after 4m12s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped
Remplace le simple lien Yapla statique par une vraie intégration API SumUp :
checkout hébergé créé à la soumission du formulaire, statut de paiement
(PENDING/PAID/FAILED/EXPIRED) tenu à jour par un webhook SumUp et visible sur
la fiche du bénévole. La cotisation n'est plus un montant unique : la
coordination configure autant de types de paiement qu'elle veut (libellé +
montant, ex. « Cotisation classique », « Mécénat »), et le bénévole en
choisit un à l'inscription — le libellé et le montant choisis sont figés sur
le bénévole, indépendamment d'une modification ultérieure de la config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 19:56:04 +02:00
AdamThomas 0be5daa89c fix: update ci 2026-09-29 18:31:08 +02:00
AdamThomasandClaude Sonnet 5 26a10cd595 Ajustements manuels : textes du formulaire, opacité des en-têtes de grille
ci-cd / backend-test (push) Failing after 41m59s
ci-cd / build-backend (push) Skipped
ci-cd / frontend-test (push) Failing after 45m57s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped
Formulaire public : libellés retravaillés (photo, disponibilité, cotisation),
suppression du libellé RGPD affiché sur le bouton de suppression d'un
bénévole. Grilles (besoins, vue bénévoles) : en-têtes « Affectés/idéal » et
« Nom » en pleine opacité ; z-index de l'infobulle au survol d'un créneau
relevé (z-[100]). .gitignore : ignore le fichier TODO personnel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 20:23:49 +02:00
AdamThomasandClaude Sonnet 5 5f7718adfd Grille des besoins : corrige le chevauchement de la ligne d'en-tête
La cellule « Affectés / idéal » (figée en haut à gauche) et le libellé
« N postes » partageaient le même z-index (6) — une égalité entre deux
éléments sticky peut se départager de façon imprévisible pendant un
défilement selon le navigateur. Par ailleurs, l'infobulle de survol
d'une cellule de créneau (z-20) n'était pas isolée dans son propre
contexte d'empilement et rivalisait donc directement avec les en-têtes
figés (max z-6) : un survol déclenché par la molette pendant un
défilement (le curseur reste immobile, ce sont les cellules qui
défilent dessous) pouvait la faire passer au-dessus de l'en-tête le
temps de sa transition de disparition.

Hiérarchie refaite sans égalité (coins figés 20/21 > en-têtes 11/12 >
libellés de poste 10 > cellules, chacune isolée via `relative z-0`).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 19:34:34 +02:00
AdamThomasandClaude Sonnet 5 8b9c0ac498 Corrige la grille des besoins : postes manquants au-delà du premier
Chaque cellule de la grille (label de poste, en-têtes, créneaux, cases
vides) recevait sa position via l'ordre d'émission des enfants, en
comptant sur le fait que chaque ligne totalise exactement le nombre de
tranches de 30 min d'un jour pour que l'auto-flow CSS reste aligné. Un
poste dont les créneaux ne tombaient pas exactement sur ce total (deux
créneaux adjacents mais non alignés sur 30 min, courant depuis la
création de créneaux à la main) décalait tous les postes suivants —
leur nom de poste n'apparaissait plus sur la bonne ligne.

Chaque cellule reçoit maintenant sa position de grille explicitement
(gridColumn/gridRow calculés), immunisé contre ce genre de décalage :
l'anomalie d'un poste reste locale à sa propre ligne.

Nouveau test de non-régression (scripts/smoke.tsx:checkGridLayout) :
inspecte les gridRow réellement rendus plutôt que la simple absence de
crash — un décalage CSS n'est pas détectable par un rendu SSR ordinaire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 19:04:38 +02:00
AdamThomasandClaude Sonnet 5 5f80f167c8 Formulaire public : encart de paiement de la cotisation via Yapla
FormTemplate.yapla_url (lien vers la page de cotisation Yapla, doit
commencer par http(s)://, PATCH .../form comme description/is_open)
fait apparaître un encart « Payer ma cotisation » sous la case
WILLING_TO_PAY_FEE, sur le formulaire public. Configurable depuis le
panneau « Personnalisation » de l'écran /inscriptions. La case reste
une simple déclaration : rien ne vérifie que le paiement a eu lieu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 21:10:38 +02:00
AdamThomasandClaude Sonnet 5 8f8350aa1b Feuilles de poste : ajouter la programmation du jour (horaires artistes)
Sous le référent, la liste des artistes qui jouent ce jour-là (heure de
passage + nom), tous postes confondus — repère utile même hors de la
régie (affluence prévisible au bar, sécurité...). Triée par horaire,
omise si aucun artiste ne joue ce jour-là.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 21:01:48 +02:00
AdamThomasandClaude Sonnet 5 957a004623 Formulaire public : logo et texte d'introduction personnalisables
FormTemplate porte désormais un logo (image, PUT/GET .../form/logo — GET
public comme .../form/public, affiché sur /inscription) et un texte
d'introduction libre (PATCH .../form, description désormais optionnelle
comme is_open). Écran de configuration (/inscriptions) : nouveau panneau
« Personnalisation » pour importer le logo et rédiger le texte. Le
formulaire public affiche le logo en grand au-dessus du panneau et le
texte juste sous le titre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 20:46:52 +02:00
AdamThomasandClaude Sonnet 5 48d3deec65 Formulaire public : photo recadrée en carré, aperçu agrandi
La photo ajoutée (fichier ou prise depuis l'appareil) est recadrée en carré
côté navigateur avant l'envoi (centré, tronque le surplus, réencodée en
JPEG ≤ 1024 px de côté) — elle est donc toujours carrée en base, sans
recadrage serveur, ce qui profite aussi aux vignettes des badges et des
feuilles de poste. Un aperçu agrandi (180 px) s'affiche dès que la photo
est choisie.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 20:26:01 +02:00
AdamThomasandClaude Sonnet 5 f6fbfaf82a Feuilles de poste : ajouter la photo des bénévoles affectés
Vignette carrée (14 mm, proportions préservées) à côté du nom de chaque
bénévole affecté, à côté de son téléphone — case vide si le bénévole n'a
pas de photo. Complète l'affichage déjà en place sur l'écran référent et
l'écran bénévole (étape 22).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:06:51 +02:00
AdamThomasandClaude Sonnet 5 15109acfe5 Créneaux à la main + grille des besoins en axe des temps (30 min)
Un poste ne génère plus de créneaux à sa création : la coordination crée
elle-même ceux dont elle a besoin, avec ses propres horaires de début et
de fin (ConfigGrid.tsx). La génération depuis les créneaux types reste
disponible en option (bouton « Générer les créneaux »).

La grille des besoins devient un vrai axe des temps : une colonne = 30 min,
bornée par jour à la plage réellement utilisée, avec les cases fusionnées
pour correspondre aux horaires de chaque créneau (buildTimeGrid /
placeInTimeGrid). La « Vue bénévoles » garde son ancien modèle (une colonne
par créneau distinct), plus adapté à son tableau bénévole × créneau.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:44:20 +02:00
AdamThomasandClaude Sonnet 5 f1d88302a4 Feuilles de poste : ajouter le référent en en-tête
Nom + téléphone du référent du poste (Position.lead_account_id), juste
sous le sous-titre (jour/lieu) — « Référent : à désigner » si aucun.
Même source que le référent affiché à l'écran bénévole
(volunteers.py:get_volunteer_planning).

Vérifié : pytest (287 passés, dont les nouveaux tests avec/sans
référent), ruff, alembic check — tous verts (pas de changement
frontend, la feuille reste un simple téléchargement de PDF).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:21:26 +02:00
AdamThomasandClaude Sonnet 5 2a2a0c23ca Formulaire public : phrases des cases à cocher plus chaleureuses
« Case « je suis disponible »" / « Case « je veux bien payer une
cotisation »" étaient en fait le libellé de configuration (écran
coordination) réutilisé tel quel comme texte de la case à cocher côté
formulaire public — d'où le ton administratif. Clés dédiées
(publicForm.field.*) avec une formulation à la première personne, plus
adaptée à quelqu'un qui remplit le formulaire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:16:49 +02:00
AdamThomasandClaude Sonnet 5 40127a2252 Étape 22 : feuilles de poste (PDF), complément des badges
Une page par poste et par jour, listant les créneaux avec les bénévoles
affectés et leur téléphone — à imprimer et afficher sur place. Aucune
nouvelle entité : dérivé de Position/Shift/Assignment/Volunteer
existants (contrairement aux badges, pas de personnalisation à
configurer). Un créneau à besoin 0/0 sans affectation n'apparaît pas.

Backend : app/sheets/pdf.py (ReportLab, Platypus — SimpleDocTemplate/
Table plutôt que le canvas bas niveau des badges, plus adapté à un
rapport texte multi-pages), POST /editions/{id}/position-sheets/generate
(COORDINATION, {position_ids?} — absent = tous les postes).

Frontend : bouton « Feuilles de poste » dans la grille des besoins,
respecte le filtre de catégorie déjà actif à l'écran.

Vérifié : pytest (284 passés), ruff, alembic check, tsc --noEmit,
eslint, vite build, smoke (flux de bout en bout en mode démonstration)
— tous verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 21:27:01 +02:00
AdamThomasandClaude Sonnet 5 111d1e5c4b Afficher la photo du bénévole sur son écran et sur l'écran référent
Photo transmise via le formulaire public (Volunteer.photo) : nouveau
composant partagé VolunteerAvatar (photo si disponible, sinon repli sur
le carré d'initiales existant, même gabarit dans les deux cas).

- Écran bénévole (VolunteerHomeView) : photo dans le bandeau d'accueil,
  à côté du message de bienvenue.
- Écran référent (MobileLeadPage) : photo devant le nom de chaque membre
  de l'équipe, à la place du carré d'initiales.

GET .../volunteers/{id}/photo n'était accessible qu'à COORDINATION/
RÉFÉRENT ; ouvert aussi à l'intéressé lui-même (même garde que
get_volunteer), sinon l'écran bénévole ne pouvait jamais charger sa
propre photo.

Vérifié : pytest (274 passés, dont le nouveau test d'accès à sa propre
photo), ruff, tsc --noEmit, eslint, vite build, smoke — tous verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 21:10:04 +02:00
AdamThomasandClaude Sonnet 5 82088f3b0c Étape 21 : formulaire public d'inscription des bénévoles
Retire « constructeur de formulaire » des exclusions du CLAUDE.md (§ 5) —
décision explicite prise en conversation.

Un formulaire par édition (COORDINATION choisit les champs actifs parmi
neuf : email, téléphone, vœux de poste — max 2, vœux d'artiste — un par
soir de festival, vœu de compagnon en texte libre, disponibilité/
cotisation déclaratives, photo, commentaire ; nom/prénom toujours
collectés) ouvert/fermé à volonté, avec un lien à partager
(/inscription, aucun compte requis).

Backend : FormTemplate/FormField (migration 0013), quatre nouveaux champs
sur Volunteer (photo, companion_wish_note, self_declared_available,
willing_to_pay_fee), app/api/forms.py — les deux seuls endpoints publics
de toute l'API (GET .../form/public, POST .../form/public/submit),
protégés par hCaptcha (app/forms/captcha.py, désactivé sans
HCAPTCHA_SECRET_KEY) et un filet de sécurité global par édition
(form_submission_limiter). resolve_owned_ids/set_artists/
set_wished_positions déplacés vers app/api/_common.py, partagés avec
volunteers.py.

Frontend : écran de configuration (/inscriptions) avec lien copiable et
bascule ouvert/fermé, formulaire public (/inscription, hors RequireAuth)
avec widget hCaptcha chargé dynamiquement, capture photo native
(<input capture>), vœux de poste/artiste avec les mêmes règles que le
back-end. Réponses du formulaire visibles et éditables depuis la fiche
bénévole existante (ConfigVolunteers.tsx).

Vérifié : pytest (273 passés), ruff, alembic check, tsc --noEmit, eslint,
vite build, smoke (rendu SSR de chaque écran + flux de bout en bout du
formulaire en mode démonstration) — tous verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 20:24:32 +02:00
AdamThomasandClaude Sonnet 5 55c5d539a8 Badges : permettre d'agrandir le badge proportionnellement à l'image
Le format déduit de l'image importée (300 dpi supposés, étape
précédente) donne un badge minuscule pour une image à résolution web —
ajout d'un contrôle « largeur du badge (mm) » dans l'écran de
personnalisation : la hauteur suit proportionnellement (ratio de
l'image). PUT /editions/{id}/badges/template/size écrase la taille
déduite de l'image ; la réimporter la recalcule à nouveau.

Vérifié : pytest (250 passés), ruff, tsc --noEmit, eslint, vite build,
smoke — tous verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 20:51:01 +02:00
AdamThomasandClaude Sonnet 5 bc165757ca Badges : corrections de lint (E501) et couverture du smoke test
Lignes trop longues détectées par ruff (badges.py, tests) ; ajout de
/badges au smoke test frontend (rendu SSR de chaque écran protégé).

Vérifié dans ce commit : pytest (248 passés), ruff check, alembic
check, tsc --noEmit, eslint, vite build, smoke — tous verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 20:37:24 +02:00
AdamThomasandClaude Sonnet 5 579ea5d9db Badges : format dérivé de l'image importée, postes avec horaire et référent
- Le format du badge n'est plus fixé à CR80 : il est déduit des pixels de
  l'image du recto à l'import (résolution supposée 300 dpi,
  app/badges/pdf.py:image_size_mm), stocké sur BadgeTemplate (migration
  0012). L'éditeur (BadgesPage.tsx) et la génération PDF s'alignent sur ce
  format réel ; CR80 reste le repère par défaut tant qu'aucune image n'est
  importée. Le verso est ajusté sans déformation (preserveAspectRatio).

- Le champ « Postes affectés » liste désormais une ligne par créneau
  (triées chronologiquement) : nom du poste + horaire, et le référent du
  poste s'il y en a un et que ce n'est pas le bénévole badgé lui-même.
  Texte multi-lignes pris en charge par la génération PDF (découpe sur
  `\n`, hauteur de ligne calculée en mm) et par l'aperçu de l'éditeur.

- Corrige au passage un bug d'unités dans le positionnement vertical du
  texte (police en points confondue avec des millimètres).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 20:35:11 +02:00
AdamThomasandClaude Sonnet 5 b7fc80a7dc Étape 20 : planches de badges bénévoles (PDF)
Recto personnalisable (fond image + champs positionnés : nom, poste(s)
affectés, catégorie, rôle) via un éditeur visuel glisser-déposer ; verso
fixe, la même image pour tous les bénévoles. Nouvel écran « Badges »
(COORDINATION uniquement, /badges).

Backend : modèle BadgeTemplate/BadgeField (migration 0011), génération
PDF isolée (app/badges/pdf.py, ReportLab — planches recto suivies des
planches verso correspondantes pour une impression recto-verso), route
POST /editions/{id}/badges/generate, tests (module pur + API).

Frontend : client HTTP (upload multipart, réponses binaires images/PDF),
client mock (mode démonstration), éditeur de positionnement en
millimètres (coordonnées partagées avec le backend), écran de génération
avec sélection des bénévoles et téléchargement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 20:05:03 +02:00
AdamThomasandClaude Sonnet 5 4bcdafca96 Grille : ordonner les créneaux de nuit après minuit selon la vraie journée
Un créneau type commençant avant la frontière du jour (le plus petit
`order`, ex. « Nuit » 00 h–03 h après « Soirée » 19 h–00 h) était généré
sur la même date calendaire que la soirée elle-même au lieu du lendemain,
et s'affichait donc avant 19 h/22 h/23 h dans la grille des besoins au
lieu d'après 23 h. Corrigé dans la génération des créneaux
(_shiftgen.py, même frontière que validation.engine._day_boundary) et
dans le regroupement/tri de la grille (planning.ts : `dayBoundary`
calculé depuis les créneaux types).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 22:59:17 +02:00
AdamThomas f565feb3bc Déclenche le pipeline
ci-cd / backend-test (push) Successful in 53s
ci-cd / frontend-test (push) Successful in 32s
ci-cd / build-backend (push) Successful in 1m7s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 14s
2026-09-16 22:18:51 +02:00
AdamThomas db578fbda2 Déclenche le pipeline
ci-cd / frontend-test (push) Successful in 31s
ci-cd / build-backend (push) Failing after 6s
ci-cd / build-frontend (push) Failing after 6s
ci-cd / deploy (push) Skipped
ci-cd / backend-test (push) Successful in 52s
2026-09-16 22:16:10 +02:00
AdamThomasandClaude Sonnet 5 4a60c9ec34 Jeu de démo : restaurer les 15 postes avec catégories et lieux corrects
ci-cd / build-backend (push) Failing after 7s
ci-cd / build-frontend (push) Failing after 6s
ci-cd / deploy (push) Skipped
ci-cd / backend-test (push) Successful in 52s
ci-cd / frontend-test (push) Successful in 32s
La liste POSITIONS avait été remplacée par 16 postes tous classés BAR
et sans lieu, faisant échouer test_seed.py (assert 16 == 15).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:09:20 +02:00
AdamThomas ff2d490c94 Déclenche le pipeline
ci-cd / backend-test (push) Failing after 52s
ci-cd / build-backend (push) Skipped
ci-cd / frontend-test (push) Successful in 32s
ci-cd / build-frontend (push) Failing after 11s
ci-cd / deploy (push) Skipped
2026-09-16 22:05:20 +02:00
AdamThomas 056a1ee7da Déclenche le pipeline
ci-cd / backend-test (push) Failing after 52s
ci-cd / build-backend (push) Skipped
ci-cd / frontend-test (push) Failing after 42s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped
2026-09-16 21:59:03 +02:00
AdamThomas 5a3e11b77e update ci
ci-cd / backend-test (push) Canceled after 0s
ci-cd / frontend-test (push) Canceled after 0s
ci-cd / build-backend (push) Canceled after 0s
ci-cd / build-frontend (push) Canceled after 0s
ci-cd / deploy (push) Canceled after 0s
2026-09-16 21:49:34 +02:00
AdamThomas ba3ffbebf9 Déclenche le pipeline
ci-cd / backend-test (push) Failing after 13s
ci-cd / build-backend (push) Skipped
ci-cd / frontend-test (push) Failing after 39s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped
2026-09-16 21:28:04 +02:00
AdamThomas 010cac8adc gitea ci
ci-cd / backend-test (push) Failing after 7m57s
ci-cd / build-backend (push) Skipped
ci-cd / frontend-test (push) Failing after 32s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped
2026-09-16 21:09:28 +02:00
AdamThomasandClaude Sonnet 5 0ede409841 Limiteur de tentatives sur /auth/login et /auth/magic-link
Suite de la revue de sécurité (commit précédent) : ni /auth/login (mot
de passe COORDINATION) ni /auth/magic-link n'avaient de limite de
tentatives — aucun verrou de compte, aucun throttling, avant ouverture
au public.

`app/auth/rate_limit.py` : limiteur en mémoire (un seul processus
uvicorn, cf. Dockerfile), à créneau glissant, par clé (édition, email)
plutôt que par IP — le VPS ne transmet pas encore l'IP réelle du
client à travers Caddy, et le risque réel ici est de deviner le mot de
passe d'un compte connu (peu de comptes protégés par mot de passe),
pas une attaque distribuée sur des emails au hasard.

- `/auth/login` : 5 tentatives/minute → 429 `error.too_many_requests`.
- `/auth/magic-link` : 3 demandes/5 min (évite aussi le spam d'emails
  vers une victime ou vers le SMTP).

Fixture `_reset_rate_limiters` (autouse) pour ne pas faire fuiter l'état
d'un test à l'autre. Testé : dépassement, indépendance des clés,
expiration de la fenêtre, scénarios bout en bout sur les deux
endpoints.

Non inclus dans ce commit (édités en parallèle par l'utilisateur, laissés
en l'état) : backend/app/seed.py, frontend/tailwind.config.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-16 08:19:27 +02:00
AdamThomasandClaude Sonnet 5 89e74e5ec7 Revue de sécurité avant ouverture au public
Audit de l'application avant sa première publication sur internet.
Correctifs immédiats, peu coûteux et sans changement de comportement
en dev :

- Le backend refuse de démarrer en `ENVIRONMENT=production` si
  `JWT_SECRET` est vide, trop court ou égal à la valeur par défaut
  versionnée dans `.env.example` — sinon n'importe qui pourrait forger
  un jeton de session valide (y compris pour un compte COORDINATION)
  sans jamais s'authentifier. Testé (`test_config.py`).
- Avertissement au démarrage si `EMAIL_ENABLED=false` en production :
  dans ce cas le jeton de lien magique est journalisé en clair (confort
  de dev, cf. `api/auth.py`) — un identifiant de connexion en clair dans
  les logs du conteneur en public serait un vrai problème.
- `frontend/nginx.conf` : plus de journal d'accès (la route
  `/connexion?token=…` y ferait apparaître le jeton de lien magique en
  clair), en-têtes `X-Content-Type-Options`, `X-Frame-Options`,
  `Referrer-Policy` (empêche aussi la fuite de ce même jeton vers
  Google Fonts via l'en-tête Referer), `server_tokens off`.
- `deploy/Caddyfile.benevoles` : mêmes en-têtes côté reverse proxy
  public, plus `Strict-Transport-Security`.
- `deploy/README.md` : check-list sécurité avant ouverture (secret,
  SMTP, ne jamais mettre en production la base de démo `app.seed` — le
  mot de passe du compte coordination y est versionné en clair —,
  limites connues comme l'absence de rate-limiting sur `/auth/*`).

Points vérifiés sans modification nécessaire : autorisation par rôle et
cloisonnement par édition cohérents sur tous les endpoints (`owned_or_404`
+ vérification d'édition partout), mots de passe bcryptés, jetons de
lien magique à usage unique haché en base (256 bits d'entropie), pas de
PII/jeton dans les logs applicatifs (RGPD déjà en place), pas
d'injection de gabarit dans les emails (substitution sur liste blanche,
pas de `.format()`), pas d'injection d'en-tête SMTP (`EmailMessage` du
socle Python), pas de `dangerouslySetInnerHTML` côté front, jeton JWT
en `Authorization: Bearer` (pas de cookie ⇒ pas de CSRF), `.env` jamais
versionné, dépendances à jour.

Restent des choix à trancher avec l'utilisateur plutôt qu'à décider
seul (compromis effort/risque) : rate-limiting sur les endpoints
d'authentification, désactivation de `/docs` en production, conteneurs
non-root.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-16 08:14:00 +02:00
AdamThomasandClaude Sonnet 5 ade59b3dc4 Écran référent : repli sur la vue bénévole, tous les créneaux du poste
Deux bugs remontés sur l'écran référent (/mobile-referent), tous deux
révélés par le repli documenté « décocher un poste ne rétrograde jamais
le compte » (§2) :

- Un compte LEAD qui ne référence plus aucun poste (ex. décoché) reste
  LEAD et atterrit donc sur /mobile-referent après son lien magique —
  qui affichait un écran référent vide au lieu de sa vue de bénévole
  classique. `MobileLeadPage` détecte maintenant ce cas et affiche,
  quand le compte est rattaché à une fiche bénévole, le même contenu
  que `/mobile-benevole` (planning : poste, jour, horaire, lieu,
  responsable, téléphone) — extrait dans un composant partagé
  `VolunteerHomeView`.

- Un poste avec plusieurs créneaux n'en montrait qu'un seul (le plus
  proche de l'heure réelle) : le référent ne pouvait pas voir son
  équipe sur les autres créneaux. La carte de chaque poste liste
  maintenant tous ses créneaux, chacun avec sa propre équipe, avec un
  repère « MAINTENANT » (composant partagé `NowLine`, déjà utilisé côté
  bénévole) pour se situer parmi eux.

Non inclus dans ce commit (édités en parallèle par l'utilisateur, laissés
en l'état) : backend/app/seed.py, frontend/tailwind.config.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-16 07:50:00 +02:00
AdamThomasandClaude Sonnet 5 9e0be07726 Écran référent : vraie page responsive, référent correctement calculé
Deux bugs sur l'écran référent (/mobile-referent) :

- Il était resté à l'étape maquette (`PhoneFrame`/`DesignNote`, cadre de
  téléphone à taille fixe) alors que la vue bénévole avait déjà été
  convertie en vraie page responsive. Repli sur `positions.data[0]` en
  l'absence de poste géré par le compte connecté : un référent qui
  n'est encore désigné sur aucun poste se voyait quand même affiché
  comme référent du premier poste de l'édition (« Accueil artistes »
  dans les données de démo) au lieu de voir qu'il n'en a aucun.

Réécriture de `MobileLeadPage.tsx` en page responsive (mobile d'abord,
`sm:` pour l'élargissement, comme `MobileVolunteerPage.tsx`) : plus de
maquette de téléphone, une carte par poste réellement géré
(`Position.lead_account_id === account.id`, sans repli), créneau en
cours ou à venir calculé sur l'heure réelle (nouveau hook partagé
`useNowMinutes`, extrait de la vue bénévole), équipe affectée avec
pointage présent/absent (toujours local à l'écran : le pointage sur
site reste hors périmètre, CLAUDE.md §5). État vide explicite si le
compte n'est référent d'aucun poste.

`PhoneFrame`/`DesignNote` n'avaient plus d'autre utilisateur : supprimés
avec leurs clés i18n devenues mortes.

Non inclus dans ce commit (édités en parallèle par l'utilisateur, laissés
en l'état) : backend/app/seed.py, frontend/tailwind.config.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-15 23:42:48 +02:00
AdamThomasandClaude Sonnet 5 a78aad58e2 Vue bénévole mobile : responsable, téléphone et repère « maintenant »
La vue bénévole reprenait les infos essentielles (poste, jour, horaire,
lieu) mais affichait un badge de validation factice (« TU ES ATTENDU
SUR CE CRÉNEAU ») sans plus-value, sans jamais donner le nom ni le
téléphone du référent du poste à contacter — pourtant déjà modélisés
(`Position.lead_account_id`) depuis la gestion des référents.

Un compte VOLUNTEER n'a pas accès à `positions`/`shifts` (403, cf. § 2
matrice des accès) : ces infos ne pouvaient donc pas être jointes côté
front à partir des listes existantes. Nouvel endpoint dédié et
minimal, réservé à la lecture du planning propre du bénévole :

  GET /editions/{id}/volunteers/{vid}/planning

Renvoie, par affectation : poste, lieu, et référent (nom + téléphone,
`null` si le poste n'a pas de référent ou si son compte n'est pas
rattaché à une fiche bénévole) — sans exposer la liste complète des
postes/créneaux de l'édition. Tests : accès (soi-même / autre
bénévole 403 / COORD+LEAD), présence/absence du référent, tri par
heure de début.

Front : retrait du badge de validation, ajout des lignes responsable/
téléphone par créneau, et un repère horizontal « MAINTENANT » qui se
positionne dans la liste triée des créneaux selon l'heure réelle (juste
avant le prochain créneau à venir) pour situer le bénévole dans son
planning.

Non inclus dans ce commit (édités en parallèle par l'utilisateur,
laissés en l'état) : backend/app/seed.py, frontend/tailwind.config.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-15 23:33:06 +02:00
AdamThomasandClaude Sonnet 5 8fcc22115f Gestion des référents depuis la fiche bénévole
Un poste avait bien un `lead_account_id`, lu par l'écran référent
(`/mobile-referent`) et par les droits d'écriture (`ensure_can_write_position`),
mais rien ne permettait de le renseigner : aucun écran ne l'exposait, et
l'API ne validait même pas que la valeur envoyée désignait un compte référent
existant de la bonne édition.

Backend :
- `PATCH /editions/{id}/accounts/{account_id}` (nouveau) : change le `role`
  d'un compte, seulement entre LEAD et VOLUNTEER (lien magique dans les deux
  cas) — 422 `account.role_change_not_allowed` dès que COORDINATOR est
  impliqué (mot de passe en jeu, jamais touché ici). C'est ce qui permet de
  faire passer le compte d'un bénévole en référent.
- `POST`/`PATCH /positions` : `lead_account_id` vérifié — doit désigner un
  compte `Role.LEAD` de la même édition, sinon 422
  `position.lead_must_be_referent_account` (silencieusement accepté
  jusqu'ici, y compris un compte d'une autre édition ou du mauvais rôle).

Frontend (`ConfigVolunteers.tsx`) : nouvelle section « Référent des postes »
sur la fiche du bénévole, juste après l'accès de connexion — mêmes badges à
cocher que les vœux de poste/artiste. Cocher un poste crée ou met à niveau
(LEAD) l'accès de connexion du bénévole automatiquement ; décocher retire
seulement le poste, sans jamais rétrograder l'accès. Un bénévole dont le
compte est déjà COORDINATOR ne peut pas être désigné référent (il a déjà
accès à tout). Miroir complet côté mock (`accounts.update`,
`checkLeadAccount`) pour que la démonstration se comporte pareil.

Vérifié : 204 tests backend pertinents (14 nouveaux : 6 sur `PATCH accounts`,
8 sur la validation `lead_account_id`), ruff, alembic check ; typecheck/
lint/build/smoke frontend ; parcours complet testé en direct sur le jeu de
démo (création de compte, promotion LEAD, rejet COORDINATOR, affectation
référent) avant nettoyage des données de test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-15 23:19:56 +02:00
AdamThomasandClaude Sonnet 5 db11d9a3ba Page bénévole : vraie page responsive, plus une maquette de téléphone
C'est l'écran d'accueil réel d'un compte bénévole (`homePath`) — consulté sur
son propre téléphone la plupart du temps, mais la page rendait un cadre de
téléphone factice à taille fixe (376×772 px, fausse barre de statut) pensé
pour être visionné sur un écran d'ordinateur, plus une note de choix de
conception destinée à l'équipe, pas au bénévole. Sur un vrai téléphone,
c'était un téléphone dessiné à l'intérieur du vrai téléphone.

- Retiré le cadre (`PhoneFrame`) et la note de conception (`DesignNote`) —
  toujours utilisés par la page référent (`MobileLeadPage.tsx`, non touchée
  ici), donc le composant partagé reste en place.
- Le contenu (bandeau d'accueil + cartes de créneaux) devient la page
  elle-même : pleine largeur sur mobile, centrée avec une largeur de lecture
  confortable (max 640 px) et un peu plus d'air à partir de `sm:` pour un
  écran d'ordinateur. Mêmes repères de conception qu'avant (fonds opaques,
  cibles tactiles ≥ 52 px), déplacés en commentaire de code.
- `AppShell` : la barre d'onglets ne s'affiche plus quand il n'y a qu'un seul
  écran accessible (bénévole, référent) — elle n'a de sens que pour la
  coordination (3 écrans) ; simplifie l'en-tête sur les rôles qui n'ont nulle
  part où naviguer.

Vérifié : typecheck, lint, build, smoke frontend OK.

À noter : `MobileLeadPage.tsx` (référent) a exactement le même défaut
(maquette de téléphone + note de conception) — pas touchée ici, la demande
portait sur la vue bénévole.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-15 23:03:59 +02:00
AdamThomasandClaude Sonnet 5 9871785d89 Grille : aperçu des bénévoles affectés au survol d'une case
Chaque case ne montrait qu'un nombre (affecté/idéal) ; il fallait cliquer et
ouvrir le panneau pour savoir qui exactement. Une bulle au survol affiche
maintenant les noms des bénévoles déjà affectés à ce créneau, sans clic.

- Construit une table (créneau → noms) à partir des affectations et des
  bénévoles de l'édition (mêmes requêtes déjà utilisées par le panneau
  d'affectation, donc pas de coût réseau supplémentaire une fois en cache).
- Remplace l'ancien `title` natif (lent, sans style) par la même bulle que
  `HelpTip` (bordures franches, apparaît sous la case) ; garde le rappel
  affecté/idéal/minimum en tête de bulle, puis la liste triée des noms, ou
  le message « personne d'affecté » sinon.

Vérifié : typecheck, lint, build, smoke frontend OK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-15 22:54:31 +02:00
AdamThomasandClaude Sonnet 5 efa652fffb Panneau d'affectation : barre de recherche pour retrouver un bénévole
La liste des candidats se limitait aux 10 mieux classés par le score de
chance — un bénévole précis pouvait ne jamais y apparaître. Un champ de
recherche (nom/prénom, insensible aux accents/casse) permet maintenant de le
retrouver et de l'affecter directement, sans être limité au classement.

- Recherche vide : comportement inchangé (top 10 par score de chance).
- Recherche non vide : jusqu'à 20 bénévoles non affectés dont le nom
  correspond, triés par le même score ; message dédié si aucun ne correspond.
- `foldAccents` (accents/casse) déplacé de `ConfigVolunteers.tsx` vers
  `lib/planning.ts` pour être partagé avec le panneau d'affectation.
- `GrillePage.tsx` : `key={shift.shift_id}` sur `AssignmentPanel` pour que la
  recherche (et tout futur état local) reparte à zéro en changeant de
  créneau, plutôt que de persister sur le suivant.

Vérifié : typecheck, lint, build, smoke frontend OK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-15 22:43:10 +02:00
AdamThomasandClaude Sonnet 5 11bc8cbe36 Vœu de compagnon : traité comme réciproque par le solveur
Le vœu reste à sens unique en base (A choisit B sur sa fiche, B n'a rien à
faire) mais si A souhaite B, B doit désormais profiter aussi de la présence
de A — dans les deux sens : affecter A d'abord fait remonter B comme
candidat, et affecter B d'abord fait remonter A, même si B n'a rien demandé.

- Solveur (`loader.py`) : le vœu (A → B) alimente maintenant l'ensemble
  effectif des deux bénévoles (A et B), pas seulement celui du demandeur.
  Aucun changement dans `engine.py` : il ne fait que lire un ensemble déjà
  symétrisé.
- Mock (`buildEffectiveCompanions`) et classement par défaut
  (`buildEffectiveCompanionIds` dans `lib/planning.ts`, utilisé par
  `AssignmentPanel.tsx`) : même symétrisation, pour rester cohérents avec le
  vrai solveur.
- La fiche bénévole (vœux affichés/édités) n'est pas touchée : c'est le seul
  endroit qui reste à sens unique, par construction (la réciprocité n'est
  qu'une lecture, jamais une donnée enregistrée — sinon éditer sa propre
  liste risquerait d'effacer le vœu de quelqu'un d'autre).
- Nouveau test d'intégration : le bénévole qui n'a rien demandé profite quand
  même de la présence de celui qui l'a choisi.
- CLAUDE.md à jour.

Vérifié : 193 tests backend pertinents (dont le nouveau), ruff, alembic
check ; typecheck/lint/build/smoke frontend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-15 22:32:02 +02:00
182 changed files with 25169 additions and 2916 deletions
+6
View File
@@ -29,6 +29,12 @@ SMTP_FROM=Festival des bénévoles <no-reply@example.test>
# Alternative OVH si le 587/STARTTLS échoue : SMTP_PORT=465, SMTP_SSL=true,
# SMTP_STARTTLS=false
# Formulaire public d'inscription (/inscription). Vide en dev ⇒ vérification
# hCaptcha désactivée (voir app/forms/captcha.py) — aucun compte requis pour
# développer en local. Couple site/secret d'un même compte https://hcaptcha.com.
HCAPTCHA_SECRET_KEY=
HCAPTCHA_SITE_KEY=
# Frontend (service `frontend` du docker-compose).
# mock (défaut) : jeu de démonstration en mémoire, aucun backend requis.
# http : API réelle — renseigner alors VITE_EDITION_ID avec l'UUID
+99
View File
@@ -0,0 +1,99 @@
# CI/CD Gitea Actions : à chaque push sur `main`, build des images
# (registre de conteneurs intégré à Gitea) puis déploiement SSH sur le VPS.
#
# Nécessite un runner Gitea Actions (act_runner, exécuteur "docker") attaché
# au dépôt ou à l'organisation.
#
# Secrets à définir (Paramètres du dépôt → Actions → Secrets) :
# REGISTRY_TOKEN jeton d'accès (scope "write:package") pour pousser sur
# le registre de conteneurs de cette instance Gitea
# SSH_KEY clé privée SSH (format OpenSSH) pour se connecter au VPS
#
# Variables à définir (Paramètres du dépôt → Actions → Variables) :
# REGISTRY_HOST hôte du registre (ex. gitea.mon-domaine.fr)
# REGISTRY_NAMESPACE espace du registre (ex. <organisation>/<depot>)
# REGISTRY_USER utilisateur associé à REGISTRY_TOKEN
# SSH_HOST hôte du VPS
# SSH_USER utilisateur SSH
# DEPLOY_PATH dossier du projet sur le VPS (ex. /opt/benevoles)
# VITE_EDITION_ID UUID de l'édition de production, à renseigner APRÈS
# la toute première mise en service (voir deploy/README.md)
# VITE_HCAPTCHA_SITE_KEY clé de site hCaptcha (publique) — figée au build
# du frontend, voir app/api/forms.py et deploy/README.md.
# Vide ⇒ formulaire public sans vérification anti-robot.
name: ci-cd
on:
push:
branches: [main]
concurrency:
group: ci-cd-${{ gitea.ref }}
cancel-in-progress: false
jobs:
build-backend:
runs-on: ubuntu-latest
container: docker:27
steps:
- name: Installer git et node
run: apk add --no-cache git nodejs
- uses: actions/checkout@v4
- name: Connexion au registre
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ vars.REGISTRY_USER }}" --password-stdin
- name: Build & push
run: |
IMAGE="${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_NAMESPACE }}/backend"
docker build -t "$IMAGE:${{ gitea.sha }}" -t "$IMAGE:latest" backend
docker push "$IMAGE:${{ gitea.sha }}"
docker push "$IMAGE:latest"
build-frontend:
needs: build-backend # séquentiel pour ne pas saturer le CPU
runs-on: ubuntu-latest
container: docker:27
steps:
- name: Installer git et node
run: apk add --no-cache git nodejs
- uses: actions/checkout@v4
- name: Connexion au registre
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ vars.REGISTRY_USER }}" --password-stdin
- name: Build & push
run: |
IMAGE="${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_NAMESPACE }}/frontend"
docker build \
--build-arg VITE_API_MODE=http \
--build-arg VITE_EDITION_ID="${{ vars.VITE_EDITION_ID }}" \
--build-arg VITE_HCAPTCHA_SITE_KEY="${{ vars.VITE_HCAPTCHA_SITE_KEY }}" \
-f frontend/Dockerfile.prod \
-t "$IMAGE:${{ gitea.sha }}" -t "$IMAGE:latest" frontend
docker push "$IMAGE:${{ gitea.sha }}"
docker push "$IMAGE:latest"
deploy:
needs: [build-backend, build-frontend]
runs-on: ubuntu-latest
container: alpine:3.20
steps:
- name: Installer les outils
run: apk add --no-cache git nodejs openssh-client rsync
- uses: actions/checkout@v4
- name: Déployer par SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "${{ secrets.SSH_KEY }}" | tr -d '\r' > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "${{ vars.SSH_HOST }}" >> ~/.ssh/known_hosts
rsync -az -e "ssh -i ~/.ssh/id_ed25519" deploy/docker-compose.prod.yml "${{ vars.SSH_USER }}@${{ vars.SSH_HOST }}:${{ vars.DEPLOY_PATH }}/docker-compose.prod.yml"
ssh -i ~/.ssh/id_ed25519 "${{ vars.SSH_USER }}@${{ vars.SSH_HOST }}" sh -s <<EOF
set -e
cd "${{ vars.DEPLOY_PATH }}"
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ vars.REGISTRY_USER }}" --password-stdin
export BACKEND_IMAGE="${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_NAMESPACE }}/backend:${{ gitea.sha }}"
export FRONTEND_IMAGE="${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_NAMESPACE }}/frontend:${{ gitea.sha }}"
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml run --rm backend uv run alembic upgrade head
docker compose -f docker-compose.prod.yml up -d
docker image prune -f
EOF
+2
View File
@@ -23,3 +23,5 @@ dist/
# Claude Code (worktrees, agents locaux)
.claude/
TODO
-116
View File
@@ -1,116 +0,0 @@
# CI/CD : tests à chaque push, puis sur `main` seulement, build des images
# (registre de conteneurs GitLab) et déploiement SSH sur le VPS.
#
# Variables CI/CD à définir (Paramètres → CI/CD → Variables du projet) :
# SSH_HOST hôte du VPS
# SSH_USER utilisateur SSH
# SSH_KEY clé privée SSH (format OpenSSH), variable « protégée »
# + « masquée », type fichier ou variable multi-lignes
# DEPLOY_PATH dossier du projet sur le VPS (ex. /opt/benevoles)
# VITE_EDITION_ID UUID de l'édition de production, à renseigner APRÈS la
# toute première mise en service (voir deploy/README.md) —
# le frontend fige cette valeur au moment du build.
#
# `CI_REGISTRY`, `CI_REGISTRY_USER`, `CI_REGISTRY_PASSWORD` et
# `CI_REGISTRY_IMAGE` sont fournis automatiquement par GitLab.
stages:
- test
- build
- deploy
variables:
BACKEND_IMAGE: $CI_REGISTRY_IMAGE/backend
FRONTEND_IMAGE: $CI_REGISTRY_IMAGE/frontend
backend-test:
stage: test
image: python:3.12-slim
services:
- name: postgres:16-alpine
alias: postgres
variables:
POSTGRES_USER: benevoles
POSTGRES_PASSWORD: benevoles
POSTGRES_DB: benevoles_test
TEST_DATABASE_URL: postgresql+psycopg://benevoles:benevoles@postgres:5432/benevoles_test
DATABASE_URL: postgresql+psycopg://benevoles:benevoles@postgres:5432/benevoles_test
before_script:
- pip install --no-cache-dir uv
- cd backend
- uv sync
script:
- uv run ruff check .
- uv run alembic upgrade head
- uv run alembic check
- uv run pytest -q
frontend-test:
stage: test
image: node:20-alpine
before_script:
- cd frontend
- npm ci
script:
- npm run typecheck
- npm run lint
- npm run build
- npm run smoke
build-backend:
stage: build
image: docker:27
services:
- docker:27-dind
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- docker build -t "$BACKEND_IMAGE:$CI_COMMIT_SHORT_SHA" -t "$BACKEND_IMAGE:latest" backend
- docker push "$BACKEND_IMAGE:$CI_COMMIT_SHORT_SHA"
- docker push "$BACKEND_IMAGE:latest"
build-frontend:
stage: build
image: docker:27
services:
- docker:27-dind
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- >
docker build
--build-arg VITE_API_MODE=http
--build-arg VITE_EDITION_ID="$VITE_EDITION_ID"
-f frontend/Dockerfile.prod
-t "$FRONTEND_IMAGE:$CI_COMMIT_SHORT_SHA" -t "$FRONTEND_IMAGE:latest"
frontend
- docker push "$FRONTEND_IMAGE:$CI_COMMIT_SHORT_SHA"
- docker push "$FRONTEND_IMAGE:latest"
deploy:
stage: deploy
image: alpine:3.20
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
before_script:
- apk add --no-cache openssh-client rsync
- eval "$(ssh-agent -s)"
- echo "$SSH_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts
script:
- rsync -az deploy/docker-compose.prod.yml "$SSH_USER@$SSH_HOST:$DEPLOY_PATH/docker-compose.prod.yml"
- |
ssh "$SSH_USER@$SSH_HOST" sh -s <<EOF
set -e
cd "$DEPLOY_PATH"
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
export BACKEND_IMAGE="$BACKEND_IMAGE:$CI_COMMIT_SHORT_SHA"
export FRONTEND_IMAGE="$FRONTEND_IMAGE:$CI_COMMIT_SHORT_SHA"
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml run --rm backend uv run alembic upgrade head
docker compose -f docker-compose.prod.yml up -d
docker image prune -f
EOF
+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.
+737 -103
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -48,4 +48,4 @@ démarrage du conteneur PostgreSQL.
| `backend/` | API FastAPI, modèle SQLModel, migrations Alembic |
| `frontend/` | Interface React + Vite + TS + Tailwind (service `frontend`) |
| `deploy/` | Configuration de déploiement production (compose, Caddy, runbook) |
| `.gitlab-ci.yml` | Chaîne CI/CD (tests → build → SSH sur le VPS) |
| `.gitea/workflows/` | Chaîne CI/CD (tests → build → SSH sur le VPS) |
+9 -1
View File
@@ -12,7 +12,15 @@ ENV TZ=Europe/Paris \
UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
UV_PROJECT_ENVIRONMENT=/app/.venv \
PATH="/app/.venv/bin:$PATH"
PATH="/app/.venv/bin:$PATH" \
# Le runner CI a une bande passante limitée : à 50 téléchargements uv en
# parallèle (défaut), chacun n'obtient qu'une fraction du débit et finit
# par dépasser même un timeout généreux ("network timeout" sur de gros
# paquets comme sqlalchemy/reportlab). On réduit la concurrence pour que
# chaque téléchargement avance réellement, et on garde une marge large
# sur le délai par requête.
UV_CONCURRENT_DOWNLOADS=4 \
UV_HTTP_TIMEOUT=600
WORKDIR /app
+111 -3
View File
@@ -1,10 +1,20 @@
"""Helpers partagés par les routers CRUD."""
"""Helpers partagés par les routers CRUD.
`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`
(vœux saisis depuis le formulaire public) — un seul endroit pour la
validation « ces ids appartiennent bien à cette édition ». Idem pour
`read_photo` (photo d'un bénévole)."""
import io
import uuid
from fastapi import HTTPException
from fastapi import HTTPException, UploadFile
from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete
from sqlmodel import Session
from sqlmodel import Session, select
from app.models import Artist, Position, VolunteerArtist, VolunteerPositionWish, local_now
def owned_or_404(instance, edition_id: uuid.UUID):
@@ -23,3 +33,101 @@ def replace_links(
)
for row in pairs:
session.add(link_model(**row))
def resolve_owned_ids(
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID], code: str
) -> list[uuid.UUID]:
"""Ordre d'entrée conservé, doublons retirés ; 422 si un id est étranger."""
wanted = list(dict.fromkeys(ids))
if wanted:
found = set(
session.exec(
select(model.id).where(
model.edition_id == edition_id,
model.id.in_(wanted),
)
)
)
if len(found) != len(wanted):
raise HTTPException(status_code=422, detail={"code": code})
return wanted
def set_artists(session: Session, volunteer, artist_ids: list[uuid.UUID]) -> None:
ids = resolve_owned_ids(
session, Artist, volunteer.edition_id, artist_ids, "artist.unknown"
)
replace_links(
session,
VolunteerArtist,
filter_column=VolunteerArtist.volunteer_id,
filter_value=volunteer.id,
pairs=[{"volunteer_id": volunteer.id, "artist_id": aid} for aid in ids],
)
def set_wished_positions(
session: Session, volunteer, position_ids: list[uuid.UUID]
) -> None:
ids = resolve_owned_ids(
session, Position, volunteer.edition_id, position_ids, "position.unknown"
)
replace_links(
session,
VolunteerPositionWish,
filter_column=VolunteerPositionWish.volunteer_id,
filter_value=volunteer.id,
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)
+8 -5
View File
@@ -5,6 +5,7 @@ import datetime as dt
from sqlmodel import Session, select
from app.models import DayPart, Edition, Position, Shift
from app.validation.engine import day_boundary
def _dates(edition: Edition) -> list[dt.date]:
@@ -12,10 +13,11 @@ def _dates(edition: Edition) -> list[dt.date]:
return [edition.start_date + dt.timedelta(days=i) for i in range(span + 1)]
def _window(day: dt.date, part: DayPart) -> tuple[dt.datetime, dt.datetime]:
start = dt.datetime.combine(day, part.start_time)
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
start = dt.datetime.combine(actual_day, part.start_time)
end_day = (
day if part.end_time > part.start_time else day + dt.timedelta(days=1)
actual_day if part.end_time > part.start_time else actual_day + dt.timedelta(days=1)
)
return start, dt.datetime.combine(end_day, part.end_time)
@@ -28,6 +30,7 @@ def generate_missing_shifts(
day_parts = list(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
boundary = day_boundary(day_parts)
existing = {
(s.day_part_id, s.starts_at.date())
for s in session.exec(
@@ -37,9 +40,9 @@ def generate_missing_shifts(
created = 0
for day in _dates(edition):
for part in day_parts:
if (part.id, day) in existing:
start, end = _window(day, part, boundary)
if (part.id, start.date()) in existing:
continue
start, end = _window(day, part)
session.add(
Shift(
edition_id=edition.id,
+43 -1
View File
@@ -9,7 +9,7 @@ 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 AccountCreate, AccountRead
from app.api.schemas import AccountCreate, AccountRead, AccountUpdate
from app.auth.deps import require_coordinator
from app.auth.passwords import MIN_LENGTH, hash_password
from app.db import get_session
@@ -85,6 +85,48 @@ def list_accounts(
)
@router.patch("/{account_id}", response_model=AccountRead)
def update_account(
account_id: uuid.UUID,
payload: AccountUpdate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Account:
"""Seul le rôle change de main ici, et seulement entre LEAD et VOLUNTEER
(lien magique dans les deux cas) : devenir référent d'un poste (voir
`positions.py`) met à niveau le compte bénévole existant, sans toucher au
mot de passe d'un compte COORDINATION."""
account = owned_or_404(session.get(Account, account_id), edition.id)
if payload.role is not None and payload.role != account.role:
if Role.COORDINATOR in (account.role, payload.role):
raise HTTPException(
status_code=422, detail={"code": "account.role_change_not_allowed"}
)
account.role = payload.role
session.commit()
session.refresh(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)
def delete_account(
account_id: uuid.UUID,
+26 -1
View File
@@ -17,6 +17,7 @@ from app.api.schemas import (
AssignmentValidateRequest,
AssignmentValidateResult,
AssignmentWriteResult,
AttendanceUpdate,
)
from app.auth.deps import (
current_account_in_edition,
@@ -24,7 +25,7 @@ from app.auth.deps import (
require_staff,
)
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.loader import build_validation_input
@@ -206,6 +207,30 @@ def update_assignment(
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)
def delete_assignment(
assignment_id: uuid.UUID,
+54 -2
View File
@@ -25,10 +25,13 @@ from app.api.schemas import (
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.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.tokens import create_access_token, hash_token
from app.config import get_settings
from app.db import get_session
from app.email import render_magic_link, send
from app.email import edition_logo, render_magic_link, send
from app.models import Account, MagicLinkToken, local_now
router = APIRouter(tags=["auth"])
@@ -76,13 +79,24 @@ def login(
payload: LoginRequest,
session: Session = Depends(get_session),
) -> TokenResponse:
login_limiter.hit(f"{edition_id}:{payload.email.strip().lower()}")
account = _account_by_email(session, edition_id, payload.email)
if (
account is None
or account.password_hash is None
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()
logger.info("Connexion par mot de passe : compte %s (édition %s)", account.id, edition_id)
return _issue(account)
@@ -131,8 +145,21 @@ def request_magic_link(
"""Émet un lien magique si un compte **sans mot de passe** correspond.
Réponse identique dans tous les cas (pas de divulgation). L'email part en
tâche de fond (une erreur SMTP est journalisée, pas remontée)."""
magic_link_limiter.hit(f"{edition_id}:{payload.email.strip().lower()}")
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:
logger.info("Lien magique émis pour le compte %s (édition %s)", account.id, edition_id)
settings = get_settings()
raw = issue_magic_link_token(session, account)
session.commit()
@@ -140,7 +167,9 @@ def request_magic_link(
link=build_magic_link_url(raw),
minutes=settings.magic_link_ttl_minutes,
)
background.add_task(send, account.email, subject, body)
background.add_task(
send, account.email, subject, body, logo=edition_logo(session, edition_id)
)
if not settings.email_enabled:
# Aide au dev sans SMTP : le jeton (non nominatif) est journalisé.
logger.info(
@@ -157,6 +186,16 @@ def verify_magic_link(
payload: VerifyRequest,
session: Session = Depends(get_session),
) -> 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(
select(MagicLinkToken).where(
MagicLinkToken.token_hash == hash_token(payload.token.strip())
@@ -167,13 +206,26 @@ def verify_magic_link(
or token.used_at is not None
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()
account = session.get(Account, token.account_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()
token.used_at = local_now()
session.commit()
session.refresh(account)
logger.info("Connexion par lien magique : compte %s (édition %s)", account.id, edition_id)
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,
)
)
)
+434
View File
@@ -0,0 +1,434 @@
"""Planches de badges bénévoles : configuration du recto/verso (COORDINATION)
et génération du PDF.
Chaque face (recto, verso) combine une image de fond (la même pour tous les
bénévoles) et des champs positionnés (nom, poste(s) affectés, catégorie,
rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py` pour la
composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
`BadgeField`) pour le modèle.
"""
import datetime as dt
import io
import re
import uuid
from collections import defaultdict
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete
from sqlmodel import Session, select
from app.api.deps import get_edition
from app.api.schemas import (
BadgeFieldRead,
BadgeFieldsSet,
BadgeGenerateRequest,
BadgeSizeUpdate,
BadgeTemplateRead,
)
from app.auth.deps import require_coordinator
from app.badges.pdf import (
DEFAULT_BADGE_HEIGHT_MM,
DEFAULT_BADGE_WIDTH_MM,
BadgeFieldSpec,
BadgeSpec,
BadgeTable,
BadgeTableSection,
generate_badge_sheets_pdf,
image_size_mm,
)
from app.db import get_session
from app.i18n import t
from app.models import (
Account,
Assignment,
BadgeFace,
BadgeField,
BadgeFieldType,
BadgeTemplate,
DayPart,
Edition,
Position,
Role,
Shift,
Volunteer,
)
from app.validation.engine import day_boundary, festival_day
router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"])
_MAX_IMAGE_BYTES = 8 * 1024 * 1024
_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}
# --------------------------------------------------------------------------- #
# Configuration (recto/verso, champs) #
# --------------------------------------------------------------------------- #
def _get_or_create_template(session: Session, edition_id: uuid.UUID) -> BadgeTemplate:
template = session.exec(
select(BadgeTemplate).where(BadgeTemplate.edition_id == edition_id)
).first()
if template is None:
template = BadgeTemplate(edition_id=edition_id)
session.add(template)
session.commit()
session.refresh(template)
return template
def _fields(session: Session, edition_id: uuid.UUID) -> list[BadgeField]:
return list(
session.exec(
select(BadgeField)
.where(BadgeField.edition_id == edition_id)
# 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,
)
)
)
def _template_read(template: BadgeTemplate, fields: list[BadgeField]) -> BadgeTemplateRead:
return BadgeTemplateRead(
edition_id=template.edition_id,
has_front_image=template.front_image is not None,
has_back_image=template.back_image is not None,
badge_width_mm=template.front_image_width_mm or DEFAULT_BADGE_WIDTH_MM,
badge_height_mm=template.front_image_height_mm or DEFAULT_BADGE_HEIGHT_MM,
fields=fields,
)
@router.get("/template", response_model=BadgeTemplateRead)
def get_template(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> BadgeTemplateRead:
template = _get_or_create_template(session, edition.id)
return _template_read(template, _fields(session, edition.id))
async def _read_image(file: UploadFile) -> tuple[bytes, str]:
content_type = file.content_type or ""
if content_type not in _ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=422, detail={"code": "badge.image_type_not_supported"}
)
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail={"code": "badge.empty_image"})
if len(content) > _MAX_IMAGE_BYTES:
raise HTTPException(status_code=413, detail={"code": "badge.image_too_large"})
return content, content_type
def _pixel_size(content: bytes) -> tuple[int, int]:
try:
with Image.open(io.BytesIO(content)) as img:
return img.size
except UnidentifiedImageError as exc:
raise HTTPException(
status_code=422, detail={"code": "badge.image_unreadable"}
) from exc
@router.put("/template/front-image", response_model=BadgeTemplateRead)
async def set_front_image(
file: UploadFile = File(...),
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> BadgeTemplateRead:
"""Le format du badge (`front_image_width_mm`/`height_mm`) est dérivé de
cette image (voir `app/badges/pdf.py:image_size_mm`) : le badge prend
désormais la forme de l'image importée plutôt qu'un format fixe imposé."""
template = _get_or_create_template(session, edition.id)
content, content_type = await _read_image(file)
width_px, height_px = _pixel_size(content)
width_mm, height_mm = image_size_mm(width_px, height_px)
template.front_image = content
template.front_image_content_type = content_type
template.front_image_width_mm = width_mm
template.front_image_height_mm = height_mm
session.add(template)
session.commit()
return _template_read(template, _fields(session, edition.id))
@router.put("/template/back-image", response_model=BadgeTemplateRead)
async def set_back_image(
file: UploadFile = File(...),
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> BadgeTemplateRead:
template = _get_or_create_template(session, edition.id)
content, content_type = await _read_image(file)
template.back_image = content
template.back_image_content_type = content_type
session.add(template)
session.commit()
return _template_read(template, _fields(session, edition.id))
@router.get("/template/front-image")
def get_front_image(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
template = _get_or_create_template(session, edition.id)
if template.front_image is None:
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
return Response(
content=template.front_image,
media_type=template.front_image_content_type or "application/octet-stream",
)
@router.get("/template/back-image")
def get_back_image(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
template = _get_or_create_template(session, edition.id)
if template.back_image is None:
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
return Response(
content=template.back_image,
media_type=template.back_image_content_type or "application/octet-stream",
)
@router.put("/template/fields", response_model=list[BadgeFieldRead])
def set_fields(
payload: BadgeFieldsSet,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> list[BadgeField]:
# 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:
if (f.face, f.field_type) in seen:
raise HTTPException(
status_code=422,
detail={
"code": "badge.duplicate_field_type",
"params": {"field_type": 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))
for f in payload.fields:
session.add(BadgeField(edition_id=edition.id, **f.model_dump()))
session.commit()
return _fields(session, edition.id)
@router.put("/template/size", response_model=BadgeTemplateRead)
def set_size(
payload: BadgeSizeUpdate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> BadgeTemplateRead:
"""Redimensionne le badge indépendamment de l'image (ex. une image
importée à basse résolution donne un format minuscule à 300 dpi
supposés — l'utilisateur l'agrandit ici). Écrase la taille déduite de
l'image ; ré-importer l'image la recalcule à nouveau depuis ses pixels
(`set_front_image`)."""
if payload.badge_width_mm <= 0 or payload.badge_height_mm <= 0:
raise HTTPException(status_code=422, detail={"code": "badge.invalid_size"})
template = _get_or_create_template(session, edition.id)
template.front_image_width_mm = payload.badge_width_mm
template.front_image_height_mm = payload.badge_height_mm
session.add(template)
session.commit()
return _template_read(template, _fields(session, edition.id))
# --------------------------------------------------------------------------- #
# Génération du PDF #
# --------------------------------------------------------------------------- #
AssignmentRow = tuple[Position, Shift]
def _role_label(volunteer_id: uuid.UUID, lead_volunteer_ids: set[uuid.UUID]) -> str:
if volunteer_id in lead_volunteer_ids:
return t("badge.role.referent")
return t("badge.role.volunteer")
def _category_label(category) -> str:
return t(f"position.category.{category.value.lower()}")
def _day_label(day: dt.date) -> str:
"""En-tête d'un jour dans le tableau des créneaux : « Samedi 11 »."""
return t("badge.table.day_label", weekday=t(f"badge.weekday.{day.weekday()}"), day=day.day)
def _positions_table(assignments: list[AssignmentRow], boundary: dt.time) -> BadgeTable | None:
"""Créneaux affectés regroupés par jour de festival (une soirée qui
passe minuit reste sur le jour où elle a commencé — `festival_day`) :
le jour en en-tête, puis une ligne horaire / poste par créneau, dans
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(
field: BadgeField,
volunteer: Volunteer,
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]],
lead_volunteer_ids: set[uuid.UUID],
) -> str:
"""Texte d'un champ — `POSITIONS` n'en a pas : c'est un tableau
(`_positions_table`)."""
if field.field_type == BadgeFieldType.FULL_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, [])
if field.field_type == BadgeFieldType.CATEGORY:
categories = sorted(
{_category_label(position.category) for position, _shift in assignments}
)
return ", ".join(categories)
if field.field_type == BadgeFieldType.ROLE:
return _role_label(volunteer.id, lead_volunteer_ids)
return ""
@router.post("/generate")
def generate_badges(
payload: BadgeGenerateRequest,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
template = _get_or_create_template(session, edition.id)
fields = _fields(session, edition.id)
if not fields:
raise HTTPException(status_code=422, detail={"code": "badge.no_fields"})
query = select(Volunteer).where(Volunteer.edition_id == edition.id)
if payload.volunteer_ids is not None:
query = query.where(Volunteer.id.in_(payload.volunteer_ids))
volunteers = list(session.exec(query.order_by(Volunteer.last_name, Volunteer.first_name)))
if not volunteers:
raise HTTPException(status_code=422, detail={"code": "badge.no_volunteers"})
volunteer_ids = [v.id for v in volunteers]
rows = session.exec(
select(Assignment.volunteer_id, Position, Shift)
.join(Shift, Shift.id == Assignment.shift_id)
.join(Position, Position.id == Shift.position_id)
.where(Assignment.volunteer_id.in_(volunteer_ids))
.order_by(Shift.starts_at)
).all()
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]] = defaultdict(list)
for volunteer_id, position, shift in rows:
assignments_by_volunteer[volunteer_id].append((position, shift))
boundary = day_boundary(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
lead_volunteer_ids = {
account.volunteer_id
for account in session.exec(
select(Account).where(
Account.edition_id == edition.id,
Account.role == Role.LEAD,
Account.volunteer_id.in_(volunteer_ids),
)
)
if account.volunteer_id is not None
}
def spec(field: BadgeField, volunteer: Volunteer) -> BadgeFieldSpec:
return BadgeFieldSpec(
text=_field_text(field, volunteer, assignments_by_volunteer, lead_volunteer_ids),
x_mm=field.x_mm,
y_mm=field.y_mm,
width_mm=field.width_mm,
height_mm=field.height_mm,
font_size=field.font_size,
align=field.align.value,
bold=field.bold,
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,
)
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 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(
badges,
template.front_image,
template.back_image,
badge_width_mm=template.front_image_width_mm or DEFAULT_BADGE_WIDTH_MM,
badge_height_mm=template.front_image_height_mm or DEFAULT_BADGE_HEIGHT_MM,
)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="badges.pdf"'},
)
+5 -1
View File
@@ -3,6 +3,7 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from app.api._common import set_message
from app.api.deps import get_edition
from app.api.schemas import EditionRead, EditionUpdate
from app.auth.deps import current_account_in_edition, require_coordinator
@@ -27,7 +28,10 @@ def update_edition(
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> 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)
if edition.end_date < edition.start_date:
raise HTTPException(
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -1,5 +1,7 @@
"""Endpoints d'import : analyse puis exécution."""
import logging
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from pydantic import ValidationError
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.models import Account, Edition
logger = logging.getLogger("app.importing")
router = APIRouter(prefix="/editions/{edition_id}/import", tags=["import"])
_PREVIEW_ROWS = 5
@@ -107,9 +111,19 @@ async def execute(
},
)
return run_import(
report = run_import(
session=session,
edition=edition,
table=table,
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)."""
import datetime as dt
import logging
import uuid
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 app.api.deps import get_edition
from app.api.schemas import (
PlanningNotificationRequest,
PlanningNotificationResult,
TestEmailRequest,
TestEmailResult,
)
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.email import 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.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(
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:
"""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(
select(Account)
.where(Account.edition_id == edition.id)
.where(Account.volunteer_id == volunteer.id)
.where(Account.password_hash.is_(None))
).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(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)
@@ -57,64 +299,51 @@ def send_planning_notification(
stmt = stmt.where(Volunteer.id.in_(payload.volunteer_ids))
volunteers = list(session.exec(stmt.order_by(Volunteer.last_name)))
positions = {
p.id: p
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)
ctx = _load_context(session, edition)
logo = edition_logo(session, edition.id)
items: list[tuple[str, str, str]] = []
messages: list[EmailMessage] = []
recipients: list[uuid.UUID] = []
skipped_no_email = 0
for volunteer in volunteers:
if not volunteer.email:
skipped_no_email += 1
continue
shifts = sorted(
shifts_by_volunteer.get(volunteer.id, []),
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(
composed = _planning_email(
ctx=ctx,
subject_template=subject_template,
body_template=body_template,
first_name=volunteer.first_name,
last_name=volunteer.last_name,
shift_count=len(shifts),
hours=round(minutes / 60, 1),
shifts_text=shifts_text,
shifts=ctx.shifts_by_volunteer.get(volunteer.id, []),
login_link=(
_login_link(session, edition, volunteer)
if wants_login_link
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)
session.commit() # persiste les éventuels MagicLinkToken émis
background.add_task(send_many, items)
session.commit() # persiste les comptes créés pour les liens de connexion
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(
queued=len(items),
queued=len(messages),
skipped_no_email=skipped_no_email,
recipients=recipients,
)
+168
View File
@@ -0,0 +1,168 @@
"""Feuilles de poste (PDF) : une page par poste et par jour, listant les
créneaux du jour avec les bénévoles affectés et leur téléphone — à imprimer
et afficher sur place. Complément des badges (`app/api/badges.py`) : un
badge identifie un bénévole, une feuille identifie un poste pour la
journée. Voir `app/sheets/pdf.py` pour la composition du PDF.
"""
import datetime as dt
import uuid
from collections import defaultdict
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.orm import aliased
from sqlmodel import Session, select
from app.api.deps import get_edition
from app.api.schemas import PositionSheetsRequest
from app.auth.deps import require_coordinator
from app.db import get_session
from app.i18n import t
from app.models import Account, Artist, Assignment, DayPart, Edition, Position, Shift, Volunteer
from app.sheets.pdf import (
PositionDaySheet,
SheetArtist,
SheetFooter,
SheetShift,
SheetVolunteer,
generate_position_sheets_pdf,
)
from app.validation.engine import day_boundary, festival_day
router = APIRouter(
prefix="/editions/{edition_id}/position-sheets", tags=["position-sheets"]
)
@router.post("/generate")
def generate_position_sheets(
payload: PositionSheetsRequest,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
query = select(Position).where(Position.edition_id == edition.id)
if payload.position_ids is not None:
query = query.where(Position.id.in_(payload.position_ids))
positions = list(session.exec(query.order_by(Position.name)))
if not positions:
raise HTTPException(
status_code=422, detail={"code": "position_sheets.no_positions"}
)
position_ids = [p.id for p in positions]
shifts = list(
session.exec(
select(Shift)
.where(Shift.position_id.in_(position_ids))
.order_by(Shift.starts_at)
)
)
shift_ids = [s.id for s in shifts]
volunteers_by_shift: dict[uuid.UUID, list[Volunteer]] = defaultdict(list)
if shift_ids:
rows = session.exec(
select(Assignment.shift_id, Volunteer)
.join(Volunteer, Volunteer.id == Assignment.volunteer_id)
.where(Assignment.shift_id.in_(shift_ids))
).all()
for shift_id, volunteer in rows:
volunteers_by_shift[shift_id].append(volunteer)
for vs in volunteers_by_shift.values():
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,
# jamais rempli) n'apparaît pas — évite des pages « personne affecté »
# pour des créneaux qui ne représentent aucun besoin.
shifts_by_position_day: dict[tuple[uuid.UUID, dt.date], list[Shift]] = defaultdict(list)
for shift in shifts:
undefined_need = shift.min_staff == 0 and shift.ideal_staff == 0
if undefined_need and not volunteers_by_shift.get(shift.id):
continue
day = festival_day(shift.starts_at, boundary)
shifts_by_position_day[(shift.position_id, day)].append(shift)
Referent = aliased(Volunteer)
referent_rows = session.exec(
select(Position.id, Referent)
.join(Account, Account.id == Position.lead_account_id, isouter=True)
.join(Referent, Referent.id == Account.volunteer_id, isouter=True)
.where(Position.id.in_(position_ids))
).all()
referent_by_position: dict[uuid.UUID, Volunteer] = {
pid: referent for pid, referent in referent_rows if referent is not None
}
# Programmation du jour, tous postes confondus — voir `SheetArtist`.
artists_by_date: dict[dt.date, list[Artist]] = defaultdict(list)
for artist in session.exec(
select(Artist)
.where(Artist.edition_id == edition.id)
.order_by(Artist.start_time)
):
artists_by_date[artist.performance_date].append(artist)
positions_by_id = {p.id: p for p in positions}
def _sheet(position_id: uuid.UUID, date: dt.date, day_shifts: list[Shift]) -> PositionDaySheet:
referent = referent_by_position.get(position_id)
return PositionDaySheet(
position_name=positions_by_id[position_id].name,
position_location=positions_by_id[position_id].location,
date=date,
referent_name=f"{referent.first_name} {referent.last_name}" if referent else None,
referent_phone=referent.phone if referent else None,
artists=tuple(
SheetArtist(name=a.name, start_time=a.start_time, end_time=a.end_time)
for a in artists_by_date.get(date, [])
),
shifts=tuple(
SheetShift(
starts_at=s.starts_at,
ends_at=s.ends_at,
min_staff=s.min_staff,
ideal_staff=s.ideal_staff,
volunteers=tuple(
SheetVolunteer(
name=f"{v.first_name} {v.last_name}", phone=v.phone, photo=v.photo
)
for v in volunteers_by_shift.get(s.id, [])
),
)
for s in sorted(day_shifts, key=lambda s: s.starts_at)
),
)
sheets = [
_sheet(position_id, date, day_shifts)
for (position_id, date), day_shifts in sorted(
shifts_by_position_day.items(),
key=lambda item: (positions_by_id[item[0][0]].name, item[0][1]),
)
]
if not sheets:
raise HTTPException(
status_code=422, detail={"code": "position_sheets.no_shifts"}
)
# 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(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="feuilles-de-poste.pdf"'},
)
+33 -8
View File
@@ -1,11 +1,16 @@
"""CRUD des postes."""
"""CRUD des postes.
`lead_account_id` (référent) ne peut désigner qu'un compte `Role.LEAD` de la
même édition — voir `_check_lead_account`. C'est le seul moyen de donner à un
bénévole une vue référent (`/mobile-referent`) à la connexion ; voir
`api/accounts.py:update_account` pour le faire passer de VOLUNTEER à LEAD."""
import uuid
from fastapi import APIRouter, Depends, Response, status
from fastapi import APIRouter, Depends, HTTPException, Response, status
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.deps import get_edition
from app.api.schemas import (
@@ -21,13 +26,28 @@ from app.auth.deps import (
require_staff,
)
from app.db import get_session
from app.models import Account, Edition, Position
from app.models import Account, Edition, Position, Role
router = APIRouter(
prefix="/editions/{edition_id}/positions", tags=["positions"]
)
def _check_lead_account(
session: Session, edition: Edition, lead_account_id: uuid.UUID | None
) -> None:
"""`lead_account_id` doit désigner un compte RÉFÉRENT (`Role.LEAD`) de
cette même édition — sinon le poste se retrouve sans référent utilisable
(cf. `ensure_can_write_position`, `auth/deps.py`)."""
if lead_account_id is None:
return
lead = session.get(Account, lead_account_id)
if lead is None or lead.edition_id != edition.id or lead.role is not Role.LEAD:
raise HTTPException(
status_code=422, detail={"code": "position.lead_must_be_referent_account"}
)
@router.post("", response_model=PositionRead, status_code=status.HTTP_201_CREATED)
def create_position(
payload: PositionCreate,
@@ -35,11 +55,12 @@ def create_position(
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Position:
_check_lead_account(session, edition, payload.lead_account_id)
position = Position(edition_id=edition.id, **payload.model_dump())
session.add(position)
session.flush()
# Un créneau à besoin 0/0 par jour et par créneau type de l'édition.
generate_missing_shifts(session, edition, position)
# Pas de créneaux générés automatiquement : la coordination choisit
# elle-même ceux dont elle a besoin (créés à la main, ou en repartant des
# créneaux types via `generate_shifts` ci-dessous).
session.commit()
session.refresh(position)
return position
@@ -80,7 +101,11 @@ def update_position(
) -> Position:
position = owned_or_404(session.get(Position, position_id), edition.id)
ensure_can_write_position(account, position)
for field, value in payload.model_dump(exclude_unset=True).items():
changes = payload.model_dump(exclude_unset=True)
_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():
setattr(position, field, value)
session.commit()
session.refresh(position)
+483 -29
View File
@@ -7,6 +7,13 @@ from pydantic import BaseModel, ConfigDict, Field
from app.models import (
AssignmentSource,
AttendanceStatus,
BadgeAlign,
BadgeFace,
BadgeFieldType,
BadgePhotoShape,
FeePaymentStatus,
FormFieldType,
PositionCategory,
Role,
)
@@ -29,23 +36,34 @@ class EditionRead(ApiModel):
start_date: dt.date
end_date: dt.date
retention_days: int | None
volunteer_message: str | None
volunteer_message_updated_at: dt.datetime | None
created_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):
name: str | None = None
year: int | None = None
start_date: dt.date | None = None
end_date: dt.date | 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 #
# --------------------------------------------------------------------------- #
class VolunteerCreate(ApiModel):
first_name: str
last_name: str
# Facultatifs à l'ajout manuel par la coordination (fiche complétée plus
# tard) ; le formulaire public et l'import les exigent toujours.
first_name: str = ""
last_name: str = ""
email: str | None = None
phone: str | None = None
comment: str | None = None
@@ -67,6 +85,16 @@ class VolunteerUpdate(ApiModel):
artist_ids: list[uuid.UUID] | None = None
wished_position_ids: list[uuid.UUID] | None = None
wished_companion_ids: list[uuid.UUID] | None = None
# Issus du formulaire public (`app/api/forms.py`), éditables ensuite
# depuis la fiche — voir CLAUDE.md § 2 (Volunteer).
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
willing_to_pay_fee: bool | None = None
class VolunteerRead(ApiModel):
@@ -80,10 +108,56 @@ class VolunteerRead(ApiModel):
artist_ids: list[uuid.UUID]
wished_position_ids: list[uuid.UUID]
wished_companion_ids: list[uuid.UUID]
has_photo: bool
companion_wish_note: str | None
badge_text: str | None
badge_count: int
self_declared_available: bool
willing_to_pay_fee: bool
# Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) —
# `fee_payment_label`/`fee_amount` : copie figée du type de paiement
# choisi (pas la config actuelle, qui peut avoir changé depuis).
# `fee_payment_status` reste `None` tant qu'aucun checkout n'a jamais été
# créé pour ce bénévole.
fee_payment_label: str | None
fee_amount: float | None
fee_payment_status: FeePaymentStatus | 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
# type de paiement « mécénat » (voir `FormPaymentOption.requires_mecenat_info`).
mecenat_address: str | None
mecenat_city: str | None
mecenat_postal_code: str | None
mecenat_country: str | None
created_at: dt.datetime
updated_at: dt.datetime
class VolunteerPlanningEntry(ApiModel):
"""Un créneau affecté, enrichi (poste, lieu, référent à contacter) — pour
l'écran bénévole (`GET .../volunteers/{id}/planning`), sans jamais
exposer la liste complète des postes/créneaux de l'édition à un compte
VOLUNTEER (lecture restreinte à sa propre fiche, cf. § 2 matrice)."""
assignment_id: uuid.UUID
shift_id: uuid.UUID
starts_at: dt.datetime
ends_at: dt.datetime
position_name: str
position_location: str | None
# None si le poste n'a pas (encore) de référent, ou si son compte n'est
# pas rattaché à une fiche bénévole (pas de téléphone à donner alors).
referent_name: str | None
referent_phone: str | None
# Message du poste (voir `Position.message`).
position_message: str | None
position_message_updated_at: dt.datetime | None
# --------------------------------------------------------------------------- #
# Artist (programmation) #
# --------------------------------------------------------------------------- #
@@ -128,6 +202,8 @@ class PositionUpdate(ApiModel):
location: str | None = None
category: PositionCategory | 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):
@@ -137,6 +213,8 @@ class PositionRead(ApiModel):
location: str | None
category: PositionCategory
lead_account_id: uuid.UUID | None
message: str | None
message_updated_at: dt.datetime | None
created_at: dt.datetime
updated_at: dt.datetime
@@ -208,33 +286,6 @@ class DayPartUpdate(ApiModel):
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 #
# --------------------------------------------------------------------------- #
@@ -257,10 +308,17 @@ class AssignmentRead(ApiModel):
shift_id: uuid.UUID
source: AssignmentSource
locked: bool
attendance: AttendanceStatus | None
attendance_marked_at: dt.datetime | None
created_at: dt.datetime
updated_at: dt.datetime
class AttendanceUpdate(ApiModel):
# `None` = annuler le pointage.
attendance: AttendanceStatus | None
class AssignmentWriteResult(ApiModel):
assignment: AssignmentRead
warnings: list[Violation] = Field(default_factory=list)
@@ -316,6 +374,77 @@ class MatrixResponse(ApiModel):
positions: list[MatrixPosition]
# --------------------------------------------------------------------------- #
# Badges (planches PDF) #
# --------------------------------------------------------------------------- #
class BadgeFieldIn(ApiModel):
face: BadgeFace = BadgeFace.FRONT
field_type: BadgeFieldType
x_mm: float
y_mm: float
width_mm: float
height_mm: float
font_size: float = 10.0
align: BadgeAlign = BadgeAlign.LEFT
bold: bool = False
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):
"""Remplace l'intégralité des champs du recto et du verso — au plus un
par (`face`, `field_type`) (décocher un champ dans l'éditeur retire sa
ligne)."""
fields: list[BadgeFieldIn] = Field(default_factory=list)
class BadgeFieldRead(BadgeFieldIn):
id: uuid.UUID
edition_id: uuid.UUID
class BadgeTemplateRead(ApiModel):
edition_id: uuid.UUID
has_front_image: bool
has_back_image: bool
# Dérivée de l'image du recto (voir `app/badges/pdf.py:image_size_mm`) ;
# format CR80 par défaut tant qu'aucune image n'est importée.
badge_width_mm: float
badge_height_mm: float
fields: list[BadgeFieldRead]
class BadgeSizeUpdate(ApiModel):
"""Ajuste la taille du badge indépendamment d'un nouvel import d'image —
ex. agrandir proportionnellement une image importée trop petite (voir
`app/api/badges.py:set_size`). Écrase la taille déduite de l'image."""
badge_width_mm: float
badge_height_mm: float
class BadgeGenerateRequest(ApiModel):
# None/absent = tous les bénévoles de l'édition.
volunteer_ids: list[uuid.UUID] | None = None
# --------------------------------------------------------------------------- #
# Feuilles de poste (PDF) #
# --------------------------------------------------------------------------- #
class PositionSheetsRequest(ApiModel):
# None/absent = tous les postes de l'édition.
position_ids: list[uuid.UUID] | None = None
# --------------------------------------------------------------------------- #
# Authentification et comptes #
# --------------------------------------------------------------------------- #
@@ -363,6 +492,12 @@ class AccountCreate(ApiModel):
password: str | None = None
class AccountUpdate(ApiModel):
# Seul le rôle est modifiable, et seulement entre LEAD et VOLUNTEER (pas
# de mot de passe en jeu) — voir `api/accounts.py:update_account`.
role: Role | None = None
# --------------------------------------------------------------------------- #
# Notifications par email #
# --------------------------------------------------------------------------- #
@@ -379,3 +514,322 @@ class PlanningNotificationResult(ApiModel):
queued: int
skipped_no_email: int
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 #
# --------------------------------------------------------------------------- #
class FormFieldsSet(ApiModel):
"""Remplace l'intégralité des champs activés du formulaire — nom/prénom
toujours collectés, pas dans cette liste (voir `FormFieldType`)."""
fields: list[FormFieldType] = Field(default_factory=list)
class FormTemplateRead(ApiModel):
edition_id: uuid.UUID
is_open: bool
fields: list[FormFieldType]
has_logo: bool
description: str | None
sumup_merchant_code: str | None
# `sumup_api_key` (secret) n'est jamais renvoyé — seulement s'il est
# défini, comme `has_logo` pour le logo.
sumup_api_key_set: bool
# Secret nécessaire pour atteindre le formulaire public
# (`/inscription/<access_token>`, voir CLAUDE.md § 2, `FormTemplate`) —
# exposé seulement ici (COORDINATION), jamais par `PublicFormConfig`.
access_token: str
class FormTemplateUpdate(ApiModel):
"""`None`/absent = inchangé pour chaque champ (`exclude_unset`) — permet
de changer `is_open` sans toucher `description`/`sumup_*`, etc. Chaîne
vide sur `sumup_api_key` = efface le secret enregistré."""
is_open: bool | None = None
description: str | None = None
sumup_merchant_code: str | None = None
sumup_api_key: str | None = None
class FormPaymentOptionRead(ApiModel):
id: uuid.UUID
edition_id: uuid.UUID
label: str
amount: float
order: int
requires_mecenat_info: bool
free_amount: bool
created_at: dt.datetime
updated_at: dt.datetime
class FormPaymentOptionCreate(ApiModel):
label: str
amount: float
order: int = 0
requires_mecenat_info: bool = False
free_amount: bool = False
class FormPaymentOptionUpdate(ApiModel):
label: str | None = None
amount: float | None = None
order: int | None = None
requires_mecenat_info: bool | None = None
free_amount: bool | None = None
class PublicPositionOption(ApiModel):
id: uuid.UUID
name: str
class PublicArtistOption(ApiModel):
id: uuid.UUID
name: str
performance_date: dt.date
class PublicPaymentOption(ApiModel):
"""Type de paiement proposé au bénévole sur le formulaire public — un
montant n'est pas un secret, contrairement aux identifiants SumUp."""
id: uuid.UUID
label: str
# Montant fixe, ou minimum si `free_amount`.
amount: float
requires_mecenat_info: bool
free_amount: bool
class PublicFormConfig(ApiModel):
"""Tout ce qu'il faut pour afficher le formulaire public — jamais plus :
ni les bénévoles déjà inscrits, ni les créneaux/affectations de
l'édition (voir `app/api/forms.py:get_public_form`)."""
is_open: bool
fields: list[FormFieldType]
positions: list[PublicPositionOption]
artists: list[PublicArtistOption]
has_logo: bool
description: str | None
# `True` si l'édition a configuré SumUp (identifiants marchand + au
# moins un type de paiement) — utilisé par le front-end pour afficher le
# choix de paiement près de `WILLING_TO_PAY_FEE`, sans jamais exposer les
# identifiants eux-mêmes.
sumup_enabled: bool
payment_options: list[PublicPaymentOption]
class FormSubmissionResult(ApiModel):
"""Trois formes possibles, selon le choix de paiement (voir
`app/api/forms.py:submit_public_form`) :
- aucun paiement en ligne choisi (case désactivée, ou « je paierai sur
place ») : `volunteer_id` seul, le bénévole est déjà en base ;
- paiement en ligne, type « mécénat » : `pending_token` +
`requires_mecenat_info=True`, rien en base encore — direction l'étape
d'adresse (`submit_mecenat_info`) ;
- paiement en ligne, type simple : `pending_token` + `sumup_checkout_url`,
direction la page de paiement SumUp. Toujours rien en base tant que le
paiement n'est pas confirmé (voir `PendingFormSubmission`)."""
volunteer_id: uuid.UUID | None = None
pending_token: uuid.UUID | None = None
requires_mecenat_info: bool = False
sumup_checkout_url: str | None = None
class MecenatInfoSubmit(ApiModel):
"""Étape d'adresse postale pour un type de paiement « mécénat »
(`POST .../form/public/pending/{token}/mecenat-info`) — tous obligatoires,
nécessaires à l'édition d'un reçu fiscal (cerfa)."""
address: str
city: str
postal_code: str
country: str
class PendingSubmissionStatus(ApiModel):
"""Réponse de `GET .../form/public/pending/{token}` — interrogée en
direct par la page de confirmation jusqu'à ce que le statut ne soit plus
`PENDING`. Relit toujours l'état réel auprès de SumUp (jamais seulement
la dernière valeur connue), comme le webhook."""
status: FeePaymentStatus
label: str
amount: float
volunteer_id: uuid.UUID | None = None
class SumUpWebhookPayload(ApiModel):
"""Corps envoyé par SumUp à `return_url` — voir la doc SumUp sur les
webhooks. Le seul événement existant à ce jour."""
event_type: 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
bénévoles remontés en tête de liste."""
import logging
import time
import uuid
from fastapi import APIRouter, Depends
@@ -14,6 +16,8 @@ from app.db import get_session
from app.models import Account, Edition
from app.solver import build_solver_input, solve
logger = logging.getLogger("app.solver")
router = APIRouter(prefix="/editions/{edition_id}/solver", tags=["solver"])
@@ -23,8 +27,20 @@ def run_solver(
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> SolverRunResult:
started = time.monotonic()
data = build_solver_input(session=session, edition=edition)
loaded = time.monotonic()
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(
suggestions=[
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),
)
+230 -66
View File
@@ -5,24 +5,42 @@ L'email n'est pas unique : plusieurs bénévoles peuvent en partager un même
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 sqlmodel import Session, select
from app.api._common import owned_or_404, replace_links
from app.api._common import (
owned_or_404,
read_photo,
replace_links,
resolve_owned_ids,
set_artists,
set_wished_positions,
)
from app.api.deps import get_edition
from app.api.schemas import VolunteerCreate, VolunteerRead, VolunteerUpdate
from app.api.schemas import (
VolunteerCreate,
VolunteerPlanningEntry,
VolunteerRead,
VolunteerUpdate,
)
from app.auth.deps import (
current_account_in_edition,
require_coordinator,
require_staff,
)
from app.db import get_session
from app.exporting import generate_volunteers_xlsx
from app.mecenat import assign_receipt_number, generate_mecenat_receipts_pdf, receipt_for
from app.models import (
Account,
Artist,
Assignment,
Edition,
FeePaymentStatus,
Position,
Role,
Shift,
Volunteer,
VolunteerArtist,
VolunteerCompanionWish,
@@ -85,74 +103,20 @@ def _companion_ids(session: Session, volunteer_id: uuid.UUID) -> list[uuid.UUID]
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
return VolunteerRead(
**volunteer.model_dump(),
**volunteer.model_dump(
exclude={"photo", "photo_content_type", "fee_checkout_id"}
),
artist_ids=_artist_ids(session, volunteer.id),
wished_position_ids=_wished_position_ids(session, volunteer.id),
wished_companion_ids=_companion_ids(session, volunteer.id),
)
def _resolve_owned_ids(
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID], code: str
) -> list[uuid.UUID]:
"""Ordre d'entrée conservé, doublons retirés ; 422 si un id est étranger."""
wanted = list(dict.fromkeys(ids))
if wanted:
found = set(
session.exec(
select(model.id).where(
model.edition_id == edition_id,
model.id.in_(wanted),
)
)
)
if len(found) != len(wanted):
raise HTTPException(status_code=422, detail={"code": code})
return wanted
def _set_artists(
session: Session, volunteer: Volunteer, artist_ids: list[uuid.UUID]
) -> None:
ids = _resolve_owned_ids(
session, Artist, volunteer.edition_id, artist_ids, "artist.unknown"
)
replace_links(
session,
VolunteerArtist,
filter_column=VolunteerArtist.volunteer_id,
filter_value=volunteer.id,
pairs=[
{"volunteer_id": volunteer.id, "artist_id": aid} for aid in ids
],
)
def _set_wished_positions(
session: Session, volunteer: Volunteer, position_ids: list[uuid.UUID]
) -> None:
ids = _resolve_owned_ids(
session,
Position,
volunteer.edition_id,
position_ids,
"position.unknown",
)
replace_links(
session,
VolunteerPositionWish,
filter_column=VolunteerPositionWish.volunteer_id,
filter_value=volunteer.id,
pairs=[
{"volunteer_id": volunteer.id, "position_id": pid} for pid in ids
],
has_photo=volunteer.photo is not None,
)
def _set_companions(
session: Session, volunteer: Volunteer, companion_ids: list[uuid.UUID]
) -> None:
ids = _resolve_owned_ids(
ids = resolve_owned_ids(
session, Volunteer, volunteer.edition_id, companion_ids, "volunteer.unknown"
)
if volunteer.id in ids:
@@ -177,6 +141,8 @@ def create_volunteer(
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> VolunteerRead:
payload.first_name = payload.first_name.title()
payload.last_name = payload.last_name.title()
volunteer = Volunteer(
edition_id=edition.id,
**payload.model_dump(
@@ -185,8 +151,8 @@ def create_volunteer(
)
session.add(volunteer)
session.flush()
_set_artists(session, volunteer, payload.artist_ids)
_set_wished_positions(session, volunteer, payload.wished_position_ids)
set_artists(session, volunteer, payload.artist_ids)
set_wished_positions(session, volunteer, payload.wished_position_ids)
_set_companions(session, volunteer, payload.wished_companion_ids)
session.commit()
session.refresh(volunteer)
@@ -207,6 +173,100 @@ def list_volunteers(
return [_read(session, v) for v in volunteers]
@router.get("/export")
def export_volunteers(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
"""Export Excel de la liste des bénévoles — vœux (postes, artistes,
compagnons) résolus en noms lisibles, pas en UUID. Déclaré avant
`/{volunteer_id}` : un chemin statique doit être enregistré avant le
chemin dynamique qui le suivrait sinon FastAPI essaierait de parser
« export » comme un UUID."""
volunteers_orm = list(
session.exec(
select(Volunteer)
.where(Volunteer.edition_id == edition.id)
.order_by(Volunteer.last_name, Volunteer.first_name)
)
)
volunteers = [_read(session, v) for v in volunteers_orm]
position_names = {
p.id: p.name
for p in session.exec(
select(Position).where(Position.edition_id == edition.id)
)
}
artist_names = {
a.id: a.name
for a in session.exec(select(Artist).where(Artist.edition_id == edition.id))
}
volunteer_names = {
v.id: f"{v.first_name} {v.last_name}" for v in volunteers_orm
}
content = generate_volunteers_xlsx(
volunteers,
position_names=position_names,
artist_names=artist_names,
volunteer_names=volunteer_names,
)
return Response(
content=content,
media_type=(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
),
headers={
"Content-Disposition": f'attachment; filename="benevoles-{edition.year}.xlsx"'
},
)
@router.get("/mecenat-receipts")
def export_mecenat_receipts(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
"""Reçus fiscaux (cerfa 11580*05, `app/mecenat/pdf.py`) des bénévoles
ayant payé un type de paiement « mécénat » — repérés par
`mecenat_address` renseigné (voir CLAUDE.md § 2, Paiement de la
cotisation : ce champ n'est rempli que pour ce chemin-là). Déclaré avant
`/{volunteer_id}`, comme `/export` juste au-dessus."""
volunteers = list(
session.exec(
select(Volunteer)
.where(Volunteer.edition_id == edition.id)
.where(Volunteer.fee_payment_status == FeePaymentStatus.PAID)
.where(Volunteer.mecenat_address.is_not(None))
.order_by(Volunteer.last_name, Volunteer.first_name)
)
)
if not volunteers:
raise HTTPException(
status_code=422, detail={"code": "mecenat.no_receipts"}
)
# Bénévoles payés avant la numérotation (ou jamais exportés) : numéro
# attribué maintenant, dans l'ordre des paiements, puis figé.
unnumbered = [v for v in volunteers if not v.mecenat_receipt_number]
if unnumbered:
for v in sorted(unnumbered, key=lambda v: v.fee_paid_at or v.created_at):
assign_receipt_number(session, v)
session.commit()
receipts = [receipt_for(v) for v in volunteers]
content = generate_mecenat_receipts_pdf(receipts)
return Response(
content=content,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="recus-mecenat-{edition.year}.pdf"'
},
)
@router.get("/{volunteer_id}", response_model=VolunteerRead)
def get_volunteer(
volunteer_id: uuid.UUID,
@@ -219,6 +279,110 @@ def get_volunteer(
return _read(session, volunteer)
@router.get("/{volunteer_id}/planning", response_model=list[VolunteerPlanningEntry])
def get_volunteer_planning(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> list[VolunteerPlanningEntry]:
"""Planning enrichi (poste, lieu, référent + téléphone à contacter) d'un
bénévole — permet à l'écran bénévole (`/mobile-benevole`) d'afficher ces
infos sans jamais donner à un compte VOLUNTEER un accès en lecture aux
postes/créneaux de toute l'édition (réservé à COORDINATOR/LEAD)."""
_ensure_can_read(account, volunteer_id)
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
Referent = aliased(Volunteer)
rows = session.exec(
select(Assignment, Shift, Position, Referent)
.join(Shift, Shift.id == Assignment.shift_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 == volunteer_id,
Assignment.edition_id == edition.id,
)
.order_by(Shift.starts_at)
).all()
return [
VolunteerPlanningEntry(
assignment_id=a.id,
shift_id=s.id,
starts_at=s.starts_at,
ends_at=s.ends_at,
position_name=p.name,
position_location=p.location,
referent_name=f"{r.first_name} {r.last_name}" 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
]
@router.get("/{volunteer_id}/photo")
def get_volunteer_photo(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> Response:
"""Photo transmise par le bénévole via le formulaire public
(`app/api/forms.py`) — COORDINATION/RÉFÉRENT (ex. écran référent, en
face du nom de chaque membre de l'équipe) ou le bénévole lui-même (écran
bénévole)."""
_ensure_can_read(account, volunteer_id)
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
if volunteer.photo is None:
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
return Response(
content=volunteer.photo,
media_type=volunteer.photo_content_type or "application/octet-stream",
)
@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)
def update_volunteer(
volunteer_id: uuid.UUID,
@@ -236,9 +400,9 @@ def update_volunteer(
setattr(volunteer, field, value)
session.flush()
if artist_ids is not None:
_set_artists(session, volunteer, artist_ids)
set_artists(session, volunteer, artist_ids)
if wished_position_ids is not None:
_set_wished_positions(session, volunteer, wished_position_ids)
set_wished_positions(session, volunteer, wished_position_ids)
if wished_companion_ids is not None:
_set_companions(session, volunteer, wished_companion_ids)
session.commit()
+5 -1
View File
@@ -4,7 +4,7 @@ import uuid
from collections.abc import Callable
import jwt
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlmodel import Session
@@ -31,6 +31,7 @@ def _forbidden() -> HTTPException:
def get_current_account(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
session: Session = Depends(get_session),
) -> Account:
@@ -44,6 +45,9 @@ def get_current_account(
account = session.get(Account, account_id)
if account is None:
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
+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
+80
View File
@@ -0,0 +1,80 @@
"""Limiteur de tentatives, en mémoire — protège `/auth/login` (force brute
sur le mot de passe COORDINATION) et `/auth/magic-link` (spam d'emails vers
une victime, ou vers le SMTP) faute d'un reverse-proxy qui le ferait à notre
place.
Clé par **email seul**, pas par IP : le service tourne derrière Caddy sur le
VPS (voir `deploy/`), qui ne transmet pas l'IP réelle du client par défaut —
il faudrait faire confiance à `X-Forwarded-For` en configurant un proxy de
confiance, ce que ce déploiement ne fait pas encore. Limiter par email suffit
au risque réel ici : il n'y a qu'un très petit nombre de comptes protégés par
mot de passe (la coordination), donc l'attaque à contrer est « deviner le
mot de passe d'un compte connu », pas une attaque distribuée sur des emails
au hasard.
En mémoire, un seul processus uvicorn (voir `Dockerfile`, pas de
`--workers`) : un compteur partagé entre threads (les endpoints sont
synchrones, exécutés dans un pool de threads par Starlette) suffit, pas
besoin de Redis pour ce volume."""
import threading
import time
from fastapi import HTTPException, status
class RateLimiter:
def __init__(self, max_attempts: int, window_seconds: float) -> None:
self.max_attempts = max_attempts
self.window_seconds = window_seconds
self._hits: dict[str, list[float]] = {}
self._lock = threading.Lock()
def hit(self, key: str) -> None:
"""Enregistre une tentative pour `key` ; lève 429 si la limite du
créneau glissant est dépassée."""
now = time.monotonic()
with self._lock:
recent = [
t for t in self._hits.get(key, []) if now - t < self.window_seconds
]
if len(recent) >= self.max_attempts:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail={"code": "error.too_many_requests"},
)
recent.append(now)
self._hits[key] = recent
def reset(self) -> None:
"""Tests uniquement."""
with self._lock:
self._hits.clear()
# 5 tentatives/minute : large pour un vrai utilisateur qui se trompe, serré
# pour de la force brute (bcrypt ralentit déjà chaque essai, ceci coupe
# l'automatisation par-dessus).
login_limiter = RateLimiter(max_attempts=5, window_seconds=60)
# 3 demandes/5 min : un bénévole qui n'a pas reçu son email réessaie deux ou
# trois fois ; au-delà c'est soit un abus, soit un email qui n'arrivera pas
# plus vite en insistant.
magic_link_limiter = RateLimiter(max_attempts=3, window_seconds=300)
# Formulaire public d'inscription (`app/api/forms.py`) : pas d'identité
# fiable à clé (n'importe qui peut soumettre des emails différents à chaque
# fois, et l'IP réelle du client n'est pas encore transmise par Caddy — voir
# plus haut) ; hCaptcha est la défense principale contre les robots, ceci
# n'est qu'un filet de sécurité global côté édition contre un emballement
# malgré tout (30 inscriptions/5 min est très large pour un usage réel).
form_submission_limiter = RateLimiter(max_attempts=30, window_seconds=300)
# Interrogation du statut d'une inscription en attente de paiement
# (`GET .../form/public/pending/{token}`) : relit toujours l'état réel
# auprès de SumUp (voir `app/api/forms.py:get_pending_status`), donc chaque
# appel coûte une requête à l'API SumUp de l'édition. Clé par jeton (pas par
# édition, contrairement à `form_submission_limiter`) : plusieurs
# inscriptions en cours en même temps ne doivent pas s'épuiser mutuellement
# leur quota de sondage. 60/5 min couvre largement le sondage légitime (la
# page de confirmation interroge toutes les 3 s, jusqu'à 40 fois — voir
# `PublicSignupPage.tsx`), tout en bornant un sondage en boucle serrée qui
# n'aurait d'autre effet que de solliciter inutilement l'API SumUp.
pending_status_limiter = RateLimiter(max_attempts=60, window_seconds=300)
+17
View File
@@ -0,0 +1,17 @@
from app.badges.pdf import (
DEFAULT_BADGE_HEIGHT_MM,
DEFAULT_BADGE_WIDTH_MM,
BadgeFieldSpec,
BadgeSpec,
generate_badge_sheets_pdf,
image_size_mm,
)
__all__ = [
"DEFAULT_BADGE_HEIGHT_MM",
"DEFAULT_BADGE_WIDTH_MM",
"BadgeFieldSpec",
"BadgeSpec",
"generate_badge_sheets_pdf",
"image_size_mm",
]
+562
View File
@@ -0,0 +1,562 @@
"""Génération des planches de badges bénévoles (PDF).
Module isolé, fonction pure (`generate_badge_sheets_pdf`) : aucun accès base
ici — voir `app/api/badges.py` pour l'assemblage des données (bénévole →
champs résolus en texte).
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,
`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 à
l'éditeur. Planche : grille sur page A4 portrait. Chaque page de recto est
suivie de sa page de verso (même image de fond à chaque emplacement rempli,
plus les champs du verso de chaque bénévole) — impression recto-verso
classique, retournement sur le bord long : sur la page de verso, chaque
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
from dataclasses import dataclass
from PIL import Image, ImageOps, UnidentifiedImageError
from reportlab.lib.colors import HexColor
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen import canvas
DEFAULT_BADGE_WIDTH_MM = 85.6
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
_GUTTER_MM = 4.0
_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
# en millimètres réels (donc en taille de badge) — 300 dpi est la résolution
# d'impression standard.
ASSUMED_DPI = 300.0
_PAGE_WIDTH_MM = A4[0] / mm
_PAGE_HEIGHT_MM = A4[1] / mm
def image_size_mm(width_px: int, height_px: int, dpi: float = ASSUMED_DPI) -> tuple[float, float]:
"""Taille physique (mm) d'une image, en supposant qu'elle a été exportée
à `dpi` — c'est cette taille qui devient celle du badge (voir
`app/api/badges.py:set_front_image`)."""
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)
class BadgeFieldSpec:
"""Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n`
possibles — ex. prénom puis nom), positionné depuis le coin haut-gauche
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
x_mm: float
y_mm: float
width_mm: float
height_mm: float
font_size: float
align: str # "LEFT" | "CENTER" | "RIGHT"
bold: bool
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)
class BadgeSpec:
"""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, ...]
back_fields: tuple[BadgeFieldSpec, ...] = ()
def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]:
usable_w = _PAGE_WIDTH_MM - 2 * _MARGIN_MM
usable_h = _PAGE_HEIGHT_MM - 2 * _MARGIN_MM
cols = max(1, int((usable_w + _GUTTER_MM) // (badge_width_mm + _GUTTER_MM)))
rows = max(1, int((usable_h + _GUTTER_MM) // (badge_height_mm + _GUTTER_MM)))
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(
index: int,
cols: int,
badge_width_mm: float,
badge_height_mm: float,
mirrored: bool = False,
) -> tuple[float, float]:
"""Coin bas-gauche (repère PDF) de l'emplacement `index` sur 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
if mirrored:
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
def _draw_background(
c: canvas.Canvas,
image: bytes | None,
x0_mm: float,
y0_mm: float,
badge_width_mm: float,
badge_height_mm: float,
) -> None:
if not image:
return
c.drawImage(
ImageReader(io.BytesIO(image)),
x0_mm * mm,
y0_mm * mm,
width=badge_width_mm * mm,
height=badge_height_mm * mm,
# Le format du badge est désormais dérivé de l'image du recto (voir
# `image_size_mm`), donc déjà au bon ratio ; `True` évite en plus
# toute déformation du verso s'il n'a pas exactement le même ratio.
preserveAspectRatio=True,
anchor="c",
mask="auto",
)
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,
f: BadgeFieldSpec,
x0_mm: float,
y0_mm: float,
badge_height_mm: float,
cache: PhotoCache,
) -> None:
x, y, w, h = _frame_rect(f, x0_mm, y0_mm, badge_height_mm)
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))
left_x = x0_mm + f.x_mm
# `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
# ici le facteur de conversion pt/mm de reportlab).
font_size_mm = font_size / mm
line_height_mm = font_size_mm * _LINE_HEIGHT_FACTOR
# `f.y_mm` est depuis le HAUT du badge (repère écran) ; reportlab
# dessine depuis le BAS de la page.
top_y = y0_mm + badge_height_mm - f.y_mm
if f.valign == "MIDDLE":
# 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
if f.align == "CENTER":
c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, line)
elif f.align == "RIGHT":
c.drawRightString((left_x + f.width_mm) * mm, baseline_y * mm, line)
else:
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(
badges: list[BadgeSpec],
front_image: bytes | None,
back_image: bytes | None,
badge_width_mm: float = DEFAULT_BADGE_WIDTH_MM,
badge_height_mm: float = DEFAULT_BADGE_HEIGHT_MM,
) -> bytes:
"""`badges` : un par bénévole, dans l'ordre d'impression. Retourne les
octets du PDF (recto puis verso, planche par planche)."""
cols, rows = _grid_size(badge_width_mm, badge_height_mm)
per_page = cols * rows
pages = [badges[i : i + per_page] for i in range(0, len(badges), per_page)]
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
photos: PhotoCache = {}
for page_badges in pages:
for i, badge in enumerate(page_badges):
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_fields(c, badge.fields, x0, y0, badge_height_mm, photos)
c.showPage()
for i, badge in enumerate(page_badges):
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_fields(c, badge.back_fields, x0, y0, badge_height_mm, photos)
c.showPage()
c.save()
return buffer.getvalue()
+35 -1
View File
@@ -1,7 +1,14 @@
from functools import lru_cache
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Valeur de développement uniquement (versionnée dans `.env.example`) : un
# secret connu de quiconque lit le dépôt ne protège plus rien. Voir le
# contrôle dans `Settings.check_production_secrets`.
_DEV_JWT_SECRET = "dev-insecure-secret-change-me-please-32b"
_MIN_JWT_SECRET_LENGTH = 32
class Settings(BaseSettings):
"""Configuration applicative, alimentée par l'environnement."""
@@ -20,7 +27,7 @@ class Settings(BaseSettings):
)
# Authentification (lien magique + session JWT). Secret >= 32 octets.
jwt_secret: str = "dev-insecure-secret-change-me-please-32b"
jwt_secret: str = _DEV_JWT_SECRET
magic_link_ttl_minutes: int = 30
session_ttl_hours: int = 12
@@ -42,6 +49,12 @@ class Settings(BaseSettings):
# (ou un alias validé). En cas de rejet, mettre SMTP_FROM = SMTP_USER.
smtp_from: str = "Festival des bénévoles <no-reply@example.test>"
# Formulaire public d'inscription (`app/api/forms.py`). Secret hCaptcha
# seulement (la clé de site est publique, elle vit côté front-end en
# VITE_HCAPTCHA_SITE_KEY, figée au build). None ⇒ vérification
# désactivée (dev/tests uniquement, voir `app/forms/captcha.py`).
hcaptcha_secret_key: str | None = None
log_level: str = "INFO"
cors_origins: list[str] = ["http://localhost:5173"]
@@ -49,6 +62,27 @@ class Settings(BaseSettings):
def is_production(self) -> bool:
return self.environment == "production"
@model_validator(mode="after")
def check_production_secrets(self) -> "Settings":
"""Un secret JWT par défaut, vide ou trop court n'importe pas en dev
(confort), mais en production il permettrait de forger un jeton pour
n'importe quel compte (y compris COORDINATION) — donc de fabriquer
soi-même un jeton valide sans jamais s'authentifier. On refuse de
démarrer plutôt que de tourner silencieusement sans protection."""
if not self.is_production:
return self
if (
not self.jwt_secret
or self.jwt_secret == _DEV_JWT_SECRET
or len(self.jwt_secret) < _MIN_JWT_SECRET_LENGTH
):
raise ValueError(
"JWT_SECRET manquant, par défaut ou trop court (>= "
f"{_MIN_JWT_SECRET_LENGTH} caractères) en production. "
"Générer une vraie valeur : openssl rand -hex 32"
)
return self
@lru_cache
def get_settings() -> Settings:
+16 -2
View File
@@ -4,7 +4,21 @@
(sans destinataire ni corps — RGPD). Voir CLAUDE.md.
"""
from app.email.branding import edition_logo
from app.email.sender import send, send_many
from app.email.templates import render_magic_link, render_planning
from app.email.templates import (
render_magic_link,
render_planning,
render_registration_confirmation,
render_registration_paid_confirmation,
)
__all__ = ["render_magic_link", "render_planning", "send", "send_many"]
__all__ = [
"edition_logo",
"render_magic_link",
"render_planning",
"render_registration_confirmation",
"render_registration_paid_confirmation",
"send",
"send_many",
]
+25
View File
@@ -0,0 +1,25 @@
"""Logo d'une édition pour l'en-tête des emails (voir `app/email/html.py`) —
`None` tant qu'aucun logo n'a été importé sur le formulaire public
(`FormTemplate.logo`, `app/api/forms.py:set_logo`).
Renvoie les octets (intégrés en pièce jointe « inline », voir
`app/email/sender.py:build_message`), jamais une URL : `GET .../form/logo`
exige désormais le `access_token` du formulaire public
(`app/api/forms.py:_require_access_token`) — une URL externe dans un email
aurait exposé ce jeton à quiconque lit la source du message, pas seulement
la coordination."""
import uuid
from sqlmodel import Session, select
from app.models import FormTemplate
def edition_logo(session: Session, edition_id: uuid.UUID) -> tuple[bytes, str] | None:
template = session.exec(
select(FormTemplate).where(FormTemplate.edition_id == edition_id)
).first()
if template is None or template.logo is None:
return None
return template.logo, template.logo_content_type or "image/png"
+136
View File
@@ -0,0 +1,136 @@
"""Version HTML des emails — même thème que l'appli (couleurs/typographie de
`frontend/tailwind.config.js`), logo de l'édition en en-tête si défini.
Chaque email reste envoyé en `multipart/alternative` (texte + HTML, voir
`app/email/sender.py:build_message`) : le texte brut reste la source de
vérité (accessibilité, clients sans HTML, RGPD — rien de plus dans le HTML
que dans le texte déjà journalisable). Pas de CSS externe ni de police web
(rarement chargées par les clients mail) : mise en page par tableau, styles
en ligne, polices système en repli du style « Archivo »/« DM Mono »."""
import html as _html
import re
from collections.abc import Mapping, Sequence
_INK = "#14110E"
_CREAM = "#F5EFE1"
_PAPER = "#FFFDF7"
_YELLOW = "#FFD23F"
_FONT_DISPLAY = "Arial, Helvetica, sans-serif"
_FONT_MONO = "'Courier New', Courier, monospace"
# Identifiant de la pièce jointe « inline » du logo (voir
# `app/email/sender.py:build_message`) — jamais une URL externe avec le
# `access_token` du formulaire public en requête : n'importe quel
# destinataire d'un email (lien magique, confirmation d'inscription…) aurait
# pu lire ce jeton depuis la source du message, pas seulement la coordination.
LOGO_CID = "edition-logo"
_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
ligne — même convention que les modèles texte (`app/email/templates.py`).
`blocks` : repère → HTML déjà construit (ex. tableau des créneaux),
inséré tel quel à la place du repère, hors de tout paragraphe."""
out = []
for block in body.strip().split("\n\n"):
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, blocks: Mapping[str, str] | None = None
) -> str:
"""`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
simple bandeau texte (le sujet, en capitales). `blocks` : voir
`_paragraphs`."""
if has_logo:
header = (
f'<img src="cid:{LOGO_CID}" alt="" width="160" '
'style="max-height:64px;width:auto;display:block;margin:0 auto;'
'border:0;">'
)
else:
header = (
f'<span style="font-family:{_FONT_DISPLAY};font-weight:800;'
f"text-transform:uppercase;letter-spacing:1px;color:{_YELLOW};"
f'font-size:14px;">{_html.escape(subject)}</span>'
)
return f"""<!doctype html>
<html lang="fr">
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:24px 16px;background:{_CREAM};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<table role="presentation" width="520" cellpadding="0" cellspacing="0"
style="max-width:520px;width:100%;">
<tr>
<td style="background:{_INK};padding:18px;text-align:center;
border:3px solid {_INK};">
{header}
</td>
</tr>
<tr>
<td style="background:{_PAPER};border:3px solid {_INK};
border-top:none;padding:22px;font-size:13px;
line-height:1.6;color:{_INK};font-family:{_FONT_MONO};">
{_paragraphs(body, blocks or {})}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
+108 -13
View File
@@ -8,10 +8,11 @@ Pour diagnostiquer : `python -m app.email.check <destinataire>`."""
import logging
import smtplib
import ssl
from collections.abc import Iterable
from collections.abc import Iterable, Mapping, Sequence
from email.message import EmailMessage
from app.config import Settings, get_settings
from app.email.html import LOGO_CID, render_html
logger = logging.getLogger("app.email")
@@ -32,12 +33,59 @@ def _smtp_error_summary(exc: smtplib.SMTPException) -> str:
return exc.__class__.__name__
def build_message(to: str, subject: str, body: str) -> EmailMessage:
# (octets, content-type, nom de fichier) d'une pièce jointe.
Attachment = tuple[bytes, str, str]
def build_message(
to: str,
subject: str,
body: str,
*,
logo: tuple[bytes, str] | None = None,
attachments: Sequence[Attachment] = (),
html_body: str | None = None,
html_blocks: Mapping[str, str] | None = None,
) -> EmailMessage:
"""`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
(`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
HTML. Le logo, s'il y en a un, est intégré en pièce jointe « inline »
(Content-ID) référencée par la partie HTML — jamais une URL externe, qui
aurait exposé le jeton d'accès du formulaire public à quiconque lit la
source du message (voir `app/email/branding.py`). `attachments` : vraies
pièces jointes (ex. reçu fiscal mécénat), ajoutées après le logo — le
message devient alors `multipart/mixed`."""
message = EmailMessage()
message["From"] = get_settings().smtp_from
message["To"] = to
message["Subject"] = subject
message.set_content(body)
message.add_alternative(
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",
)
if logo is not None:
content, content_type = logo
maintype, _, subtype = content_type.partition("/")
html_part = message.get_payload()[-1]
html_part.add_related(
content, maintype or "image", subtype or "png", cid=f"<{LOGO_CID}>"
)
for content, content_type, filename in attachments:
maintype, _, subtype = content_type.partition("/")
message.add_attachment(content, maintype=maintype, subtype=subtype, filename=filename)
return message
@@ -102,19 +150,66 @@ def deliver_many(messages: list[EmailMessage]) -> None:
message["From"],
_smtp_error_summary(exc),
)
# RGPD : ni destinataire ni corps, seulement le compte et le sujet.
logger.info(
"Emails remis : %d/%d — sujet=%r",
sent,
len(messages),
messages[0]["Subject"],
# RGPD : ni destinataire, ni corps, ni sujet — un sujet de récapitulatif
# de planning (`app/api/notifications.py`) peut être personnalisé par la
# coordination avec le nom du bénévole (`{prenom}`/`{nom}`), ce ne serait
# alors plus un simple texte d'interface.
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(to: str, subject: str, body: str) -> None:
deliver_many([build_message(to, subject, body)])
def send(
to: str,
subject: str,
body: str,
*,
logo: tuple[bytes, str] | None = None,
attachments: Sequence[Attachment] = (),
) -> None:
deliver_many([build_message(to, subject, body, logo=logo, attachments=attachments)])
def send_many(items: Iterable[tuple[str, str, str]]) -> None:
"""`items` = itérable de `(destinataire, sujet, corps)`."""
deliver_many([build_message(*item) for item in items])
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(
items: Iterable[tuple[str, str, str]], *, logo: tuple[bytes, str] | None = None
) -> None:
"""`items` = itérable de `(destinataire, sujet, corps)` — même logo pour
tous (un seul appel par édition, voir `app/api/notifications.py`)."""
deliver_many([build_message(*item, logo=logo) for item in items])
+30
View File
@@ -21,6 +21,36 @@ def render_magic_link(*, link: str, minutes: int) -> tuple[str, str]:
)
def render_registration_confirmation(*, first_name: str) -> tuple[str, str]:
"""Inscription prise en compte, sans paiement en ligne (case désactivée,
ou « je paierai sur place ») — voir `render_registration_paid_confirmation`
pour le cas d'un paiement confirmé."""
return (
t("email.registration_confirmation.subject"),
t("email.registration_confirmation.body", prenom=first_name),
)
def render_registration_paid_confirmation(
*, first_name: str, label: str, amount: float, with_mecenat_receipt: bool = False
) -> tuple[str, str]:
"""Inscription prise en compte **et** paiement en ligne confirmé — envoyé
une fois seulement, au moment de la promotion (`app/api/forms.py:
_promote_pending`). Sert de reçu (montant, type de paiement) en
l'absence de confirmation documentée que l'API SumUp Hosted Checkout en
envoie un elle-même. `with_mecenat_receipt` : le cerfa rempli est joint
(`_promote_pending`), le corps l'annonce."""
body = t(
"email.registration_confirmation_paid.body",
prenom=first_name,
label=label,
montant=amount,
)
if with_mecenat_receipt:
body += "\n\n" + t("email.registration_confirmation_paid.mecenat_receipt_note")
return t("email.registration_confirmation_paid.subject"), body
def _fill(template: str, values: dict[str, object]) -> str:
out = template
for key in _PLANNING_PLACEHOLDERS:
+3
View File
@@ -0,0 +1,3 @@
from app.exporting.xlsx import generate_volunteers_xlsx
__all__ = ["generate_volunteers_xlsx"]
+145
View File
@@ -0,0 +1,145 @@
"""Export Excel de la liste des bénévoles d'une édition — voir
`app/api/volunteers.py:export_volunteers`. Même bibliothèque que l'import
(`openpyxl`, `app/importing/parser.py`), en écriture cette fois.
Texte en dur (en-têtes de colonnes, libellés) : comme `app/sheets/pdf.py` et
`app/badges/pdf.py`, un document généré n'est pas un texte d'interface —
pas de `app.i18n.t()` ici, contrairement aux réponses de l'API."""
import io
import uuid
from openpyxl import Workbook
from openpyxl.styles import Font
from app.api.schemas import VolunteerRead
_HEADER = [
"Prénom",
"Nom",
"Email",
"Téléphone",
"Disponible",
"Postes souhaités",
"Artistes souhaités",
"Compagnons souhaités",
"Note de compagnon",
"Mode de paiement",
"Cotisation choisie",
"Montant (€)",
"Statut du paiement",
"Payée le",
"Transaction SumUp (code)",
"Transaction SumUp (identifiant)",
"Mécénat — adresse",
"Mécénat — ville",
"Mécénat — code postal",
"Mécénat — pays",
"Commentaire",
"Texte du badge",
"Nombre de badges",
"Inscrit le",
]
_FEE_STATUS_LABELS = {
"PENDING": "En attente",
"PAID": "Payée",
"FAILED": "Échouée",
"EXPIRED": "Expirée",
}
# Caractères qui font d'une cellule Excel/LibreOffice une formule plutôt
# qu'un texte littéral — injection de formule (CWE-1236) : la plupart de ces
# champs viennent du formulaire public, sans authentification (nom, email,
# téléphone, commentaire, note de compagnon, adresse de mécénat…). Un
# préfixe `'` force l'interprétation en texte, sans changer la valeur
# affichée.
_FORMULA_PREFIXES = ("=", "+", "-", "@")
def _safe_text(value: str) -> str:
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:
return "Oui" if value else "Non"
def _join_names(ids: list[uuid.UUID], names: dict[uuid.UUID, str]) -> str:
return _safe_text(", ".join(names[i] for i in ids if i in names))
def _fmt_dt(value) -> str:
return value.strftime("%d/%m/%Y %H:%M") if value else ""
def generate_volunteers_xlsx(
volunteers: list[VolunteerRead],
*,
position_names: dict[uuid.UUID, str],
artist_names: dict[uuid.UUID, str],
volunteer_names: dict[uuid.UUID, str],
) -> bytes:
"""`position_names`/`artist_names`/`volunteer_names` : cartes id → nom
construites une fois par l'appelant (pas de requête par bénévole) —
voir `app/api/volunteers.py:export_volunteers`."""
workbook = Workbook()
sheet = workbook.active
sheet.title = "Bénévoles"
sheet.append(_HEADER)
for cell in sheet[1]:
cell.font = Font(bold=True)
for v in volunteers:
sheet.append(
[
_safe_text(v.first_name),
_safe_text(v.last_name),
_safe_text(v.email or ""),
_safe_text(v.phone or ""),
_yes_no(v.self_declared_available),
_join_names(v.wished_position_ids, position_names),
_join_names(v.artist_ids, artist_names),
_join_names(v.wished_companion_ids, volunteer_names),
_safe_text(v.companion_wish_note or ""),
_payment_mode(v),
_safe_text(v.fee_payment_label or ""),
v.fee_amount if v.fee_amount is not None else "",
_FEE_STATUS_LABELS.get(v.fee_payment_status, ""),
_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_city or ""),
_safe_text(v.mecenat_postal_code or ""),
_safe_text(v.mecenat_country or ""),
_safe_text(v.comment or ""),
_safe_text(v.badge_text or ""),
v.badge_count,
_fmt_dt(v.created_at),
]
)
for column_cells in sheet.columns:
length = max(
(len(str(c.value)) for c in column_cells if c.value is not None),
default=8,
)
sheet.column_dimensions[column_cells[0].column_letter].width = min(
max(length + 2, 10), 40
)
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
+3
View File
@@ -0,0 +1,3 @@
from app.forms.captcha import verify_hcaptcha
__all__ = ["verify_hcaptcha"]
+46
View File
@@ -0,0 +1,46 @@
"""Vérification hCaptcha du formulaire public d'inscription.
Point d'injection unique : `_verify_with_hcaptcha` (remplacé dans les tests,
même principe que `app.email.sender.deliver_many`). Sans `HCAPTCHA_SECRET_KEY`
configurée (dev/tests), la vérification est désactivée — voir
`app/config.py:Settings.hcaptcha_secret_key`.
"""
import logging
import httpx
from app.config import get_settings
logger = logging.getLogger("app.forms")
_VERIFY_URL = "https://hcaptcha.com/siteverify"
_TIMEOUT_SECONDS = 8.0
def _verify_with_hcaptcha(token: str, secret: str) -> bool:
"""Appel réel à l'API hCaptcha. Toute erreur réseau/serveur est traitée
comme un échec de vérification (on ne laisse jamais passer par défaut)."""
try:
resp = httpx.post(
_VERIFY_URL,
data={"secret": secret, "response": token},
timeout=_TIMEOUT_SECONDS,
)
resp.raise_for_status()
return bool(resp.json().get("success"))
except httpx.HTTPError as exc:
logger.warning("Échec de la vérification hCaptcha : %s", exc.__class__.__name__)
return False
def verify_hcaptcha(token: str | None) -> bool:
"""`False` si `token` est vide, ou si la vérification hCaptcha échoue.
Toujours `True` quand `HCAPTCHA_SECRET_KEY` n'est pas configurée (dev,
tests) — voir l'avertissement au démarrage dans `app/main.py`."""
secret = get_settings().hcaptcha_secret_key
if not secret:
return True
if not token:
return False
return _verify_with_hcaptcha(token, secret)
+66 -6
View File
@@ -5,11 +5,13 @@
"error.unauthorized": "Authentification requise",
"error.validation_failed": "La validation a échoué",
"error.conflict": "Conflit avec une ressource existante.",
"error.too_many_requests": "Trop de tentatives, réessayez dans un instant",
"edition.dates_inconsistent": "La date de fin doit être postérieure ou égale à la date de début.",
"day_part.code_required": "Le code du créneau type ne peut pas être vide.",
"day_part.duplicate_code": "Un créneau type porte déjà ce code dans l'édition.",
"shift.ends_not_after_starts": "La fin du créneau doit être postérieure à son début.",
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition.",
"position.lead_must_be_referent_account": "Le référent d'un poste doit être un compte référent (rôle LEAD) de cette édition.",
"artist.name_required": "Le nom de l'artiste ne peut pas être vide.",
"artist.duplicate": "Un artiste porte déjà ce nom à cette date dans l'édition.",
"artist.unknown": "Un des artistes choisis n'appartient pas à cette édition.",
@@ -25,6 +27,7 @@
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition.",
"account.password_required": "Un compte coordination requiert un mot de passe d'au moins {min} caractères.",
"account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe.",
"account.role_change_not_allowed": "Un compte coordination ne peut pas changer de rôle ainsi.",
"import.error.empty_file": "Le fichier est vide.",
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
"import.error.unreadable_file": "Fichier illisible ou format non pris en charge.",
@@ -40,16 +43,73 @@
"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.",
"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é.",
"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.registration_confirmation.subject": "Ton inscription bénévole est confirmée",
"email.registration_confirmation.body": "Bonjour {prenom},\n\nTon inscription comme bénévole a bien été enregistrée. La coordination du festival prendra contact avec toi si besoin.\n\nÀ bientôt !",
"email.registration_confirmation_paid.subject": "Ton inscription bénévole est confirmée — paiement reçu",
"email.registration_confirmation_paid.body": "Bonjour {prenom},\n\nTon inscription comme bénévole a bien été enregistrée, et nous confirmons la réception de ton paiement :\n\n{label} — {montant} €\n\nLa coordination du festival prendra contact avec toi si besoin.\n\nÀ bientôt !",
"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_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.shift_line": "- {position} : {start} → {end}",
"availability.date_outside_edition": "La date {date} est hors des dates de l'édition.",
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes."
"email.planning.table.time": "Horaire",
"email.planning.table.position": "Poste",
"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_unreadable": "L'image est illisible ou corrompue.",
"badge.empty_image": "L'image est vide.",
"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.invalid_size": "La taille du badge doit être strictement positive.",
"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.role.referent": "Référent",
"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.ticketing": "Billetterie",
"position.category.welcome": "Accueil",
"position.category.parking": "Parking",
"position.category.cleaning": "Propreté",
"position.category.catering": "Catering",
"position.category.setup": "Montage",
"position.category.stage": "Régie",
"position.category.other": "Autre",
"form.closed": "Les inscriptions sont actuellement fermées.",
"form.captcha_failed": "Vérification anti-robot invalide, réessayez.",
"form.invalid_payload": "Données du formulaire invalides.",
"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.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
"form.logo_empty": "Le logo est vide.",
"form.logo_too_large": "Le logo est trop volumineux.",
"form.invalid_sumup_amount": "Le montant doit être supérieur à 0.",
"form.invalid_free_amount": "Le montant doit être compris entre {min} € et {max} €, avec au plus deux décimales.",
"form.unknown_payment_option": "Ce type de paiement n'existe pas pour cette édition.",
"form.duplicate_payment_option_label": "Un type de paiement porte déjà ce libellé.",
"form.pending_already_paid": "Cette inscription est déjà payée.",
"form.pending_not_awaiting_info": "Cette inscription n'attend pas ces informations.",
"form.too_many_position_wishes": "Deux vœux de poste au maximum.",
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.",
"position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.",
"position_sheets.no_shifts": "Aucun créneau à imprimer pour ces postes.",
"mecenat.no_receipts": "Aucun bénévole n'a payé la cotisation en mécénat pour cette édition."
}
+26 -1
View File
@@ -1,4 +1,5 @@
import logging
from contextvars import ContextVar
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
# 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:
# `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.
logging.basicConfig(
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,
)
# 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)
+33 -2
View File
@@ -1,3 +1,5 @@
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -5,24 +7,48 @@ from app.api.accounts import router as account_router
from app.api.artists import router as artist_router
from app.api.assignments import router as assignment_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.day_parts import router as day_part_router
from app.api.editions import router as edition_router
from app.api.forms import router as form_router
from app.api.health import router as health_router
from app.api.imports import router as import_router
from app.api.matrix import router as matrix_router
from app.api.notifications import router as notification_router
from app.api.position_sheets import router as position_sheets_router
from app.api.positions import router as position_router
from app.api.shifts import router as shift_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.config import get_settings
from app.logging_config import configure_logging
from app.request_logging import REQUEST_ID_HEADER, install_request_logging
configure_logging()
settings = get_settings()
if settings.is_production and not settings.email_enabled:
# Avec EMAIL_ENABLED=false, le jeton de lien magique est journalisé en
# clair (confort de dev, voir `api/auth.py`) — acceptable en local, jamais
# en production : quiconque lit les logs du conteneur peut alors se
# connecter à la place de n'importe quel bénévole ou référent.
logging.getLogger("app.main").warning(
"EMAIL_ENABLED=false en production : les liens magiques sont "
"journalisés en clair au lieu d'être envoyés par email. Configurer "
"le SMTP avant l'ouverture au public."
)
if settings.is_production and not settings.hcaptcha_secret_key:
# Le formulaire public (`app/api/forms.py`) reste accessible sans
# HCAPTCHA_SECRET_KEY, mais sans aucune protection anti-robot au-delà du
# filet de sécurité global par édition (`form_submission_limiter`).
logging.getLogger("app.main").warning(
"HCAPTCHA_SECRET_KEY absente en production : le formulaire public "
"d'inscription n'est pas protégé par captcha."
)
app = FastAPI(title="Gestion des bénévoles", version="0.1.0")
app.add_middleware(
@@ -31,7 +57,9 @@ app.add_middleware(
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=[REQUEST_ID_HEADER],
)
install_request_logging(app)
app.include_router(health_router)
app.include_router(auth_router)
@@ -41,10 +69,13 @@ app.include_router(account_router)
app.include_router(import_router)
app.include_router(artist_router)
app.include_router(volunteer_router)
app.include_router(availability_router)
app.include_router(position_router)
app.include_router(shift_router)
app.include_router(assignment_router)
app.include_router(solver_router)
app.include_router(matrix_router)
app.include_router(stats_router)
app.include_router(notification_router)
app.include_router(badge_router)
app.include_router(form_router)
app.include_router(position_sheets_router)
+10
View File
@@ -0,0 +1,10 @@
from app.mecenat.pdf import MecenatReceipt, generate_mecenat_receipts_pdf
from app.mecenat.receipts import RECEIPT_FILENAME, assign_receipt_number, receipt_for
__all__ = [
"RECEIPT_FILENAME",
"MecenatReceipt",
"assign_receipt_number",
"generate_mecenat_receipts_pdf",
"receipt_for",
]
Binary file not shown.
+84
View File
@@ -0,0 +1,84 @@
"""Montant en toutes lettres, en français — remplit l'encadré « Somme en
toutes lettres » du cerfa (voir `pdf.py`). Fonction pure, testée séparément
de la génération PDF : l'orthographe des nombres en français a suffisamment
d'exceptions (accord de "quatre-vingts"/"cents", "et un" irrégulier à 71)
pour mériter ses propres cas de test.
"""
_ONES = [
"zéro", "un", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf",
"dix", "onze", "douze", "treize", "quatorze", "quinze", "seize",
"dix-sept", "dix-huit", "dix-neuf",
]
_TENS_PREFIX = {2: "vingt", 3: "trente", 4: "quarante", 5: "cinquante", 6: "soixante"}
def _under_hundred(n: int) -> str:
"""0 <= n <= 99."""
if n < 20:
return _ONES[n]
if n < 70:
tens, unit = divmod(n, 10)
prefix = _TENS_PREFIX[tens]
if unit == 0:
return prefix
if unit == 1:
return f"{prefix} et un"
return f"{prefix}-{_ONES[unit]}"
if n < 80:
# 70-79 : soixante + dix..dix-neuf, « et onze » irrégulier à 71 (le
# seul cas des années 70 avec « et » — 72..79 n'en prennent pas).
rem = n - 60
if rem == 11:
return "soixante et onze"
return f"soixante-{_ONES[rem]}"
# 80-99 : quatre-vingt(s) + ... — jamais de « et », y compris à 91.
rem = n - 80
if rem == 0:
return "quatre-vingts"
return f"quatre-vingt-{_ONES[rem]}"
def _under_thousand(n: int) -> str:
"""0 <= n <= 999."""
if n < 100:
return _under_hundred(n)
hundreds, rem = divmod(n, 100)
prefix = "cent" if hundreds == 1 else f"{_ONES[hundreds]} cent"
if rem == 0:
# « cents » ne prend un accord au pluriel que sur un multiple exact
# (deux cents, mais deux cent un).
return f"{prefix}s" if hundreds > 1 else prefix
return f"{prefix} {_under_hundred(rem)}"
def _number_to_words(n: int) -> str:
"""0 <= n <= 999 999 999 — largement suffisant pour une cotisation ou un
don, jamais invariable au-delà (« mille » ne prend jamais de « s »)."""
if n == 0:
return "zéro"
parts = []
millions, n = divmod(n, 1_000_000)
if millions:
parts.append("un million" if millions == 1 else f"{_under_thousand(millions)} millions")
thousands, n = divmod(n, 1000)
if thousands:
parts.append("mille" if thousands == 1 else f"{_under_thousand(thousands)} mille")
if n:
parts.append(_under_thousand(n))
return " ".join(parts)
def amount_in_words(amount: float) -> str:
"""« Cent cinquante euros », « cinquante centimes », « un euro et dix
centimes »… — texte de l'encadré « Somme en toutes lettres » du cerfa, à
partir d'un montant en euros (`Volunteer.fee_amount`)."""
cents_total = round(amount * 100)
euros, cents = divmod(cents_total, 100)
parts = []
if euros or not cents:
parts.append(f"{_number_to_words(euros)} {'euro' if euros <= 1 else 'euros'}")
if cents:
parts.append(f"{_number_to_words(cents)} {'centime' if cents <= 1 else 'centimes'}")
text = " et ".join(parts)
return text[:1].upper() + text[1:]
+164
View File
@@ -0,0 +1,164 @@
"""Génération des reçus fiscaux (cerfa 11580*05) pour les dons « mécénat »
de la cotisation — voir `app/api/volunteers.py:generate_mecenat_receipts`.
Module isolé, fonction pure (`generate_mecenat_receipts_pdf`) : aucun accès
base ici. Le cerfa officiel (`cerfa-mecenat-particuliers.pdf`, à côté de ce
fichier) n'est pas un formulaire PDF interactif (aucun champ AcroForm) — les
informations du donateur, du montant et de la date sont donc dessinées
par-dessus la page 2 du modèle, aux coordonnées mesurées sur ce document
précis (mise en page fixe d'un cerfa officiel, jamais destinée à changer).
Les informations de l'organisme bénéficiaire (page 1), les cases articles
200/978 et la signature (page 2) sont pré-remplies dans le modèle lui-même
par l'association — rien à dessiner ici.
"""
import datetime as dt
import io
from dataclasses import dataclass
from pathlib import Path
from pypdf import PageObject, PdfReader, PdfWriter
from reportlab.lib.colors import black
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen import canvas
from app.mecenat.number_words import amount_in_words
_TEMPLATE_PATH = Path(__file__).parent / "cerfa-mecenat-particuliers.pdf"
_PAGE_W = 595.0
_PAGE_H = 842.0
# `bottom` : ligne de base du champ imprimé (pointillés) sur lequel écrire,
# en points depuis le HAUT de la page (repère pdfplumber, utilisé pour
# mesurer ces coordonnées) ; `_NUDGE` replace le texte juste au-dessus de
# cette ligne plutôt que dessus, une fois converti en repère PDF (origine en
# bas — voir `_draw`).
_NUDGE = 1.5
@dataclass(frozen=True)
class _Field:
x: float
bottom: float
max_width: float
font_size: float
bold: bool = False
_LAST_NAME = _Field(x=66, bottom=250.5, max_width=230, font_size=9)
_FIRST_NAME = _Field(x=352, bottom=250.5, max_width=190, font_size=9)
_ADDRESS = _Field(x=136, bottom=281.2, max_width=405, font_size=9)
_POSTAL_CODE = _Field(x=99, bottom=295.5, max_width=33, font_size=8)
_CITY = _Field(x=232, bottom=295.5, max_width=310, font_size=9)
_COUNTRY = _Field(x=67, bottom=310.5, max_width=125, font_size=9)
_AMOUNT = _Field(x=44, bottom=365.2, max_width=110, font_size=9)
_AMOUNT_IN_WORDS = _Field(x=356, bottom=363.1, max_width=190, font_size=8)
_PAYMENT_DATE = _Field(x=264, bottom=385, max_width=76, font_size=9)
# Page 1 : case « Numéro d'ordre du reçu » (x 418,7 → 566,8, haut 111 → bas
# 128,2), texte centré dedans (`x` = centre de la case).
_RECEIPT_NUMBER = _Field(x=492.75, bottom=124.0, max_width=140, font_size=11, bold=True)
_CERFA_DATE = _Field(x=320, bottom=630, max_width=76, font_size=9)
_CHECK_VIRMENT = _Field(x=291.8, bottom=592.1, max_width=10, font_size=8)
@dataclass(frozen=True)
class MecenatReceipt:
"""Un reçu = un bénévole ayant payé un type de paiement « mécénat » —
voir `Volunteer.mecenat_*`/`fee_*` (CLAUDE.md § 2, Paiement de la
cotisation)."""
last_name: str
first_name: str
address: str
postal_code: str
city: str
country: str
amount: float
payment_date: dt.date
number: str = ""
def _fitted_font_size(text: str, max_width: float, font_size: float, bold: bool) -> float:
"""Réduit la taille de police jusqu'à ce que `text` tienne dans
`max_width` (plancher 6 pt, en dessous le texte reste simplement
tronqué par le tracé plutôt que rendu illisible)."""
font = "Helvetica-Bold" if bold else "Helvetica"
size = font_size
while size > 6 and stringWidth(text, font, size) > max_width:
size -= 0.5
return size
def _draw(c: canvas.Canvas, text: str, field: _Field) -> None:
if not text:
return
font_size = _fitted_font_size(text, field.max_width, field.font_size, field.bold)
c.setFont("Helvetica-Bold" if field.bold else "Helvetica", font_size)
c.setFillColor(black)
c.drawString(field.x, _PAGE_H - field.bottom + _NUDGE, text)
def _draw_centered(c: canvas.Canvas, text: str, field: _Field) -> None:
"""Comme `_draw`, mais `field.x` est le centre de la zone (case encadrée,
pas une ligne de pointillés : pas de `_NUDGE`)."""
if not text:
return
font_size = _fitted_font_size(text, field.max_width, field.font_size, field.bold)
c.setFont("Helvetica-Bold" if field.bold else "Helvetica", font_size)
c.setFillColor(black)
c.drawCentredString(field.x, _PAGE_H - field.bottom, text)
def _finish(c: canvas.Canvas, buffer: io.BytesIO) -> PageObject:
c.showPage()
c.save()
buffer.seek(0)
return PdfReader(buffer).pages[0]
def _organisation_overlay(receipt: MecenatReceipt) -> PageObject:
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=(_PAGE_W, _PAGE_H))
_draw_centered(c, receipt.number, _RECEIPT_NUMBER)
return _finish(c, buffer)
def _overlay_page(receipt: MecenatReceipt) -> PageObject:
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=(_PAGE_W, _PAGE_H))
_draw(c, receipt.last_name, _LAST_NAME)
_draw(c, receipt.first_name, _FIRST_NAME)
_draw(c, receipt.address, _ADDRESS)
_draw(c, receipt.postal_code, _POSTAL_CODE)
_draw(c, receipt.city, _CITY)
_draw(c, receipt.country, _COUNTRY)
_draw(c, f"{receipt.amount:.2f}".replace(".", ","), _AMOUNT)
_draw(c, amount_in_words(receipt.amount), _AMOUNT_IN_WORDS)
_draw(c, receipt.payment_date.strftime("%d/%m/%Y"), _PAYMENT_DATE)
_draw(c, receipt.payment_date.strftime("%d/%m/%Y"), _CERFA_DATE)
_draw(c, "X", _CHECK_VIRMENT)
return _finish(c, buffer)
def generate_mecenat_receipts_pdf(receipts: list[MecenatReceipt]) -> bytes:
"""Un reçu = les 2 pages du cerfa officiel, page 1 complétée avec le
numéro d'ordre, page 2 avec les informations du donateur, du montant et
de la date — voir le docstring
du module. Retourne les octets du PDF concaténant tous les reçus, un
bénévole après l'autre. Le modèle est relu à chaque itération : `merge_page`
mutant la page en place, réutiliser le même objet d'un tour à l'autre
accumulerait les tracés d'un bénévole sur la page du suivant."""
writer = PdfWriter()
for receipt in receipts:
base = PdfReader(_TEMPLATE_PATH)
organisation_page = writer.add_page(base.pages[0])
organisation_page.merge_page(_organisation_overlay(receipt))
# `add_page` renvoie la page telle qu'attachée au writer — fusionner
# l'overlay sur ce retour plutôt que sur `base.pages[1]` (page encore
# détachée d'un writer, `merge_page` dépréciée dans ce cas).
donor_page = writer.add_page(base.pages[1])
donor_page.merge_page(_overlay_page(receipt))
buffer = io.BytesIO()
writer.write(buffer)
return buffer.getvalue()
+56
View File
@@ -0,0 +1,56 @@
"""Passage d'un `Volunteer` à un `MecenatReceipt` — partagé par l'export
coordination (`app/api/volunteers.py:export_mecenat_receipts`) et l'envoi du
reçu au bénévole à la confirmation de son paiement (`app/api/forms.py:
_promote_pending`) — et attribution de son numéro d'ordre."""
from sqlalchemy import func, text
from sqlmodel import Session, select
from app.mecenat.pdf import MecenatReceipt
from app.models import Volunteer
RECEIPT_FILENAME = "recu-fiscal-mecenat.pdf"
# Verrou applicatif PostgreSQL (`pg_advisory_xact_lock`) sérialisant
# l'attribution des numéros : le webhook SumUp et le sondage du navigateur
# peuvent confirmer deux paiements en même temps — sans lui, les deux
# liraient le même maximum et se disputeraient le même numéro. Libéré
# automatiquement à la fin de la transaction. Valeur arbitraire, propre à
# cet usage.
_NUMBERING_LOCK = 11580_05
def assign_receipt_number(session: Session, volunteer: Volunteer) -> None:
"""Attribue (sans commit) le prochain numéro de l'année du paiement :
`AAAA` + rang sur 5 chiffres, à la suite du plus grand déjà attribué
cette année-là, toutes éditions confondues (une seule numérotation pour
l'association). Ne fait rien si le bénévole en a déjà un — un numéro
n'est jamais réattribué."""
if volunteer.mecenat_receipt_number:
return
session.exec(text("SELECT pg_advisory_xact_lock(:key)").bindparams(key=_NUMBERING_LOCK))
year = (volunteer.fee_paid_at or volunteer.created_at).year
prefix = str(year)
last = session.exec(
select(func.max(Volunteer.mecenat_receipt_number)).where(
Volunteer.mecenat_receipt_number.startswith(prefix)
)
).one()
rank = int(last[len(prefix):]) + 1 if last else 1
volunteer.mecenat_receipt_number = f"{prefix}{rank:05d}"
session.add(volunteer)
session.flush()
def receipt_for(volunteer: Volunteer) -> MecenatReceipt:
return MecenatReceipt(
last_name=volunteer.last_name,
first_name=volunteer.first_name,
address=volunteer.mecenat_address or "",
postal_code=volunteer.mecenat_postal_code or "",
city=volunteer.mecenat_city or "",
country=volunteer.mecenat_country or "",
amount=volunteer.fee_amount or 0.0,
payment_date=(volunteer.fee_paid_at or volunteer.created_at).date(),
number=volunteer.mecenat_receipt_number or "",
)
@@ -0,0 +1,82 @@
"""Planches de badges bénévoles : recto/verso et champs positionnés
Revision ID: 0011_badge_template
Revises: 0010_companion_wish
Create Date: 2026-09-20
Deux tables : `badge_template` (images recto/verso, une ligne par édition) et
`badge_field` (champs positionnés sur le recto — nom, poste(s), catégorie,
rôle). Voir `app/badges/pdf.py` pour la génération des planches PDF.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0011_badge_template"
down_revision: str | None = "0010_companion_wish"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"badge_template",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column("front_image", sa.LargeBinary(), nullable=True),
sa.Column("front_image_content_type", sa.String(), nullable=True),
sa.Column("back_image", sa.LargeBinary(), nullable=True),
sa.Column("back_image_content_type", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("edition_id"),
)
op.create_table(
"badge_field",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column(
"field_type",
sa.Enum(
"FULL_NAME", "POSITIONS", "CATEGORY", "ROLE",
name="badgefieldtype", native_enum=False,
),
nullable=False,
),
sa.Column("x_mm", sa.Float(), nullable=False),
sa.Column("y_mm", sa.Float(), nullable=False),
sa.Column("width_mm", sa.Float(), nullable=False),
sa.Column("height_mm", sa.Float(), nullable=False),
sa.Column("font_size", sa.Float(), nullable=False),
sa.Column(
"align",
sa.Enum(
"LEFT", "CENTER", "RIGHT", name="badgealign", native_enum=False
),
nullable=False,
server_default="LEFT",
),
sa.Column(
"bold", sa.Boolean(), server_default="false", nullable=False
),
sa.Column("color", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"edition_id", "field_type", name="uq_badge_field_edition_type"
),
)
op.create_index("ix_badge_field_edition_id", "badge_field", ["edition_id"])
def downgrade() -> None:
op.drop_index("ix_badge_field_edition_id", table_name="badge_field")
op.drop_table("badge_field")
op.drop_table("badge_template")
@@ -0,0 +1,35 @@
"""Format du badge dérivé de l'image de fond du recto
Revision ID: 0012_badge_template_size
Revises: 0011_badge_template
Create Date: 2026-09-21
`badge_template.front_image_width_mm`/`front_image_height_mm` : taille
physique (mm) déduite des pixels de l'image importée (résolution supposée
300 dpi, voir `app/badges/pdf.py:image_size_mm`) — remplace le format CR80
fixe pour la génération du PDF (`app/api/badges.py`).
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0012_badge_template_size"
down_revision: str | None = "0011_badge_template"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"badge_template", sa.Column("front_image_width_mm", sa.Float(), nullable=True)
)
op.add_column(
"badge_template", sa.Column("front_image_height_mm", sa.Float(), nullable=True)
)
def downgrade() -> None:
op.drop_column("badge_template", "front_image_height_mm")
op.drop_column("badge_template", "front_image_width_mm")
@@ -0,0 +1,94 @@
"""Formulaire public d'inscription + champs bénévole associés
Revision ID: 0013_signup_form
Revises: 0012_badge_template_size
Create Date: 2026-09-21
Deux tables : `form_template` (configuration par édition — ouvert/fermé) et
`form_field` (champs activés, présence = activation). Sur `volunteer` :
`photo`/`photo_content_type` (formulaire public), `companion_wish_note`
(vœu de compagnon en texte libre, résolu ensuite par la coordination),
`self_declared_available`/`willing_to_pay_fee` (cases à cocher purement
informatives). Voir `app/api/forms.py`.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0013_signup_form"
down_revision: str | None = "0012_badge_template_size"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("volunteer", sa.Column("photo", sa.LargeBinary(), nullable=True))
op.add_column(
"volunteer", sa.Column("photo_content_type", sa.String(), nullable=True)
)
op.add_column(
"volunteer", sa.Column("companion_wish_note", sa.String(), nullable=True)
)
op.add_column(
"volunteer",
sa.Column(
"self_declared_available", sa.Boolean(),
server_default="false", nullable=False,
),
)
op.add_column(
"volunteer",
sa.Column(
"willing_to_pay_fee", sa.Boolean(),
server_default="false", nullable=False,
),
)
op.create_table(
"form_template",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column(
"is_open", sa.Boolean(), server_default="true", nullable=False
),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("edition_id"),
)
op.create_table(
"form_field",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column(
"field_type",
sa.Enum(
"EMAIL", "PHONE", "POSITION_WISHES", "ARTIST_WISHES",
"COMPANION_WISH", "AVAILABLE", "WILLING_TO_PAY_FEE", "PHOTO",
"COMMENT",
name="formfieldtype", native_enum=False,
),
nullable=False,
),
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"edition_id", "field_type", name="uq_form_field_edition_type"
),
)
op.create_index("ix_form_field_edition_id", "form_field", ["edition_id"])
def downgrade() -> None:
op.drop_index("ix_form_field_edition_id", table_name="form_field")
op.drop_table("form_field")
op.drop_table("form_template")
op.drop_column("volunteer", "willing_to_pay_fee")
op.drop_column("volunteer", "self_declared_available")
op.drop_column("volunteer", "companion_wish_note")
op.drop_column("volunteer", "photo_content_type")
op.drop_column("volunteer", "photo")
@@ -0,0 +1,34 @@
"""Logo et description du formulaire public
Revision ID: 0014_form_logo
Revises: 0013_signup_form
Create Date: 2026-09-23
Habillage de la page publique (`/inscription`, `FormTemplate`) : `logo`/
`logo_content_type` (image, stockée en base comme les autres images de
l'appli) et `description` (texte libre). Voir `app/api/forms.py`.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0014_form_logo"
down_revision: str | None = "0013_signup_form"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("form_template", sa.Column("logo", sa.LargeBinary(), nullable=True))
op.add_column(
"form_template", sa.Column("logo_content_type", sa.String(), nullable=True)
)
op.add_column("form_template", sa.Column("description", sa.String(), nullable=True))
def downgrade() -> None:
op.drop_column("form_template", "description")
op.drop_column("form_template", "logo_content_type")
op.drop_column("form_template", "logo")
@@ -0,0 +1,28 @@
"""Lien de paiement Yapla
Revision ID: 0015_form_yapla_url
Revises: 0014_form_logo
Create Date: 2026-09-23
Encart affiché sur la page publique (`/inscription`), près de la case
« je participe à la cotisation » — lien vers la page de cotisation sur
Yapla (yapla.com, plateforme externe). Voir `app/api/forms.py`.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0015_form_yapla_url"
down_revision: str | None = "0014_form_logo"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("form_template", sa.Column("yapla_url", sa.String(), nullable=True))
def downgrade() -> None:
op.drop_column("form_template", "yapla_url")
@@ -0,0 +1,69 @@
"""Paiement de la cotisation via l'API SumUp (remplace le lien Yapla)
Revision ID: 0016_sumup_payment
Revises: 0015_form_yapla_url
Create Date: 2026-09-29
`form_template.yapla_url` (simple lien externe statique) est remplacé par un
vrai suivi via l'API SumUp : identifiants marchand (`sumup_merchant_code`,
`sumup_api_key`) et montant (`sumup_fee_amount`) configurés par édition, plus
le statut de paiement par bénévole (`volunteer.fee_checkout_id`,
`fee_payment_status`, `fee_paid_at`), tenu à jour par le webhook SumUp — voir
`app/sumup/` et `app/api/forms.py`.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0016_sumup_payment"
down_revision: str | None = "0015_form_yapla_url"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.drop_column("form_template", "yapla_url")
op.add_column(
"form_template", sa.Column("sumup_merchant_code", sa.String(), nullable=True)
)
op.add_column(
"form_template", sa.Column("sumup_api_key", sa.String(), nullable=True)
)
op.add_column(
"form_template", sa.Column("sumup_fee_amount", sa.Float(), nullable=True)
)
op.add_column(
"volunteer", sa.Column("fee_checkout_id", sa.String(), nullable=True)
)
op.create_index(
"ix_volunteer_fee_checkout_id", "volunteer", ["fee_checkout_id"]
)
op.add_column(
"volunteer",
sa.Column(
"fee_payment_status",
sa.Enum(
"PENDING", "PAID", "FAILED", "EXPIRED",
name="feepaymentstatus", native_enum=False,
),
nullable=True,
),
)
op.add_column(
"volunteer", sa.Column("fee_paid_at", sa.DateTime(), nullable=True)
)
def downgrade() -> None:
op.drop_column("volunteer", "fee_paid_at")
op.drop_column("volunteer", "fee_payment_status")
op.drop_index("ix_volunteer_fee_checkout_id", table_name="volunteer")
op.drop_column("volunteer", "fee_checkout_id")
op.drop_column("form_template", "sumup_fee_amount")
op.drop_column("form_template", "sumup_api_key")
op.drop_column("form_template", "sumup_merchant_code")
op.add_column("form_template", sa.Column("yapla_url", sa.String(), nullable=True))
@@ -0,0 +1,65 @@
"""Plusieurs types de paiement de la cotisation (SumUp)
Revision ID: 0017_form_payment_options
Revises: 0016_sumup_payment
Create Date: 2026-09-29
`form_template.sumup_fee_amount` (un montant unique) est remplacé par une
liste libre de types de paiement (`form_payment_option` : libellé + montant,
configurés par la coordination, comme les créneaux types `DayPart`). Le
bénévole en choisit au plus un à l'inscription ; le libellé et le montant
choisis sont recopiés sur `volunteer` (`fee_payment_label`/`fee_amount`) au
moment de la création du checkout — voir `app/api/forms.py`.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0017_form_payment_options"
down_revision: str | None = "0016_sumup_payment"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"form_payment_option",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column("label", sa.String(), nullable=False),
sa.Column("amount", sa.Float(), nullable=False),
sa.Column("order", sa.Integer(), server_default="0", nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"edition_id", "label", name="uq_form_payment_option_edition_label"
),
)
op.create_index(
"ix_form_payment_option_edition_id", "form_payment_option", ["edition_id"]
)
op.drop_column("form_template", "sumup_fee_amount")
op.add_column(
"volunteer", sa.Column("fee_payment_label", sa.String(), nullable=True)
)
op.add_column("volunteer", sa.Column("fee_amount", sa.Float(), nullable=True))
def downgrade() -> None:
op.drop_column("volunteer", "fee_amount")
op.drop_column("volunteer", "fee_payment_label")
op.add_column(
"form_template", sa.Column("sumup_fee_amount", sa.Float(), nullable=True)
)
op.drop_index(
"ix_form_payment_option_edition_id", table_name="form_payment_option"
)
op.drop_table("form_payment_option")
@@ -0,0 +1,106 @@
"""Inscription en deux temps + mécénat (cerfa)
Revision ID: 0018_pending_submissions_mecenat
Revises: 0017_form_payment_options
Create Date: 2026-09-29
Un bénévole qui choisit un paiement en ligne n'est plus créé en base tant que
le paiement n'est pas confirmé : `pending_form_submission` retient le
formulaire (et l'état du checkout SumUp) jusqu'à la promotion en `Volunteer`
(`app/api/forms.py:_promote_pending`) ou jusqu'à purge si jamais payé.
`form_payment_option.requires_mecenat_info` distingue un type de paiement
« mécénat » (étape d'adresse postale avant paiement, cerfa) ; les coordonnées
correspondantes vivent sur `volunteer.mecenat_*` une fois le bénévole promu.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0018_pending_submissions_mecenat"
down_revision: str | None = "0017_form_payment_options"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_FEE_STATUS_ENUM = sa.Enum(
"PENDING", "PAID", "FAILED", "EXPIRED",
name="feepaymentstatus", native_enum=False,
)
def upgrade() -> None:
op.add_column(
"form_payment_option",
sa.Column(
"requires_mecenat_info", sa.Boolean(), server_default="false", nullable=False
),
)
op.add_column("volunteer", sa.Column("mecenat_address", sa.String(), nullable=True))
op.add_column("volunteer", sa.Column("mecenat_city", sa.String(), nullable=True))
op.add_column("volunteer", sa.Column("mecenat_postal_code", sa.String(), nullable=True))
op.add_column("volunteer", sa.Column("mecenat_country", sa.String(), nullable=True))
op.create_table(
"pending_form_submission",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column("first_name", sa.String(), nullable=True),
sa.Column("last_name", sa.String(), nullable=True),
sa.Column("email", sa.String(), nullable=True),
sa.Column("phone", sa.String(), nullable=True),
sa.Column("comment", sa.String(), nullable=True),
sa.Column("companion_wish_note", sa.String(), nullable=True),
sa.Column("available", sa.Boolean(), server_default="false", nullable=False),
sa.Column("position_wish_ids_json", sa.String(), nullable=True),
sa.Column("artist_wish_ids_json", sa.String(), nullable=True),
sa.Column("photo", sa.LargeBinary(), nullable=True),
sa.Column("photo_content_type", sa.String(), nullable=True),
sa.Column("payment_label", sa.String(), nullable=False),
sa.Column("payment_amount", sa.Float(), nullable=False),
sa.Column(
"requires_mecenat_info", sa.Boolean(), server_default="false", nullable=False
),
sa.Column("mecenat_address", sa.String(), nullable=True),
sa.Column("mecenat_city", sa.String(), nullable=True),
sa.Column("mecenat_postal_code", sa.String(), nullable=True),
sa.Column("mecenat_country", sa.String(), nullable=True),
sa.Column("checkout_id", sa.String(), nullable=True),
sa.Column("checkout_status", _FEE_STATUS_ENUM, nullable=True),
sa.Column("promoted_volunteer_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["promoted_volunteer_id"], ["volunteer.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_pending_form_submission_edition_id",
"pending_form_submission",
["edition_id"],
)
op.create_index(
"ix_pending_form_submission_checkout_id",
"pending_form_submission",
["checkout_id"],
)
def downgrade() -> None:
op.drop_index(
"ix_pending_form_submission_checkout_id", table_name="pending_form_submission"
)
op.drop_index(
"ix_pending_form_submission_edition_id", table_name="pending_form_submission"
)
op.drop_table("pending_form_submission")
op.drop_column("volunteer", "mecenat_country")
op.drop_column("volunteer", "mecenat_postal_code")
op.drop_column("volunteer", "mecenat_city")
op.drop_column("volunteer", "mecenat_address")
op.drop_column("form_payment_option", "requires_mecenat_info")
@@ -0,0 +1,44 @@
"""Jeton d'accès au formulaire public (lien non devinable)
Revision ID: 0019_form_access_token
Revises: 0018_pending_submissions_mecenat
Create Date: 2026-09-30
`form_template.access_token` : secret partagé désormais nécessaire pour
atteindre le formulaire public (`/inscription/<jeton>`) — voir CLAUDE.md § 2,
`FormTemplate`, et `app/api/forms.py:_require_access_token`. `/inscription`
seul, sans jeton dans l'URL, ne mène plus nulle part.
Rétrocompatibilité : chaque `form_template` déjà en base reçoit un jeton
généré aléatoirement — les liens déjà partagés cessent de fonctionner, la
coordination doit en repartager un nouveau (« Copier le lien » sur l'écran
Inscriptions) — c'est précisément le but du changement.
"""
import secrets
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0019_form_access_token"
down_revision: str | None = "0018_pending_submissions_mecenat"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"form_template", sa.Column("access_token", sa.String(), nullable=True)
)
conn = op.get_bind()
for (row_id,) in conn.execute(sa.text("SELECT id FROM form_template")):
conn.execute(
sa.text("UPDATE form_template SET access_token = :token WHERE id = :id"),
{"token": secrets.token_urlsafe(16), "id": row_id},
)
op.alter_column("form_template", "access_token", nullable=False)
def downgrade() -> None:
op.drop_column("form_template", "access_token")
@@ -0,0 +1,35 @@
"""Numéro d'ordre des reçus fiscaux mécénat
Revision ID: 0020_mecenat_receipt_number
Revises: 0019_form_access_token
Create Date: 2026-10-03
`volunteer.mecenat_receipt_number` : numéro unique du cerfa (`AAAA` + rang
sur 5 chiffres, ex. `202600001`) — voir `app/mecenat/receipts.py`. Les
bénévoles déjà payés en mécénat n'en reçoivent pas ici : ils sont numérotés
au premier export des reçus, dans l'ordre de leurs paiements.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0020_mecenat_receipt_number"
down_revision: str | None = "0019_form_access_token"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"volunteer", sa.Column("mecenat_receipt_number", sa.String(), nullable=True)
)
op.create_unique_constraint(
"volunteer_mecenat_receipt_number_key", "volunteer", ["mecenat_receipt_number"]
)
def downgrade() -> None:
op.drop_constraint("volunteer_mecenat_receipt_number_key", "volunteer", type_="unique")
op.drop_column("volunteer", "mecenat_receipt_number")
@@ -0,0 +1,32 @@
"""Type de paiement à montant libre
Revision ID: 0021_payment_option_free_amount
Revises: 0020_mecenat_receipt_number
Create Date: 2026-10-03
`form_payment_option.free_amount` : le bénévole saisit lui-même son montant
sur le formulaire public, `amount` en devient le minimum — voir
`app/api/forms.py:_free_amount`. Les types de paiement existants restent à
montant fixe.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0021_payment_option_free_amount"
down_revision: str | None = "0020_mecenat_receipt_number"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"form_payment_option",
sa.Column("free_amount", sa.Boolean(), nullable=False, server_default="false"),
)
def downgrade() -> None:
op.drop_column("form_payment_option", "free_amount")
@@ -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")
+26 -2
View File
@@ -9,10 +9,15 @@ from app.models.domain import (
Account,
Artist,
Assignment,
Availability,
BadgeField,
BadgeTemplate,
DayPart,
Edition,
FormField,
FormPaymentOption,
FormTemplate,
MagicLinkToken,
PendingFormSubmission,
Position,
Shift,
Volunteer,
@@ -22,6 +27,13 @@ from app.models.domain import (
)
from app.models.enums import (
AssignmentSource,
AttendanceStatus,
BadgeAlign,
BadgeFace,
BadgeFieldType,
BadgePhotoShape,
FeePaymentStatus,
FormFieldType,
PositionCategory,
Role,
)
@@ -31,10 +43,22 @@ __all__ = [
"Artist",
"Assignment",
"AssignmentSource",
"Availability",
"AttendanceStatus",
"BadgeAlign",
"BadgeFace",
"BadgeField",
"BadgeFieldType",
"BadgePhotoShape",
"BadgeTemplate",
"DayPart",
"Edition",
"FeePaymentStatus",
"FormField",
"FormFieldType",
"FormPaymentOption",
"FormTemplate",
"MagicLinkToken",
"PendingFormSubmission",
"Position",
"PositionCategory",
"Role",
+403 -37
View File
@@ -7,15 +7,23 @@ au niveau base (`ondelete="CASCADE"`) ; les relations ORM utilisent
"""
import datetime as dt
import secrets
import uuid
from sqlalchemy import Boolean, CheckConstraint, Column, Integer, Time
from sqlalchemy import Boolean, CheckConstraint, Column, Integer, LargeBinary, Time
from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
from app.models.base import TimestampMixin
from app.models.enums import (
AssignmentSource,
AttendanceStatus,
BadgeAlign,
BadgeFace,
BadgeFieldType,
BadgePhotoShape,
FeePaymentStatus,
FormFieldType,
PositionCategory,
Role,
)
@@ -85,6 +93,11 @@ class Edition(TimestampMixin, table=True):
end_date: dt.date
# Durée de rétention des données nominatives, en jours. None = pas de purge.
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(
back_populates="edition", sa_relationship_kwargs=_PASSIVE
@@ -155,6 +168,10 @@ class Artist(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)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE"
@@ -168,6 +185,74 @@ class Volunteer(TimestampMixin, table=True):
last_name: str
phone: str | None = None
comment: str | None = None
# Renseigné par le formulaire public d'inscription (`app/api/forms.py`) —
# voir aussi ce module pour `photo`/`photo_content_type`.
photo: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
photo_content_type: str | None = None
# « Collègue que j'aime bien » saisi en texte libre à l'inscription : la
# personne visée n'existe pas forcément encore en base à ce moment-là,
# donc pas de lien direct vers un autre `Volunteer`. La coordination lit
# cette note sur la fiche et la transforme, si besoin, en un vrai
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
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
# logique n'y est attachée (pas de workflow de paiement).
self_declared_available: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
willing_to_pay_fee: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
# Paiement de la cotisation via l'API SumUp (`app/sumup/`, voir
# `app/api/forms.py:submit_public_form`/`sumup_webhook`) — créé si le
# bénévole choisit un type de paiement (`FormPaymentOption`) et que
# l'édition a configuré SumUp (`FormTemplate.sumup_*`). `fee_payment_label`/
# `fee_amount` sont une copie figée du libellé/montant de l'option
# choisie au moment du choix (jamais recalculés si l'option est éditée
# ou supprimée ensuite). `fee_checkout_id` (identifiant SumUp du
# checkout) sert seulement à faire le lien depuis le webhook ; jamais
# exposé (voir `app/api/volunteers.py:_read`). `fee_payment_status` reste
# `NULL` tant qu'aucun checkout n'a été créé.
fee_payment_label: str | None = None
fee_amount: float | None = None
fee_checkout_id: str | None = Field(default=None, index=True)
fee_payment_status: FeePaymentStatus | None = Field(
default=None,
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
)
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 »
# (`FormPaymentOption.requires_mecenat_info`), avant le paiement (voir
# `PendingFormSubmission` et `app/api/forms.py:submit_mecenat_info`) —
# conservées pour générer un reçu fiscal (cerfa) ultérieurement. `None`
# pour un bénévole n'ayant pas payé via une option de mécénat.
mecenat_address: str | None = None
mecenat_city: str | None = None
mecenat_postal_code: str | None = None
mecenat_country: str | None = None
# Numéro d'ordre du reçu fiscal (cerfa) : année du paiement + rang sur 5
# chiffres (`202600001`), attribué une seule fois — à la confirmation du
# paiement ou, à défaut, au premier export — puis figé, pour qu'un reçu
# regénéré garde son numéro. Voir `app/mecenat/receipts.py`.
mecenat_receipt_number: str | None = Field(default=None, unique=True)
wished_artists: list[Artist] = Relationship(
back_populates="fans",
@@ -179,9 +264,6 @@ class Volunteer(TimestampMixin, table=True):
link_model=VolunteerPositionWish,
sa_relationship_kwargs=_PASSIVE,
)
availabilities: list["Availability"] = Relationship(
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
)
assignments: list["Assignment"] = Relationship(
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
)
@@ -212,6 +294,12 @@ class Account(TimestampMixin, table=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
def auth_method(self) -> str:
return "password" if self.password_hash else "magic_link"
@@ -250,6 +338,10 @@ class Position(TimestampMixin, table=True):
ondelete="SET NULL",
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(
back_populates="position", sa_relationship_kwargs=_PASSIVE
@@ -294,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):
"""Décision de l'organisation : « tu tiens tel créneau ».
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
(déclaration du bénévole).
affectation, c'est supprimer la ligne.
"""
__table_args__ = (
@@ -361,6 +421,312 @@ class Assignment(TimestampMixin, table=True):
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")
shift: Shift = Relationship(back_populates="assignments")
# --------------------------------------------------------------------------- #
# Badges (planches recto/verso, voir app/badges/) #
# --------------------------------------------------------------------------- #
class BadgeTemplate(TimestampMixin, table=True):
"""Recto/verso du badge d'une édition. Une ligne par édition (créée à la
demande). Le verso (`back_image`) est le même pour tous les bénévoles ;
le recto combine `front_image` (fond) et les `BadgeField` positionnés
dessus. Images stockées en base (pas de volume de fichiers à gérer).
Le format du badge (`front_image_width_mm`/`height_mm`) est **dérivé de
l'image du recto** à l'import (voir `app/badges/pdf.py:image_size_mm`,
résolution supposée 300 dpi) plutôt qu'un format fixe imposé — `None`
tant qu'aucune image n'a été importée (l'éditeur retombe alors sur le
format CR80 par défaut, voir `DEFAULT_BADGE_WIDTH_MM`)."""
__tablename__ = "badge_template"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", unique=True, ondelete="CASCADE"
)
front_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
front_image_content_type: str | None = None
front_image_width_mm: float | None = None
front_image_height_mm: float | None = None
back_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
back_image_content_type: str | None = None
class BadgeField(TimestampMixin, table=True):
"""Un champ positionné sur une face du badge (`face` : recto ou verso —
nom, poste(s), catégorie, rôle… voir `BadgeFieldType`). Coordonnées en
millimètres depuis le coin haut-gauche du badge (voir `app/badges/pdf.py`).
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`)."""
__tablename__ = "badge_field"
__table_args__ = (
UniqueConstraint(
"edition_id", "face", "field_type", name="uq_badge_field_edition_face_type"
),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
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(
sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False)
)
x_mm: float
y_mm: float
width_mm: float
height_mm: float
font_size: float = Field(default=10.0)
align: BadgeAlign = Field(
default=BadgeAlign.LEFT,
sa_column=Column(
SAEnum(BadgeAlign, native_enum=False),
nullable=False,
server_default=BadgeAlign.LEFT.value,
),
)
bold: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
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")
)
# --------------------------------------------------------------------------- #
# Formulaire public d'inscription (voir app/api/forms.py) #
# --------------------------------------------------------------------------- #
class FormTemplate(TimestampMixin, table=True):
"""Configuration du formulaire public d'une édition. Une ligne par
édition (créée à la demande). `is_open` coupe les nouvelles soumissions
sans effacer la configuration (les champs activés restent enregistrés).
`logo`/`logo_content_type` et `description` habillent la page publique
(`/inscription`) — aucun rapport avec les champs collectés par
`FormField` ; stockage en base comme les autres images de l'appli
(badges, photo de bénévole), pas de volume de fichiers à gérer.
`sumup_*` configurent le paiement de la cotisation via l'API SumUp (voir
`app/sumup/`)."""
__tablename__ = "form_template"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", unique=True, ondelete="CASCADE"
)
is_open: bool = Field(
default=True,
sa_column=Column(Boolean, nullable=False, server_default="true"),
)
logo: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
logo_content_type: str | None = None
description: str | None = None
# Paiement de la cotisation via l'API SumUp (`app/sumup/`) : un checkout
# est créé à la soumission si le bénévole choisit un des types de
# paiement configurés (`FormPaymentOption`, voir plus bas) et que ces
# deux identifiants sont renseignés (voir `app/api/forms.py`,
# `sumup_enabled` sur `PublicFormConfig`). `sumup_api_key` n'est jamais
# renvoyé par l'API (voir `FormTemplateRead.sumup_api_key_set`) — c'est
# un secret, au même titre qu'un mot de passe SMTP.
sumup_merchant_code: str | None = None
sumup_api_key: str | None = None
# Secret partagé nécessaire pour atteindre le formulaire public
# (`GET .../form/public`, `GET .../form/logo`, `POST .../form/public/submit`
# — voir `app/api/forms.py:_require_access_token`) : `/inscription` seul,
# sans ce jeton dans l'URL, ne mène nulle part. Généré aléatoirement à la
# création de la ligne (`secrets.token_urlsafe`) ; régénérable par la
# coordination (`POST .../form/regenerate-token`) pour révoquer un lien
# qui aurait fuité — l'ancien cesse alors immédiatement de fonctionner.
access_token: str = Field(default_factory=lambda: secrets.token_urlsafe(16))
class FormPaymentOption(TimestampMixin, table=True):
"""Un type de paiement proposé sur le formulaire public (ex. « Cotisation
classique », « Mécénat ») : libellé + montant fixe, configurés librement
par la coordination (autant qu'elle veut, comme les créneaux types
`DayPart`) — voir `app/api/forms.py`. Le bénévole en choisit au plus un
à l'inscription ; le libellé et le montant réellement proposés sont
recopiés sur `Volunteer` au moment de la création du checkout
(`fee_payment_label`/`fee_amount`), pour ne jamais changer
rétroactivement si cette configuration est éditée ou supprimée plus
tard."""
__tablename__ = "form_payment_option"
__table_args__ = (
UniqueConstraint(
"edition_id", "label", name="uq_form_payment_option_edition_label"
),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE"
)
label: str
amount: float
order: int = Field(
default=0,
sa_column=Column(Integer, nullable=False, server_default="0"),
)
# Type « mécénat » : avant le paiement, le bénévole passe par une étape
# supplémentaire (adresse postale) pour permettre l'édition d'un reçu
# fiscal (cerfa) — voir `Volunteer.mecenat_*` et
# `app/api/forms.py:submit_mecenat_info`.
requires_mecenat_info: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
# Montant libre : le bénévole saisit lui-même son montant sur le
# formulaire public ; `amount` en est alors le minimum (voir
# `app/api/forms.py:_free_amount`).
free_amount: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
class PendingFormSubmission(TimestampMixin, table=True):
"""Inscription en attente de paiement en ligne (voir CLAUDE.md § 2,
`Paiement de la cotisation`). Créée à la place d'un `Volunteer` quand le
bénévole choisit un type de paiement en ligne (`FormPaymentOption`) sur
le formulaire public — le `Volunteer` n'est créé qu'à la confirmation du
paiement (`app/api/forms.py:_promote_pending`), pour ne jamais compter en
base quelqu'un qui a rempli le formulaire sans aller au bout du
paiement.
Reprend les champs du formulaire (identiques à `Volunteer`, en plus
volatil) ; `position_wish_ids_json`/`artist_wish_ids_json` : listes
JSON stringifiées (pas d'association SQL pour une ligne éphémère), leurs
UUID sont résolus (silencieusement filtrés si un poste/artiste a
entretemps disparu — le paiement ne doit jamais échouer pour ça) au
moment de la promotion en `Volunteer`.
`id` sert aussi de jeton public (`GET .../form/public/pending/{id}`,
aucune authentification) : un UUID v4 est déjà pratiquement impossible
à deviner, comme un identifiant de session de paiement chez la plupart
des prestataires.
`payment_label`/`payment_amount`/`requires_mecenat_info` sont une copie
figée de l'option choisie (même principe que `Volunteer.fee_payment_label`)
— indépendante d'une modification de `FormPaymentOption` pendant que le
paiement est en cours. `checkout_id`/`checkout_status` suivent le
checkout SumUp *courant* : relancés (nouvel identifiant) à chaque
nouvelle tentative après un échec/une expiration
(`app/api/forms.py:retry_pending_payment`), sans jamais perdre les
informations déjà saisies.
`promoted_volunteer_id` : posé au moment du paiement confirmé (`PAID`) —
le `Volunteer` créé. Les champs nominatifs sont alors vidés (RGPD :
la même information vit désormais uniquement sur `Volunteer`), seuls
les champs de suivi du paiement restent. Une ligne jamais promue est
purgée après un délai (voir `_prune_stale_pending`)."""
__tablename__ = "pending_form_submission"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE"
)
first_name: str | None = None
last_name: str | None = None
email: str | None = None
phone: str | None = None
comment: str | None = None
companion_wish_note: str | None = None
available: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
position_wish_ids_json: str | None = None
artist_wish_ids_json: str | None = None
photo: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
photo_content_type: str | None = None
payment_label: str
payment_amount: float
requires_mecenat_info: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
mecenat_address: str | None = None
mecenat_city: str | None = None
mecenat_postal_code: str | None = None
mecenat_country: str | None = None
checkout_id: str | None = Field(default=None, index=True)
checkout_status: FeePaymentStatus | None = Field(
default=None,
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
)
promoted_volunteer_id: uuid.UUID | None = Field(
default=None, foreign_key="volunteer.id", ondelete="SET NULL"
)
class FormField(SQLModel, table=True):
"""Un champ activé sur le formulaire public (voir `FormFieldType`) — la
présence d'une ligne vaut activation. Pas de champs d'audit : juste une
case à cocher dans l'écran de configuration, remplacée en bloc (`PUT
.../form/fields`), rien d'autre à suivre dans le temps."""
__tablename__ = "form_field"
__table_args__ = (
UniqueConstraint(
"edition_id", "field_type", name="uq_form_field_edition_type"
),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE"
)
field_type: FormFieldType = Field(
sa_column=Column(SAEnum(FormFieldType, native_enum=False), nullable=False)
)
+72
View File
@@ -18,6 +18,13 @@ class AssignmentSource(enum.StrEnum):
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):
BAR = "BAR"
TICKETING = "TICKETING"
@@ -28,3 +35,68 @@ class PositionCategory(enum.StrEnum):
SETUP = "SETUP"
STAGE = "STAGE"
OTHER = "OTHER"
class BadgeFieldType(enum.StrEnum):
"""Champ affichable sur le recto ou le verso d'un badge (voir `app/badges/`)."""
FULL_NAME = "FULL_NAME"
POSITIONS = "POSITIONS"
CATEGORY = "CATEGORY"
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):
LEFT = "LEFT"
CENTER = "CENTER"
RIGHT = "RIGHT"
class FeePaymentStatus(enum.StrEnum):
"""Statut d'un paiement de cotisation via l'API SumUp (voir `app/sumup/`
et `app/api/forms.py`). Reprend tel quel le statut d'un checkout SumUp —
`NULL` sur `Volunteer.fee_payment_status` tant qu'aucun checkout n'a
jamais été créé pour ce bénévole."""
PENDING = "PENDING"
PAID = "PAID"
FAILED = "FAILED"
EXPIRED = "EXPIRED"
class FormFieldType(enum.StrEnum):
"""Champ activable sur le formulaire public d'inscription (voir
`app/api/forms.py`). Nom/prénom sont toujours collectés, pas au choix de
la coordination — ils sont indispensables à la création du bénévole."""
EMAIL = "EMAIL"
PHONE = "PHONE"
POSITION_WISHES = "POSITION_WISHES"
ARTIST_WISHES = "ARTIST_WISHES"
COMPANION_WISH = "COMPANION_WISH"
AVAILABLE = "AVAILABLE"
WILLING_TO_PAY_FEE = "WILLING_TO_PAY_FEE"
PHOTO = "PHOTO"
COMMENT = "COMMENT"
+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)
+51 -157
View File
@@ -9,9 +9,8 @@ Déterministe (graine fixe). Aucune donnée nominative réelle.
import datetime as dt
import random
from collections import Counter
from dataclasses import dataclass, field
from dataclasses import dataclass
from sqlalchemy import delete
from sqlmodel import Session, select
from app.auth.passwords import hash_password
@@ -22,9 +21,9 @@ from app.models import (
Artist,
Assignment,
AssignmentSource,
Availability,
DayPart,
Edition,
FeePaymentStatus,
Position,
PositionCategory,
Role,
@@ -34,14 +33,7 @@ from app.models import (
VolunteerCompanionWish,
VolunteerPositionWish,
)
from app.validation import (
AvailabilityWindow,
DayPartWindow,
Severity,
ShiftInfo,
ValidationInput,
validate,
)
from app.validation import Severity, validate
from app.validation.loader import build_validation_input
SEED = 20260709
@@ -203,6 +195,14 @@ ARTIST_SLOTS: list[tuple[dt.time, dt.time]] = [
(dt.time(21, 30), dt.time(23, 0)),
]
# Adresses postales des bénévoles « mécénat » — voir `_add_demo_payments`.
MECENAT_ADDRESSES = [
("12 rue des Lilas", "75020", "Paris"),
("3 impasse du Petit Bois", "44000", "Nantes"),
("8 avenue de la République", "69003", "Lyon"),
("21 chemin des Vignes", "33000", "Bordeaux"),
]
@dataclass
class Summary:
@@ -216,7 +216,8 @@ class Summary:
position_wishes: int = 0
artist_wishes: int = 0
companion_wishes: int = 0
warnings: Counter = field(default_factory=Counter)
fee_paid_classique: int = 0
fee_paid_mecenat: int = 0
blocking: int = 0
def __str__(self) -> str:
@@ -232,10 +233,9 @@ class Summary:
f" Vœux de poste déclarés : {self.position_wishes}",
f" Vœux d'artiste déclarés : {self.artist_wishes}",
f" Vœux de compagnon déclarés : {self.companion_wishes}",
f" Avertissements actifs : {sum(self.warnings.values())}",
f" Cotisation payée (classique) : {self.fee_paid_classique}",
f" Cotisation payée (mécénat) : {self.fee_paid_mecenat}",
]
for code, count in sorted(self.warnings.items()):
lines.append(f" {code} : {count}")
lines.append(f" Violations bloquantes : {self.blocking}")
lines.append("")
lines.append(
@@ -296,9 +296,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
]
session.add_all(day_parts)
session.flush()
dp_windows = tuple(
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order) for dp in day_parts
)
coordination = Account(
edition_id=edition.id,
@@ -415,27 +412,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
)
)
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] = []
for position, (_n, category, _l, _r) in zip(positions, POSITIONS, strict=True):
for day in dates:
@@ -457,28 +433,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
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
order = shifts[:]
rng.shuffle(order)
@@ -487,7 +441,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
target = max(0, shift.min_staff - 1)
else:
target = rng.randint(shift.min_staff, shift.ideal_staff)
pool = assignable[:]
pool = volunteers[:]
rng.shuffle(pool)
placed = 0
for volunteer in pool:
@@ -495,8 +449,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
break
if any(_overlap(shift, s) for s in assigned[volunteer.id]):
continue
if not _clean(volunteer, shift):
continue
session.add(
Assignment(
edition_id=edition.id,
@@ -509,9 +461,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
placed += 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)
session.flush()
fill: Counter = Counter()
@@ -537,103 +487,49 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
position_wishes=n_position_wishes,
artist_wishes=n_artist_wishes,
companion_wishes=n_companion_wishes,
fee_paid_classique=n_fee_classique,
fee_paid_mecenat=n_fee_mecenat,
)
_verify(session, edition, 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)
def _add_demo_payments(
session: Session, volunteers: list[Volunteer], rng: random.Random
) -> tuple[int, int]:
"""Quelques bénévoles ayant payé leur cotisation via SumUp — voir
CLAUDE.md § 2, Paiement de la cotisation. `fee_payment_label`/
`fee_amount` sont une copie figée (comme en production) : aucun
`FormPaymentOption` n'a besoin d'exister pour autant. Pour que l'export
Excel et les reçus fiscaux mécénat (`/editions/{id}/volunteers/
mecenat-receipts`) aient de quoi montrer quelque chose en démonstration."""
pool = rng.sample(volunteers, k=12)
classique, mecenat = pool[:8], pool[8:]
for volunteer in classique:
volunteer.fee_payment_label = "Cotisation classique"
volunteer.fee_amount = 15.0
volunteer.fee_payment_status = FeePaymentStatus.PAID
volunteer.fee_paid_at = dt.datetime.combine(
START - dt.timedelta(days=rng.randint(5, 45)), dt.time(rng.randint(9, 21))
)
availabilities[volunteer.id] = [
w for w in availabilities[volunteer.id] if w.date != day
]
session.add(volunteer)
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,
for i, volunteer in enumerate(mecenat):
address, postal_code, city = MECENAT_ADDRESSES[i % len(MECENAT_ADDRESSES)]
volunteer.fee_payment_label = "Mécénat"
volunteer.fee_amount = rng.choice([30.0, 50.0, 80.0, 120.0])
volunteer.fee_payment_status = FeePaymentStatus.PAID
volunteer.fee_paid_at = dt.datetime.combine(
START - dt.timedelta(days=rng.randint(5, 45)), dt.time(rng.randint(9, 21))
)
)
availabilities[volunteer.id].append(AvailabilityWindow(day, start, end))
volunteer.mecenat_address = address
volunteer.mecenat_postal_code = postal_code
volunteer.mecenat_city = city
volunteer.mecenat_country = "France"
session.add(volunteer)
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 _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)
return len(classique), len(mecenat)
def _verify(session: Session, edition: Edition, summary: Summary) -> None:
@@ -655,8 +551,6 @@ def _verify(session: Session, edition: Edition, summary: Summary) -> None:
):
if violation.severity is Severity.BLOCKING:
summary.blocking += 1
else:
summary.warnings[violation.code] += 1
def main() -> None:
+13
View File
@@ -0,0 +1,13 @@
from app.sheets.pdf import (
PositionDaySheet,
SheetShift,
SheetVolunteer,
generate_position_sheets_pdf,
)
__all__ = [
"PositionDaySheet",
"SheetShift",
"SheetVolunteer",
"generate_position_sheets_pdf",
]
+324
View File
@@ -0,0 +1,324 @@
"""Génération des feuilles de poste (PDF).
Module isolé, fonction pure (`generate_position_sheets_pdf`) : aucun accès
base ici — voir `app/api/position_sheets.py` pour l'assemblage des données.
Une page par poste et par jour, listant les créneaux du jour avec les
bénévoles affectés et leur téléphone — à imprimer et afficher sur place.
Complément des badges (`app/badges/pdf.py`) : un badge identifie un
bénévole, une feuille identifie un poste pour la journée.
"""
import datetime as dt
import io
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.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import (
Image,
PageBreak,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
# 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"]
_MONTHS_FR = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
]
def _format_day_fr(date: dt.date) -> str:
return f"{_WEEKDAYS_FR[date.weekday()]} {date.day} {_MONTHS_FR[date.month - 1]} {date.year}"
@dataclass(frozen=True)
class SheetVolunteer:
name: str
phone: str | None
photo: bytes | None = None
@dataclass(frozen=True)
class SheetArtist:
name: str
start_time: dt.time
end_time: dt.time
@dataclass(frozen=True)
class SheetShift:
starts_at: dt.datetime
ends_at: dt.datetime
min_staff: int
ideal_staff: int
volunteers: tuple[SheetVolunteer, ...]
@property
def understaffed(self) -> bool:
return len(self.volunteers) < self.min_staff
@dataclass(frozen=True)
class PositionDaySheet:
"""Une page : un poste, un jour, ses créneaux triés chronologiquement."""
position_name: str
position_location: str | None
date: dt.date
shifts: tuple[SheetShift, ...]
# None si le poste n'a pas (encore) de référent désigné
# (`Position.lead_account_id`).
referent_name: str | None = None
referent_phone: str | None = None
# Programmation du jour (tous postes confondus, pas seulement les
# artistes que les bénévoles affectés souhaitent voir) — repère utile sur
# place (affluence au bar, sécurité...), triée par heure de passage.
artists: tuple[SheetArtist, ...] = ()
def _styles() -> dict[str, ParagraphStyle]:
base = getSampleStyleSheet()
return {
"title": ParagraphStyle(
"SheetTitle", parent=base["Title"], alignment=0,
fontSize=18, leading=21, spaceBefore=0, spaceAfter=1,
),
"subtitle": ParagraphStyle(
"SheetSubtitle", parent=base["Normal"],
fontSize=10.5, leading=13, textColor=colors.grey, spaceAfter=2,
),
"referent": ParagraphStyle(
"SheetReferent", parent=base["Normal"],
fontSize=10.5, leading=13, fontName="Helvetica-Bold", spaceAfter=3,
),
"artists": ParagraphStyle(
"SheetArtists", parent=base["Normal"],
fontSize=8.5, leading=10.5, textColor=colors.HexColor("#4a4a4a"), spaceAfter=4,
),
"shiftHeader": ParagraphStyle(
"ShiftHeader", parent=base["Heading3"],
fontSize=11, leading=13, spaceBefore=7, spaceAfter=3,
),
"shiftHeaderWarning": ParagraphStyle(
"ShiftHeaderWarning", parent=base["Heading3"],
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"],
}
@dataclass(frozen=True)
class SheetFooter:
"""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)
return Image(io.BytesIO(data), width=width_px * scale, height=height_px * scale)
def _shift_table(
shift: SheetShift,
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
]
rows = []
for i in range(0, len(cells), _PER_ROW):
chunk = cells[i : i + _PER_ROW]
chunk += [["", ""]] * (_PER_ROW - len(chunk))
rows.append([cell for pair in chunk for cell in pair])
table = Table(rows, colWidths=[photo_w, text_w] * _PER_ROW)
style = [
("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"),
("LINEBELOW", (0, 0), (-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
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()
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(
buffer, pagesize=A4,
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 = []
thumbnails: dict[bytes, tuple[bytes, int, int] | None] = {}
for i, sheet in enumerate(sheets):
if i > 0:
story.append(PageBreak())
story.append(Paragraph(sheet.position_name, styles["title"]))
subtitle = _format_day_fr(sheet.date)
if sheet.position_location:
subtitle += f" — {sheet.position_location}"
story.append(Paragraph(subtitle, styles["subtitle"]))
if sheet.referent_name:
referent_line = f"Référent : {sheet.referent_name}"
if sheet.referent_phone:
referent_line += f" — {sheet.referent_phone}"
story.append(Paragraph(referent_line, styles["referent"]))
else:
story.append(Paragraph("Référent : à désigner", styles["referent"]))
if sheet.artists:
programme = " · ".join(
f"{a.start_time:%H:%M}–{a.end_time:%H:%M} {a.name}" for a in sheet.artists
)
story.append(Paragraph(f"Programmation : {programme}", styles["artists"]))
if not sheet.shifts:
story.append(Paragraph("Aucun créneau ce jour-là.", styles["normal"]))
continue
for shift in sheet.shifts:
header = (
f"{shift.starts_at:%H:%M} – {shift.ends_at:%H:%M}"
f" ({len(shift.volunteers)}/{shift.ideal_staff})"
)
if shift.understaffed:
header += " ⚠ sous l'effectif minimal"
style = styles["shiftHeaderWarning"] if shift.understaffed else styles["shiftHeader"]
story.append(Paragraph(header, style))
if shift.volunteers:
story.append(_shift_table(shift, thumbnails, styles, width))
else:
story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"]))
story.append(Spacer(1, 0))
doc.build(story, onFirstPage=draw_footer, onLaterPages=draw_footer)
return buffer.getvalue()
+27 -48
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
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
`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (disponibilité, repos,
chaînage entre créneaux consécutifs, vœu de poste, vœu de compagnon, vœu
d'artiste, équité de charge) est un terme de l'objectif, pas une contrainte —
comme le moteur de validation, qui reste seul juge au moment où la
coordination confirme une affectation.
Pas de plafond de cumul journalier côté solveur : `DAILY_HOURS_EXCEEDED`
reste vérifiée par la validation, à la confirmation.
Contrainte dure : jamais deux suggestions qui se chevauchent pour un même
bénévole dans un même calcul. Un bénévole déjà affecté à un autre poste au
même moment reste proposable (la validation ne fait qu'avertir,
`SHIFT_OVERLAP`) mais avec un fort malus. Tout le reste (chaînage entre
créneaux consécutifs, vœu de poste, vœu de compagnon, vœu d'artiste, équité de
charge) est un terme de l'objectif, pas une contrainte. Aucune limite d'heures
par journée ni de temps de repos : ni le solveur ni la validation n'en
tiennent compte.
"""
import datetime as dt
from ortools.sat.python import cp_model
from app.solver.models import (
@@ -26,22 +24,16 @@ from app.solver.models import (
SolverSolution,
SolverVolunteer,
)
from app.validation.engine import uncovered_minutes
from app.validation.models import ShiftInfo
_EIGHT_HOURS_MIN = 8 * 60
# Poids de l'objectif (unités arbitraires, cf. docstring de `_score`).
# 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
# à ce que la disponibilité soit imparfaite (avertissement, pas un blocage —
# la validation reste seule juge à la confirmation). Au-delà du minimum, un
# candidat mal assorti peut légitimement ne pas être suggéré du tout.
# à ce que le candidat soit mal assorti. Au-delà du minimum, un candidat mal
# assorti peut légitimement ne pas être suggéré du tout.
_URGENT_BASE = 1000 # créneau encore sous son effectif minimal
_NORMAL_BASE = 20 # créneau entre effectif minimal et idéal
_WISH_BONUS = 30
_AVAIL_PENALTY_PER_HALF_HOUR = 5
_REST_PENALTY = 60
_FAIRNESS_PENALTY_PER_HOUR = 2
# Le créneau lui ferait manquer le passage d'un artiste qu'il veut voir.
_ARTIST_CONFLICT_PENALTY = 80
@@ -49,30 +41,20 @@ _ARTIST_CONFLICT_PENALTY = 80
# n'importe quel poste) : limite les enchaînements de shifts plutôt que de
# faire tourner un poste en particulier.
_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`).
_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:
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:
"""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 :
@@ -87,20 +69,14 @@ def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int:
"""Score d'affinité (bénévole, créneau) : plus haut = meilleur candidat.
Purement additif (aucune interaction entre deux affectations proposées),
ce qui garde le modèle CP-SAT linéaire simple. Repos, chaînage et
compagnons ne sont vérifiés que contre les affectations déjà décidées,
pas entre deux suggestions du même calcul : la validation finale (à la
confirmation) reste seule juge en cas de cas limite entre deux
suggestions (et deux compagnons suggérés ensemble dans le même calcul ne
se renforcent pas mutuellement, faute d'interaction entre variables).
ce qui garde le modèle CP-SAT linéaire simple. Chaînage et compagnons ne
sont vérifiés que contre les affectations déjà décidées, pas entre deux
suggestions du même calcul (deux compagnons suggérés ensemble ne se
renforcent pas mutuellement, faute d'interaction entre variables).
"""
score = _URGENT_BASE if shift.already_assigned < shift.min_staff else _NORMAL_BASE
if shift.position_id in volunteer.wished_position_ids:
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):
score -= _CHAIN_PENALTY
companions_present = len(volunteer.wished_companion_ids & shift.assigned_volunteer_ids)
@@ -109,6 +85,8 @@ def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int:
_overlaps(shift.shift_info, passage) for passage in volunteer.wished_artist_windows
):
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
return score
@@ -129,7 +107,8 @@ def solve(
if gap <= 0:
continue
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
key = (vi, si)
variables[key] = model.NewBoolVar(f"x_{vi}_{si}")
@@ -146,8 +125,8 @@ def solve(
if shift_vars and gap > 0:
model.Add(sum(shift_vars) <= gap)
# Aucun chevauchement entre deux créneaux nouvellement suggérés au même
# bénévole (règle `SHIFT_OVERLAP`, bloquante).
# Jamais deux créneaux qui se chevauchent suggérés au même bénévole dans
# un même calcul (contre les affectations décidées : malus, cf. `_score`).
for vi in range(len(data.volunteers)):
my_shifts = [si for si in range(len(data.shifts)) if (vi, si) in variables]
for a in range(len(my_shifts)):
+13 -12
View File
@@ -9,7 +9,7 @@ from sqlmodel import Session, select
from app.models import (
Artist,
Assignment,
Availability,
DayPart,
Edition,
Shift,
Volunteer,
@@ -18,7 +18,8 @@ from app.models import (
VolunteerPositionWish,
)
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:
@@ -41,6 +42,11 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
)
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
# 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
@@ -49,7 +55,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
predecessor_ids: dict = {}
by_day: dict = defaultdict(list)
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():
ids_by_end: dict = defaultdict(list)
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
)
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)
for w in session.exec(
select(VolunteerPositionWish).join(
@@ -102,6 +100,9 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
):
wishes[w.volunteer_id].add(str(w.position_id))
# Traité comme réciproque pour le solveur : si A souhaite B, B profite
# aussi de la présence de A, même s'il ne l'a pas demandé (voir CLAUDE.md
# section 4).
companion_wishes: dict = defaultdict(set)
for w in session.exec(
select(VolunteerCompanionWish).join(
@@ -109,6 +110,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
).where(Volunteer.edition_id == edition.id)
):
companion_wishes[w.volunteer_id].add(str(w.companion_id))
companion_wishes[w.companion_id].add(str(w.volunteer_id))
artists_by_id = {
a.id: a
@@ -130,7 +132,6 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
solver_volunteers = tuple(
SolverVolunteer(
id=str(v.id),
availability=tuple(availabilities.get(v.id, ())),
wished_position_ids=frozenset(wishes.get(v.id, ())),
wished_companion_ids=frozenset(companion_wishes.get(v.id, ())),
wished_artist_windows=tuple(artist_wishes.get(v.id, ())),
+6 -5
View File
@@ -2,13 +2,13 @@
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
(`app.validation.models`, `app.validation.engine.uncovered_minutes`).
(`app.validation.models`).
"""
import datetime as dt
from dataclasses import dataclass, field
from app.validation.models import AvailabilityWindow, ShiftInfo
from app.validation.models import ShiftInfo
@dataclass(frozen=True)
@@ -39,16 +39,17 @@ class SolverShift:
@dataclass(frozen=True)
class SolverVolunteer:
id: str
availability: tuple[AvailabilityWindow, ...] = ()
wished_position_ids: frozenset[str] = frozenset()
# Bénévoles (0 à N) avec qui il voudrait être affecté — à sens unique.
# 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
# loader : si A souhaite B, l'ensemble de B contient aussi A.
wished_companion_ids: frozenset[str] = frozenset()
# Passages des artistes que le bénévole veut voir (heure de passage),
# sous forme de fenêtres datées : un créneau qui chevauche l'une d'elles
# lui ferait manquer le passage.
wished_artist_windows: tuple[ShiftInfo, ...] = ()
# 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, ...] = ()
# Total déjà affecté sur l'édition (minutes), pour répartir la charge.
existing_minutes: int = 0
+3
View File
@@ -0,0 +1,3 @@
from app.sumup.client import SumUpCheckout, create_checkout, get_checkout
__all__ = ["SumUpCheckout", "create_checkout", "get_checkout"]
+159
View File
@@ -0,0 +1,159 @@
"""Client de l'API SumUp — paiement de la cotisation (voir CLAUDE.md § 2,
`FormTemplate`, et `app/api/forms.py`).
`create_checkout`/`get_checkout` sont les points d'injection uniques pour les
tests (remplacés comme `app.forms.captcha.verify_hcaptcha`). Best effort,
comme l'envoi d'emails : toute erreur réseau/HTTP est journalisée (code/
classe d'exception seulement, jamais de PII) et renvoyée comme `None` —
l'inscription au formulaire public n'échoue jamais pour autant.
"""
import logging
import time
from dataclasses import dataclass
import httpx
logger = logging.getLogger("app.sumup")
_BASE_URL = "https://api.sumup.com"
_TIMEOUT_SECONDS = 10.0
@dataclass(frozen=True)
class SumUpCheckout:
id: str
status: str # PENDING / PAID / FAILED / EXPIRED (voir FeePaymentStatus)
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:
started = time.monotonic()
try:
resp = httpx.request(
method,
f"{_BASE_URL}{path}",
json=json,
headers={"Authorization": f"Bearer {api_key}"},
timeout=_TIMEOUT_SECONDS,
)
resp.raise_for_status()
data = resp.json()
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(
"Échec de l'appel API SumUp (%s %s) : %s", method, path, exc.__class__.__name__
)
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:
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(
id=data["id"],
status=data["status"],
hosted_checkout_url=data.get("hosted_checkout_url"),
transaction_code=transaction_code,
transaction_id=transaction_id,
)
def create_checkout(
*,
api_key: str,
merchant_code: str,
amount: float,
currency: str,
checkout_reference: str,
description: str,
return_url: str,
redirect_url: str,
) -> SumUpCheckout | None:
"""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,
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(
"POST",
"/v0.1/checkouts",
api_key,
json={
"checkout_reference": checkout_reference,
"amount": amount,
"currency": currency,
"merchant_code": merchant_code,
"description": description,
"return_url": return_url,
"redirect_url": redirect_url,
"hosted_checkout": {"enabled": True},
},
)
return _to_checkout(data) if data is not None else None
def get_checkout(*, api_key: str, checkout_id: str) -> SumUpCheckout | None:
"""Relit l'état réel d'un checkout — un webhook SumUp ne donne qu'un
identifiant, jamais un statut de confiance en lui-même (voir la doc
SumUp sur les webhooks et `app/api/forms.py:sumup_webhook`)."""
data = _call("GET", f"/v0.1/checkouts/{checkout_id}", api_key)
return _to_checkout(data) if data is not None else None
-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.models import (
AvailabilityWindow,
DayPartWindow,
Severity,
ShiftInfo,
@@ -15,7 +14,6 @@ from app.validation.models import (
)
__all__ = [
"AvailabilityWindow",
"DayPartWindow",
"Severity",
"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
from app.validation.models import (
AvailabilityWindow,
Severity,
ShiftInfo,
ValidationInput,
@@ -15,15 +14,10 @@ from app.validation.models import (
ViolationCode,
)
_EIGHT_HOURS = dt.timedelta(hours=8)
def validate(data: ValidationInput) -> list[Violation]:
violations: list[Violation] = []
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
@@ -32,7 +26,7 @@ def has_blocking(violations: list[Violation]) -> bool:
# --------------------------------------------------------------------------- #
# SHIFT_OVERLAP (bloquante) #
# SHIFT_OVERLAP (avertissement) #
# --------------------------------------------------------------------------- #
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
# 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]:
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)
_MINUTES_PER_DAY = 24 * 60
def uncovered_minutes(
shift: ShiftInfo, windows: tuple[AvailabilityWindow, ...]
) -> int:
"""Minutes du créneau qui ne tombent dans aucune fenêtre de disponibilité.
Le bénévole peut déclarer plusieurs fenêtres ; on regarde leur union.
"""
cursor = shift.starts_at
intervals = sorted(
(iv for iv in map(_window_interval, windows) if iv[1] > iv[0]),
key=lambda iv: iv[0],
)
gap = dt.timedelta()
for start, end in intervals:
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:
def day_boundary(day_parts) -> dt.time:
"""Début d'une « journée de festival » : début du plus long creux que
laissent les créneaux types (`DayPart`) sur le cadran de 24 h — l'heure à
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
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
commencé. Indépendant de `order`, qui n'est qu'un ordre d'affichage et
ne suit pas les heures quand on les modifie (un créneau type d'ordre 0
déplacé à 21 h faisait commencer chaque journée à 21 h). Sans creux (les
créneaux types couvrent 24 h), début du premier par `order`. Accepte
aussi bien les `DayPartInfo` de ce module que les lignes `DayPart`.
Miroir front : `lib/planning.ts:dayBoundaryFromDayParts`."""
day_parts = list(day_parts)
if not day_parts:
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
# 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:
return moment.date()
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 (
Assignment,
Availability,
DayPart,
Edition,
Shift,
Volunteer,
)
from app.validation.models import (
AvailabilityWindow,
DayPartWindow,
ShiftInfo,
ValidationInput,
)
@@ -30,10 +26,6 @@ def build_validation_input(
shift: Shift,
exclude_assignment_id: uuid.UUID | None = None,
) -> 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
# décision, toujours active).
stmt = (
@@ -49,17 +41,6 @@ def build_validation_input(
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(
candidate_shift=ShiftInfo(
id=str(shift.id),
@@ -67,9 +48,4 @@ def build_validation_input(
ends_at=shift.ends_at,
),
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):
SHIFT_OVERLAP = "SHIFT_OVERLAP"
OUTSIDE_AVAILABILITY = "OUTSIDE_AVAILABILITY"
DAILY_HOURS_EXCEEDED = "DAILY_HOURS_EXCEEDED"
INSUFFICIENT_REST = "INSUFFICIENT_REST"
SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
ViolationCode.SHIFT_OVERLAP: Severity.BLOCKING,
ViolationCode.OUTSIDE_AVAILABILITY: Severity.WARNING,
ViolationCode.DAILY_HOURS_EXCEEDED: Severity.WARNING,
ViolationCode.INSUFFICIENT_REST: Severity.WARNING,
# Avertissement seulement : un bénévole peut tenir deux postes à la fois
# (le solveur l'évite par un malus, cf. `app.solver.engine`).
ViolationCode.SHIFT_OVERLAP: Severity.WARNING,
}
@@ -53,8 +49,8 @@ class ShiftInfo:
@dataclass(frozen=True)
class DayPartWindow:
"""Créneau type. Ne sert plus qu'à fixer la borne de « journée » pour le
cumul des 8 h (début du premier créneau type)."""
"""Créneau type. Ne sert plus qu'à fixer la borne de « journée de
festival » (`engine.day_boundary`)."""
code: str
start_time: dt.time
@@ -63,18 +59,6 @@ class DayPartWindow:
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)
class ValidationInput:
"""Tout ce qu'il faut pour valider une affectation candidate.
@@ -85,5 +69,3 @@ class ValidationInput:
candidate_shift: ShiftInfo
existing_shifts: tuple[ShiftInfo, ...] = ()
availabilities: tuple[AvailabilityWindow, ...] = ()
day_parts: tuple[DayPartWindow, ...] = ()
+9 -2
View File
@@ -12,16 +12,23 @@ dependencies = [
"pydantic-settings>=2.6",
"python-multipart>=0.0.12",
"openpyxl>=3.1",
"pyjwt>=2.9",
# >=2.15 : aucune des CVE corrigées (toutes autour de PyJWKClient/JWKS,
# jamais utilisés ici — seul HS256 à secret symétrique statique est en
# jeu, voir app/auth/tokens.py) n'est exploitable dans cette appli, mais
# autant ne pas rester sur une version listée vulnérable sans raison.
"pyjwt>=2.15",
"bcrypt>=4.1",
"ortools>=9.11",
"reportlab>=4.2",
"pypdf>=5.1",
"pillow>=11.0",
"httpx>=0.27",
]
[dependency-groups]
dev = [
"pytest>=8.3",
"pytest-cov>=6.0",
"httpx>=0.27",
"ruff>=0.8",
]
+18
View File
@@ -7,6 +7,12 @@ from sqlmodel import Session, SQLModel, create_engine, select
from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata)
from app.auth.deps import get_current_account
from app.auth.rate_limit import (
form_submission_limiter,
login_limiter,
magic_link_limiter,
pending_status_limiter,
)
from app.config import get_settings
from app.db import get_session
from app.main import app
@@ -23,6 +29,18 @@ def _never_use_real_smtp(monkeypatch):
get_settings.cache_clear()
@pytest.fixture(autouse=True)
def _reset_rate_limiters():
"""Les limiteurs de `/auth/login` et `/auth/magic-link` sont des
singletons en mémoire (voir `app/auth/rate_limit.py`) : un test qui en
déclenche un ne doit pas polluer les suivants."""
login_limiter.reset()
magic_link_limiter.reset()
form_submission_limiter.reset()
pending_status_limiter.reset()
yield
@pytest.fixture(scope="session")
def engine():
"""Vraie base PostgreSQL de test (voir docker/postgres-init)."""
-30
View File
@@ -4,7 +4,6 @@ import datetime as dt
from app.models import (
Artist,
Availability,
Position,
PositionCategory,
Shift,
@@ -74,34 +73,5 @@ def make_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:
return dt.datetime(2026, 7, day, hour, minute)
+10
View File
@@ -0,0 +1,10 @@
"""Aide de test : les messages capturés par `outbox` (conftest.py) sont
`multipart/alternative` depuis l'ajout d'une version HTML (voir
`app/email/sender.py:build_message`) — `.get_content()` ne suffit plus à lui
seul, il faut préciser quelle partie lire."""
from email.message import EmailMessage
def plain_text(message: EmailMessage) -> str:
return message.get_body(preferencelist=("plain",)).get_content()
+51
View File
@@ -0,0 +1,51 @@
import uuid
from app.models import Role
def _url(edition, suffix=""):
return f"/editions/{edition.id}/accounts{suffix}"
def test_promotes_a_volunteer_account_to_lead(client, edition, make_account):
account = make_account(Role.VOLUNTEER, email="v@ex.fr")
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "LEAD"})
assert resp.status_code == 200
assert resp.json()["role"] == "LEAD"
assert resp.json()["auth_method"] == "magic_link"
def test_demotes_a_lead_account_to_volunteer(client, edition, make_account):
account = make_account(Role.LEAD, email="l@ex.fr")
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "VOLUNTEER"})
assert resp.status_code == 200
assert resp.json()["role"] == "VOLUNTEER"
def test_cannot_change_role_of_a_coordinator_account(client, edition, make_account):
account = make_account(Role.COORDINATOR, email="c@ex.fr", password="password123")
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "LEAD"})
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "account.role_change_not_allowed"
def test_cannot_promote_a_volunteer_to_coordinator(client, edition, make_account):
account = make_account(Role.VOLUNTEER, email="v2@ex.fr")
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "COORDINATOR"})
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "account.role_change_not_allowed"
def test_unknown_account_is_404(client, edition):
assert (
client.patch(_url(edition, f"/{uuid.uuid4()}"), json={"role": "LEAD"}).status_code
== 404
)
def test_volunteer_role_forbidden(edition, make_account, as_account):
volunteer_account = make_account(Role.VOLUNTEER, email="v3@ex.fr")
other = make_account(Role.VOLUNTEER, email="v4@ex.fr")
api = as_account(volunteer_account)
resp = api.patch(_url(edition, f"/{other.id}"), json={"role": "LEAD"})
assert resp.status_code == 403
+27 -28
View File
@@ -1,7 +1,6 @@
import datetime as dt
from tests.factories import (
declare_available_all_day,
dt_at,
make_position,
make_shift,
@@ -13,16 +12,12 @@ def _url(edition, suffix=""):
return f"/editions/{edition.id}/assignments{suffix}"
def _setup(session, edition, *, available=True):
def _setup(session, edition):
position = make_position(session, edition)
shift = make_shift(
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18)
)
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
@@ -39,19 +34,28 @@ def test_create_assignment_clean(client, edition, session, day_parts):
assert body["assignment"]["locked"] is False
def test_create_assignment_returns_warnings(client, edition, session, day_parts):
# Bénévole non déclaré disponible sur le créneau.
volunteer, shift = _setup(session, edition, available=False)
def test_long_day_without_rest_raises_no_warning(client, edition, session, day_parts):
# Plus de limite d'heures par jour ni de repos minimal : 14h–18h puis
# 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(
_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
codes = {w["code"] for w in resp.json()["warnings"]}
assert "OUTSIDE_AVAILABILITY" in codes
assert resp.json()["warnings"] == []
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)
assert client.post(
_url(edition),
@@ -69,15 +73,14 @@ def test_create_assignment_blocked_by_overlap(client, edition, session, day_part
"shift_id": str(overlapping.id),
},
)
assert resp.status_code == 422
detail = resp.json()["detail"]
assert detail["code"] == "error.validation_failed"
assert any(v["code"] == "SHIFT_OVERLAP" for v in detail["violations"])
# Rien n'a été enregistré pour ce créneau.
assert resp.status_code == 201
warnings = resp.json()["warnings"]
assert [w["code"] for w in warnings] == ["SHIFT_OVERLAP"]
assert warnings[0]["severity"] == "WARNING"
listed = client.get(
_url(edition), params={"shift_id": str(overlapping.id)}
).json()
assert listed == []
assert len(listed) == 1
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):
volunteer, shift = _setup(session, edition, available=False)
volunteer, shift = _setup(session, edition)
resp = client.post(
_url(edition, "/validate"),
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
body = resp.json()
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() == []
def test_patch_locked_keeps_assignment_and_returns_warnings(
client, edition, session, day_parts
):
volunteer, shift = _setup(session, edition, available=False)
def test_patch_locked_keeps_assignment(client, edition, session, day_parts):
volunteer, shift = _setup(session, edition)
aid = client.post(
_url(edition),
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})
assert resp.status_code == 200
assert resp.json()["assignment"]["locked"] is True
assert any(
w["code"] == "OUTSIDE_AVAILABILITY" for w in resp.json()["warnings"]
)
assert resp.json()["warnings"] == []
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
+115 -5
View File
@@ -2,6 +2,7 @@ from sqlmodel import select
from app.models import Assignment, MagicLinkToken, Role
from tests.factories import dt_at, make_position, make_shift, make_volunteer
from tests.mail import plain_text
def _url(edition):
@@ -38,17 +39,17 @@ def test_one_email_per_volunteer_with_an_address(client, edition, session, outbo
by_to = {m["To"]: m for m in outbox}
assert by_to["ana@e.fr"]["Subject"] == "Planning de Ana"
ana_body = by_to["ana@e.fr"].get_content()
ana_body = plain_text(by_to["ana@e.fr"])
assert "Tu as 2 créneau(x), 7.0 h." in ana_body
assert "Bar" in ana_body
assert "Tu as 0 créneau(x), 0.0 h." in by_to["bob@e.fr"].get_content()
assert "Tu as 0 créneau(x), 0.0 h." in plain_text(by_to["bob@e.fr"])
def test_default_template_is_used_when_body_omitted(client, edition, session, outbox):
make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
resp = client.post(_url(edition), json={})
assert resp.status_code == 200
assert outbox[0].get_content().startswith("Bonjour Ana,")
assert plain_text(outbox[0]).startswith("Bonjour Ana,")
def test_magic_link_is_embedded_when_placeholder_present(
@@ -61,8 +62,9 @@ def test_magic_link_is_embedded_when_placeholder_present(
_url(edition), json={"body": "Connecte-toi : {lien_connexion}"}
)
assert resp.status_code == 200
assert "/connexion?token=" in outbox[0].get_content()
assert len(session.exec(select(MagicLinkToken)).all()) == 1
assert "/connexion?token=pl." in plain_text(outbox[0])
# Lien permanent : rien de stocké, pas de jeton à usage unique.
assert session.exec(select(MagicLinkToken)).all() == []
def test_no_magic_link_token_without_placeholder(
@@ -90,3 +92,111 @@ def test_planning_notification_is_coordinator_only(
lead = make_account(Role.LEAD, email="lead@e.fr")
resp = as_account(lead).post(_url(edition), json={})
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
+74
View File
@@ -1,5 +1,7 @@
import uuid
from app.models import Role
def _url(edition, suffix=""):
return f"/editions/{edition.id}/positions{suffix}"
@@ -35,3 +37,75 @@ def test_unknown_edition_is_404(client):
assert (
client.get(f"/editions/{uuid.uuid4()}/positions").status_code == 404
)
def test_sets_a_referent(client, edition, make_account):
lead = make_account(Role.LEAD, email="referent@ex.fr")
created = client.post(
_url(edition),
json={"name": "Bar", "category": "BAR"},
)
pid = created.json()["id"]
resp = client.patch(_url(edition, f"/{pid}"), json={"lead_account_id": str(lead.id)})
assert resp.status_code == 200
assert resp.json()["lead_account_id"] == str(lead.id)
def test_referent_must_have_the_lead_role(client, edition, make_account):
volunteer_account = make_account(Role.VOLUNTEER, email="v@ex.fr")
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
pid = created.json()["id"]
resp = client.patch(
_url(edition, f"/{pid}"), json={"lead_account_id": str(volunteer_account.id)}
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "position.lead_must_be_referent_account"
def test_referent_must_belong_to_the_same_edition(client, edition, session, make_account):
import datetime as dt
from app.models import Edition
other_edition = Edition(year=2027, start_date=dt.date(2027, 7, 1), end_date=dt.date(2027, 7, 4))
session.add(other_edition)
session.commit()
session.refresh(other_edition)
other_lead = make_account(Role.LEAD, email="stranger@ex.fr")
other_lead.edition_id = other_edition.id
session.add(other_lead)
session.commit()
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
pid = created.json()["id"]
resp = client.patch(
_url(edition, f"/{pid}"), json={"lead_account_id": str(other_lead.id)}
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "position.lead_must_be_referent_account"
def test_unknown_referent_is_422(client, edition):
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
pid = created.json()["id"]
resp = client.patch(
_url(edition, f"/{pid}"), json={"lead_account_id": str(uuid.uuid4())}
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "position.lead_must_be_referent_account"
def test_clearing_the_referent_is_allowed(client, edition, make_account):
lead = make_account(Role.LEAD, email="referent2@ex.fr")
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
pid = created.json()["id"]
client.patch(_url(edition, f"/{pid}"), json={"lead_account_id": str(lead.id)})
resp = client.patch(_url(edition, f"/{pid}"), json={"lead_account_id": None})
assert resp.status_code == 200
assert resp.json()["lead_account_id"] is None
+32
View File
@@ -102,6 +102,38 @@ def test_prefers_volunteer_whose_wished_companion_is_already_on_the_shift(
assert str(stranger.id) not in suggestion["volunteer_ids"]
def test_companion_bonus_applies_even_to_the_one_who_did_not_ask(client, edition, session):
# Le vœu de compagnon est à sens unique en base, mais traité comme
# réciproque par le solveur : « wisher » (déjà affecté) a souhaité être
# avec « wished », qui lui n'a rien demandé — il en profite quand même.
from app.models import Assignment
position = make_position(session, edition)
shift = make_shift(
session, edition, position,
start=dt_at(10, 14), end=dt_at(10, 18), min_staff=1, ideal_staff=2,
)
wisher = make_volunteer(session, edition, email="wisher@ex.fr")
session.add(
Assignment(edition_id=edition.id, volunteer_id=wisher.id, shift_id=shift.id)
)
session.commit()
wished = make_volunteer(session, edition, email="wished@ex.fr")
assert client.patch(
f"/editions/{edition.id}/volunteers/{wisher.id}",
json={"wished_companion_ids": [str(wished.id)]},
).status_code == 200
stranger = make_volunteer(session, edition, email="stranger@ex.fr")
resp = client.post(_url(edition, "/suggestions"))
suggestion = next(
s for s in resp.json()["suggestions"] if s["shift_id"] == str(shift.id)
)
assert suggestion["volunteer_ids"] == [str(wished.id)]
assert str(stranger.id) not in suggestion["volunteer_ids"]
def test_chain_penalty_applies_across_different_positions(client, edition, session):
from app.models import Assignment, PositionCategory
+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)
+444 -11
View File
@@ -1,13 +1,15 @@
import datetime as dt
import io
import uuid
from openpyxl import load_workbook
from pypdf import PdfReader
from sqlmodel import select
from app.models import Assignment, Availability, Volunteer
from app.models import Assignment, FeePaymentStatus, Role, Volunteer
from tests.factories import (
dt_at,
make_artist,
make_availability,
make_position,
make_shift,
make_volunteer,
@@ -205,14 +207,6 @@ def test_delete_is_a_full_cascade(client, edition, session):
shift = make_shift(
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(
Assignment(
edition_id=edition.id,
@@ -227,8 +221,447 @@ def test_delete_is_a_full_cascade(client, edition, session):
assert client.get(_url(edition, f"/{volunteer.id}")).status_code == 404
assert session.exec(select(Volunteer)).all() == []
assert session.exec(select(Assignment)).all() == []
assert session.exec(select(Availability)).all() == []
# Le créneau subsiste.
from app.models import Shift
assert session.get(Shift, shift.id) is not None
# --------------------------------------------------------------------------- #
# Planning enrichi (poste, lieu, référent) — écran bénévole #
# --------------------------------------------------------------------------- #
def test_planning_is_empty_without_assignment(client, edition, session):
volunteer = make_volunteer(session, edition, email="v@ex.fr")
resp = client.get(_url(edition, f"/{volunteer.id}/planning"))
assert resp.status_code == 200
assert resp.json() == []
def test_planning_includes_position_and_referent(client, edition, session):
from app.models import Account
volunteer = make_volunteer(session, edition, email="v@ex.fr")
referent_volunteer = make_volunteer(
session, edition, first_name="Rita", last_name="Referente", email="r@ex.fr"
)
referent_volunteer.phone = "0600000000"
session.add(referent_volunteer)
referent_account = Account(
edition_id=edition.id,
email="rita@ex.fr",
role=Role.LEAD,
volunteer_id=referent_volunteer.id,
)
session.add(referent_account)
session.commit()
session.refresh(referent_account)
position = make_position(session, edition, name="Bar central")
position.location = "Chapiteau"
position.lead_account_id = referent_account.id
session.add(position)
session.commit()
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()
resp = client.get(_url(edition, f"/{volunteer.id}/planning"))
assert resp.status_code == 200
body = resp.json()
assert len(body) == 1
entry = body[0]
assert entry["shift_id"] == str(shift.id)
assert entry["position_name"] == "Bar central"
assert entry["position_location"] == "Chapiteau"
assert entry["referent_name"] == "Rita Referente"
assert entry["referent_phone"] == "0600000000"
def test_planning_referent_fields_are_none_without_referent(
client, edition, session
):
volunteer = make_volunteer(session, edition, email="v@ex.fr")
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()
resp = client.get(_url(edition, f"/{volunteer.id}/planning"))
entry = resp.json()[0]
assert entry["referent_name"] is None
assert entry["referent_phone"] is None
def test_planning_referent_fields_are_none_when_lead_has_no_volunteer_record(
client, edition, session
):
"""Un référent peut être un compte LEAD sans fiche bénévole associée :
pas de nom/téléphone à afficher dans ce cas."""
from app.models import Account
volunteer = make_volunteer(session, edition, email="v@ex.fr")
referent_account = Account(
edition_id=edition.id, email="lead@ex.fr", role=Role.LEAD
)
session.add(referent_account)
session.commit()
session.refresh(referent_account)
position = make_position(session, edition)
position.lead_account_id = referent_account.id
session.add(position)
session.commit()
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()
resp = client.get(_url(edition, f"/{volunteer.id}/planning"))
entry = resp.json()[0]
assert entry["referent_name"] is None
assert entry["referent_phone"] is None
def test_planning_is_ordered_by_start_time(client, edition, session):
volunteer = make_volunteer(session, edition, email="v@ex.fr")
position = make_position(session, edition)
late = make_shift(
session, edition, position, start=dt_at(11, 14), end=dt_at(11, 18)
)
early = make_shift(
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
)
session.add_all(
[
Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=late.id),
Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=early.id),
]
)
session.commit()
resp = client.get(_url(edition, f"/{volunteer.id}/planning"))
body = resp.json()
assert [e["shift_id"] for e in body] == [str(early.id), str(late.id)]
def test_planning_is_404_for_unknown_volunteer(client, edition):
resp = client.get(_url(edition, f"/{uuid.uuid4()}/planning"))
assert resp.status_code == 404
def test_volunteer_can_read_own_planning_but_not_anothers(
session, edition, 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="me@e.fr")
client = as_account(account)
assert client.get(_url(edition, f"/{me.id}/planning")).status_code == 200
assert client.get(_url(edition, f"/{other.id}/planning")).status_code == 403
def test_lead_can_read_any_volunteers_planning(
session, edition, make_account, as_account
):
other = make_volunteer(session, edition, email="other@e.fr")
lead_account = make_account(Role.LEAD)
client = as_account(lead_account)
assert client.get(_url(edition, f"/{other.id}/planning")).status_code == 200
def test_export_volunteers_as_excel(client, edition, session):
position = make_position(session, edition, name="Bar central")
artist = make_artist(session, edition, name="Chilla")
volunteer = make_volunteer(
session, edition, first_name="Alex", last_name="Martin", email="alex@ex.fr"
)
client.patch(
_url(edition, f"/{volunteer.id}"),
json={
"wished_position_ids": [str(position.id)],
"artist_ids": [str(artist.id)],
},
)
resp = client.get(_url(edition, "/export"))
assert resp.status_code == 200
assert resp.headers["content-type"] == (
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
assert f"benevoles-{edition.year}.xlsx" in resp.headers["content-disposition"]
workbook = load_workbook(io.BytesIO(resp.content))
sheet = workbook.active
rows = list(sheet.iter_rows(values_only=True))
assert rows[0][0] == "Prénom"
row = rows[1]
assert row[0] == "Alex"
assert row[1] == "Martin"
assert row[2] == "alex@ex.fr"
assert row[5] == "Bar central"
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):
# CWE-1236 : un bénévole inscrit via le formulaire public (sans
# authentification) contrôle son nom, son commentaire, etc. — ouverts
# tels quels dans Excel par la coordination, une valeur comme
# `=cmd|'/c calc'!A0` y serait exécutée comme une formule. Un préfixe
# `'` force l'interprétation en texte (voir `app/exporting/xlsx.py`).
volunteer = make_volunteer(
session, edition, first_name="=cmd|'/c calc'!A0", last_name="Martin"
)
client.patch(
_url(edition, f"/{volunteer.id}"),
json={"comment": "@SUM(1+1)*cmd|' /C calc'!A0"},
)
resp = client.get(_url(edition, "/export"))
workbook = load_workbook(io.BytesIO(resp.content))
header, row = list(workbook.active.iter_rows(values_only=True))[:2]
assert row[0] == "'=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):
lead = make_account(Role.LEAD, email="lead@e.fr")
resp = as_account(lead).get(_url(edition, "/export"))
assert resp.status_code == 403
def _mark_mecenat_paid(session, volunteer, *, amount=50.0, paid_at=dt.datetime(2026, 3, 5, 10, 0)):
"""Simule l'état d'un bénévole ayant payé un type de paiement « mécénat »
(voir `app/api/forms.py:_promote_pending`) — `mecenat_address` est le
marqueur qui distingue ce chemin d'un simple paiement classique."""
volunteer.fee_payment_status = FeePaymentStatus.PAID
volunteer.fee_amount = amount
volunteer.fee_paid_at = paid_at
volunteer.mecenat_address = "12 rue des Lilas"
volunteer.mecenat_city = "Paris"
volunteer.mecenat_postal_code = "75020"
volunteer.mecenat_country = "France"
session.add(volunteer)
session.commit()
def test_mecenat_receipts_pdf_includes_only_paid_mecenat_donors(
client, edition, session
):
paid_mecenat = make_volunteer(
session, edition, first_name="Alex", last_name="Martin"
)
_mark_mecenat_paid(session, paid_mecenat)
# Cotisation classique payée (pas de mécénat) : ne doit pas apparaître.
paid_classique = make_volunteer(
session, edition, first_name="Sam", last_name="Petit"
)
paid_classique.fee_payment_status = FeePaymentStatus.PAID
paid_classique.fee_amount = 15.0
paid_classique.fee_paid_at = dt.datetime(2026, 3, 5, 10, 0)
session.add(paid_classique)
session.commit()
# Mécénat choisi mais paiement non abouti : ne doit pas apparaître.
failed_mecenat = make_volunteer(
session, edition, first_name="Jo", last_name="Dupuis"
)
failed_mecenat.fee_payment_status = FeePaymentStatus.FAILED
failed_mecenat.mecenat_address = "1 rue X"
session.add(failed_mecenat)
session.commit()
resp = client.get(_url(edition, "/mecenat-receipts"))
assert resp.status_code == 200
assert resp.headers["content-type"] == "application/pdf"
assert f"recus-mecenat-{edition.year}.pdf" in resp.headers["content-disposition"]
reader = PdfReader(io.BytesIO(resp.content))
# Un reçu = 2 pages (le cerfa officiel) — un seul bénévole qualifie ici.
assert len(reader.pages) == 2
donor_page_text = reader.pages[1].extract_text()
assert "Martin" in donor_page_text
assert "Alex" in donor_page_text
assert "50,00" in donor_page_text
assert "05/03/2026" in donor_page_text
assert "Petit" not in donor_page_text
def test_mecenat_receipts_are_numbered_by_payment_year_and_order(client, edition, session):
later = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
_mark_mecenat_paid(session, later, paid_at=dt.datetime(2026, 4, 1, 9, 0))
earlier = make_volunteer(session, edition, first_name="Sam", last_name="Petit")
_mark_mecenat_paid(session, earlier, paid_at=dt.datetime(2026, 3, 1, 9, 0))
resp = client.get(_url(edition, "/mecenat-receipts"))
assert resp.status_code == 200
session.refresh(later)
session.refresh(earlier)
# Attribués dans l'ordre des paiements, pas de l'ordre alphabétique.
assert earlier.mecenat_receipt_number == "202600001"
assert later.mecenat_receipt_number == "202600002"
reader = PdfReader(io.BytesIO(resp.content))
# Trié par nom : Martin (pages 1-2) puis Petit (pages 3-4).
assert "202600002" in reader.pages[0].extract_text()
assert "202600001" in reader.pages[2].extract_text()
def test_mecenat_receipt_number_is_stable_across_regenerations(client, edition, session):
volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
_mark_mecenat_paid(session, volunteer)
client.get(_url(edition, "/mecenat-receipts"))
session.refresh(volunteer)
first = volunteer.mecenat_receipt_number
newcomer = make_volunteer(session, edition, first_name="Sam", last_name="Petit")
_mark_mecenat_paid(session, newcomer)
client.get(_url(edition, "/mecenat-receipts"))
session.refresh(volunteer)
session.refresh(newcomer)
assert volunteer.mecenat_receipt_number == first
assert newcomer.mecenat_receipt_number == "202600002"
def test_mecenat_receipt_numbers_continue_the_existing_sequence(client, edition, session):
numbered = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
_mark_mecenat_paid(session, numbered)
numbered.mecenat_receipt_number = "202600041"
session.add(numbered)
session.commit()
other = make_volunteer(session, edition, first_name="Sam", last_name="Petit")
_mark_mecenat_paid(session, other)
# Une autre année repart à 1.
next_year = make_volunteer(session, edition, first_name="Jo", last_name="Dupuis")
_mark_mecenat_paid(session, next_year, paid_at=dt.datetime(2027, 1, 2, 9, 0))
client.get(_url(edition, "/mecenat-receipts"))
session.refresh(other)
session.refresh(next_year)
assert other.mecenat_receipt_number == "202600042"
assert next_year.mecenat_receipt_number == "202700001"
def test_mecenat_receipts_is_422_without_any_paid_donor(client, edition):
resp = client.get(_url(edition, "/mecenat-receipts"))
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "mecenat.no_receipts"
def test_mecenat_receipts_is_coordinator_only(
edition, session, make_account, as_account
):
volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
_mark_mecenat_paid(session, volunteer)
lead = make_account(Role.LEAD, email="lead@e.fr")
resp = as_account(lead).get(_url(edition, "/mecenat-receipts"))
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

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