Compare commits
19
Commits
f565feb3bc
...
26a10cd595
@@ -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,
|
# Alternative OVH si le 587/STARTTLS échoue : SMTP_PORT=465, SMTP_SSL=true,
|
||||||
# SMTP_STARTTLS=false
|
# 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).
|
# Frontend (service `frontend` du docker-compose).
|
||||||
# mock (défaut) : jeu de démonstration en mémoire, aucun backend requis.
|
# 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
|
# http : API réelle — renseigner alors VITE_EDITION_ID avec l'UUID
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
# DEPLOY_PATH dossier du projet sur le VPS (ex. /opt/benevoles)
|
# DEPLOY_PATH dossier du projet sur le VPS (ex. /opt/benevoles)
|
||||||
# VITE_EDITION_ID UUID de l'édition de production, à renseigner APRÈS
|
# VITE_EDITION_ID UUID de l'édition de production, à renseigner APRÈS
|
||||||
# la toute première mise en service (voir deploy/README.md)
|
# 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
|
name: ci-cd
|
||||||
|
|
||||||
@@ -122,6 +125,7 @@ jobs:
|
|||||||
docker build \
|
docker build \
|
||||||
--build-arg VITE_API_MODE=http \
|
--build-arg VITE_API_MODE=http \
|
||||||
--build-arg VITE_EDITION_ID="${{ vars.VITE_EDITION_ID }}" \
|
--build-arg VITE_EDITION_ID="${{ vars.VITE_EDITION_ID }}" \
|
||||||
|
--build-arg VITE_HCAPTCHA_SITE_KEY="${{ vars.VITE_HCAPTCHA_SITE_KEY }}" \
|
||||||
-f frontend/Dockerfile.prod \
|
-f frontend/Dockerfile.prod \
|
||||||
-t "$IMAGE:${{ gitea.sha }}" -t "$IMAGE:latest" frontend
|
-t "$IMAGE:${{ gitea.sha }}" -t "$IMAGE:latest" frontend
|
||||||
docker push "$IMAGE:${{ gitea.sha }}"
|
docker push "$IMAGE:${{ gitea.sha }}"
|
||||||
|
|||||||
@@ -23,3 +23,5 @@ dist/
|
|||||||
|
|
||||||
# Claude Code (worktrees, agents locaux)
|
# Claude Code (worktrees, agents locaux)
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
TODO
|
||||||
@@ -73,6 +73,16 @@ les tables sauf les tables d'association.
|
|||||||
taille de t-shirt, régime, ni compétences. Porte trois listes de vœux,
|
taille de t-shirt, régime, ni compétences. Porte trois listes de vœux,
|
||||||
exposées par la fiche (`artist_ids`, `wished_position_ids`,
|
exposées par la fiche (`artist_ids`, `wished_position_ids`,
|
||||||
`wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
|
`wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
|
||||||
|
Renseignables aussi via le formulaire public (§ 9) : `photo`/
|
||||||
|
`photo_content_type` (image, servie séparément par
|
||||||
|
`GET .../volunteers/{id}/photo`, COORD/LEAD), `companion_wish_note` (« je
|
||||||
|
voudrais être avec » en texte libre — la personne visée n'existe pas
|
||||||
|
forcément encore en base au moment de l'inscription, donc pas de lien
|
||||||
|
direct vers un `VolunteerCompanionWish` ; la coordination la résout à la
|
||||||
|
main depuis la fiche une fois les deux bénévoles inscrits),
|
||||||
|
`self_declared_available`/`willing_to_pay_fee` (cases à cocher **purement
|
||||||
|
informatives**, sans lien avec `Availability` ni aucun workflow de
|
||||||
|
paiement).
|
||||||
- **`Artist`** : programmation de l'édition (`name`, `performance_date`,
|
- **`Artist`** : programmation de l'édition (`name`, `performance_date`,
|
||||||
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
|
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
|
||||||
lendemain). Unique (`edition_id`, `name`, `performance_date`). La
|
lendemain). Unique (`edition_id`, `name`, `performance_date`). La
|
||||||
@@ -108,10 +118,17 @@ les tables sauf les tables d'association.
|
|||||||
(`VolunteerHomeView`) plutôt qu'un écran référent vide.
|
(`VolunteerHomeView`) plutôt qu'un écran référent vide.
|
||||||
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont
|
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont
|
||||||
il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at`
|
il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at`
|
||||||
(peut finir le lendemain), `min_staff`, `ideal_staff`. À la création d'un
|
(peut finir le lendemain), `min_staff`, `ideal_staff`. **Aucun créneau
|
||||||
`Position`, un `Shift` à 0/0 est généré par jour de l'édition et par
|
n'est généré automatiquement à la création d'un `Position`** : la
|
||||||
`DayPart` ; `POST /editions/{id}/positions/{pid}/generate-shifts` recrée les
|
coordination crée elle-même ceux dont elle a besoin, à l'horaire de son
|
||||||
manquants (après ajout d'un créneau type ou extension des dates).
|
choix (`POST /editions/{id}/shifts`, sans lien à un `DayPart`,
|
||||||
|
`day_part_id: null`) — écran « Postes & créneaux » (`ConfigGrid.tsx`), un
|
||||||
|
formulaire heure de début / heure de fin par jour. Repartir des créneaux
|
||||||
|
types reste possible en option, à la demande :
|
||||||
|
`POST /editions/{id}/positions/{pid}/generate-shifts` génère un `Shift` à
|
||||||
|
0/0 par jour de l'édition et par `DayPart` manquant (bouton « Générer les
|
||||||
|
créneaux », idempotent — n'écrase jamais un créneau déjà généré, même
|
||||||
|
modifié depuis).
|
||||||
- **`Availability`** : `volunteer_id`, `date`, `start_time`, `end_time`
|
- **`Availability`** : `volunteer_id`, `date`, `start_time`, `end_time`
|
||||||
(`end_time <= start_time` ⇒ la fenêtre se termine le lendemain). Fenêtre
|
(`end_time <= start_time` ⇒ la fenêtre se termine le lendemain). Fenêtre
|
||||||
horaire **libre** ; plusieurs par (`volunteer`, `date`). Unique
|
horaire **libre** ; plusieurs par (`volunteer`, `date`). Unique
|
||||||
@@ -134,6 +151,68 @@ les tables sauf les tables d'association.
|
|||||||
`volunteer_id` (nullable, unique).
|
`volunteer_id` (nullable, unique).
|
||||||
- **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`,
|
- **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`,
|
||||||
`used_at`.
|
`used_at`.
|
||||||
|
- **`BadgeTemplate`** : recto/verso d'une édition (une ligne, créée à la
|
||||||
|
demande). `front_image`/`front_image_content_type` (fond du recto),
|
||||||
|
`back_image`/`back_image_content_type` (verso — **le même pour tous les
|
||||||
|
bénévoles**). Images stockées en base (`LargeBinary`), pas de volume de
|
||||||
|
fichiers à gérer. **Le format du badge n'est pas fixe** :
|
||||||
|
`front_image_width_mm`/`front_image_height_mm` sont déduits des pixels de
|
||||||
|
l'image du recto à l'import (résolution supposée 300 dpi — voir
|
||||||
|
`app/badges/pdf.py:image_size_mm`), donc le badge prend la forme de
|
||||||
|
l'image importée ; `NULL` tant qu'aucune image n'est importée, l'éditeur
|
||||||
|
et la génération retombant alors sur le format CR80 par défaut (85,6 ×
|
||||||
|
54 mm, `DEFAULT_BADGE_WIDTH_MM`/`HEIGHT_MM`). Le verso est ajusté (sans
|
||||||
|
déformation, `preserveAspectRatio=True`) à ce même format.
|
||||||
|
- **`BadgeField`** : un champ positionné sur le recto (`field_type` ∈
|
||||||
|
`FULL_NAME` / `POSITIONS` / `CATEGORY` / `ROLE`, au plus un par édition —
|
||||||
|
`PUT .../badges/template/fields` remplace tout), coordonnées `x_mm`/`y_mm`/
|
||||||
|
`width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du badge,
|
||||||
|
`font_size`, `align`, `bold`, `color`. Édité visuellement (glisser-déposer)
|
||||||
|
depuis l'écran « Badges » du front-end (`frontend/src/pages/BadgesPage.tsx`),
|
||||||
|
qui envoie ces mêmes millimètres et redimensionne son aperçu au format réel
|
||||||
|
du badge (`GET .../badges/template` renvoie `badge_width_mm`/`height_mm`).
|
||||||
|
`POSITIONS` résout **une ligne par créneau affecté**, triée
|
||||||
|
chronologiquement : nom du poste + horaire (`HH:MM–HH:MM`), suivi du
|
||||||
|
référent du poste (`Position.lead_account_id` → son bénévole) s'il y en a
|
||||||
|
un et que ce n'est pas le bénévole badgé lui-même (texte multi-lignes,
|
||||||
|
`\n`) ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ;
|
||||||
|
`ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent »).
|
||||||
|
`POST /editions/{id}/badges/generate` (COORDINATION, `{volunteer_ids?}` —
|
||||||
|
absent = tous) génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est
|
||||||
|
pour rien : juste ReportLab) : pages recto personnalisées suivies, planche
|
||||||
|
par planche, de la page de verso correspondante (même image répétée à
|
||||||
|
chaque emplacement) — impression recto-verso classique.
|
||||||
|
- **Feuilles de poste** (PDF, `app/sheets/pdf.py` + `app/api/position_sheets.py`) :
|
||||||
|
aucune nouvelle entité — dérivées de `Position`/`Shift`/`Assignment`/
|
||||||
|
`Volunteer` existants. Complément des badges (un badge identifie un
|
||||||
|
bénévole ; une feuille identifie un poste pour la journée) : une page par
|
||||||
|
poste et par jour, avec son référent (`Position.lead_account_id`, nom +
|
||||||
|
téléphone — « à désigner » si aucun) en en-tête, puis les créneaux du jour
|
||||||
|
avec les bénévoles affectés et leur téléphone, pour affichage sur place.
|
||||||
|
`POST /editions/{id}/position-sheets/generate` (COORDINATION,
|
||||||
|
`{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune
|
||||||
|
affectation n'apparaît pas (rien à imprimer).
|
||||||
|
- **`FormTemplate`** : configuration du formulaire public d'inscription
|
||||||
|
d'une édition (une ligne, créée à la demande). `is_open` coupe les
|
||||||
|
nouvelles soumissions sans effacer la configuration.
|
||||||
|
- **`FormField`** : un champ activé sur le formulaire (`field_type` ∈
|
||||||
|
`EMAIL` / `PHONE` / `POSITION_WISHES` / `ARTIST_WISHES` / `COMPANION_WISH`
|
||||||
|
/ `AVAILABLE` / `WILLING_TO_PAY_FEE` / `PHOTO` / `COMMENT`, au plus un par
|
||||||
|
édition — `PUT .../form/fields` remplace tout). **Nom et prénom sont
|
||||||
|
toujours collectés**, indispensables à la création du `Volunteer` : ils ne
|
||||||
|
sont pas dans cette liste, contrairement aux neuf autres. Deux règles
|
||||||
|
propres à ce formulaire (pas à la fiche bénévole classique, qui reste sans
|
||||||
|
limite) : au plus **deux** vœux de poste, et au plus **un** artiste
|
||||||
|
souhaité par jour de festival (`app/api/forms.py:_check_one_artist_wish_per_day`).
|
||||||
|
`GET/PUT/PATCH /editions/{id}/form[...]` (COORDINATION) configurent ;
|
||||||
|
`GET /editions/{id}/form/public` et `POST .../form/public/submit` sont les
|
||||||
|
**deux seuls endpoints de toute l'API sans authentification** — protégés
|
||||||
|
par hCaptcha (`app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` ; vérification
|
||||||
|
désactivée si absente, comme `EMAIL_ENABLED=false`) et un filet de
|
||||||
|
sécurité global par édition (`app/auth/rate_limit.py:form_submission_limiter`,
|
||||||
|
hCaptcha reste la défense principale). Le bénévole créé par une soumission
|
||||||
|
apparaît immédiatement dans la liste de la coordination, comme n'importe
|
||||||
|
quel autre bénévole.
|
||||||
|
|
||||||
Conventions techniques :
|
Conventions techniques :
|
||||||
|
|
||||||
@@ -142,7 +221,7 @@ Conventions techniques :
|
|||||||
type ENUM natif PostgreSQL.
|
type ENUM natif PostgreSQL.
|
||||||
- Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les
|
- Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les
|
||||||
`edition_id` et sur les liens vers `volunteer`, `shift`, `position`,
|
`edition_id` et sur les liens vers `volunteer`, `shift`, `position`,
|
||||||
`artist`, `account`. La suppression complète d'un bénévole (RGPD) efface
|
`artist`, `account`. La suppression complète d'un bénévole efface
|
||||||
donc ses `Availability`, `Assignment`, vœux (artistes, postes, compagnons —
|
donc ses `Availability`, `Assignment`, vœux (artistes, postes, compagnons —
|
||||||
dans les deux sens : ceux qu'il a émis et ceux qui le visaient) et son
|
dans les deux sens : ceux qu'il a émis et ceux qui le visaient) et son
|
||||||
`Account`.
|
`Account`.
|
||||||
@@ -170,6 +249,10 @@ Matrice appliquée par les endpoints :
|
|||||||
| `artists` lecture / écriture | ✔ / ✔ | ✔ / — | ✔ / — |
|
| `artists` lecture / écriture | ✔ / ✔ | ✔ / — | ✔ / — |
|
||||||
| `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — |
|
| `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — |
|
||||||
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
|
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
|
||||||
|
| `badges` (config + génération) | ✔ | — | — |
|
||||||
|
| `position-sheets` (génération) | ✔ | — | — |
|
||||||
|
| `form` (config) | ✔ | — | — |
|
||||||
|
| `form/public`, `form/public/submit` | *(aucune authentification — voir § 2, `FormField`)* |||
|
||||||
|
|
||||||
**Authentification — deux systèmes selon le compte**, une même session JWT
|
**Authentification — deux systèmes selon le compte**, une même session JWT
|
||||||
HS256 (`Authorization: Bearer <jwt>`, TTL `session_ttl_hours`) à l'arrivée.
|
HS256 (`Authorization: Bearer <jwt>`, TTL `session_ttl_hours`) à l'arrivée.
|
||||||
@@ -222,7 +305,7 @@ référent d'un poste (voir `Position.lead_account_id` plus haut).
|
|||||||
|
|
||||||
Envoi par **SMTP** (auto-hébergé). `EMAIL_ENABLED=false` (défaut dev/tests) ⇒
|
Envoi par **SMTP** (auto-hébergé). `EMAIL_ENABLED=false` (défaut dev/tests) ⇒
|
||||||
les emails sont seulement journalisés (`app.email`, INFO, **sans destinataire
|
les emails sont seulement journalisés (`app.email`, INFO, **sans destinataire
|
||||||
ni corps** — RGPD). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
|
ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
|
||||||
`PUBLIC_BASE_URL` (pour les liens). Ne jamais mettre de vraies valeurs dans
|
`PUBLIC_BASE_URL` (pour les liens). Ne jamais mettre de vraies valeurs dans
|
||||||
`config.py` : elles vivent dans `.env` (non versionné).
|
`config.py` : elles vivent dans `.env` (non versionné).
|
||||||
|
|
||||||
@@ -349,13 +432,37 @@ effectivement tourné (`POST .../solver/suggestions`), ses bénévoles retenus
|
|||||||
passent malgré tout en tête (ce sont eux le résultat d'une vraie optimisation
|
passent malgré tout en tête (ce sont eux le résultat d'une vraie optimisation
|
||||||
jointe, pas juste un score local).
|
jointe, pas juste un score local).
|
||||||
|
|
||||||
**Colonnes de la grille des besoins** — une colonne représente un **créneau**
|
**Colonnes de la grille des besoins** (`GrillePage.tsx`) — un vrai axe des
|
||||||
distinct (`starts_at` + `ends_at`), pas seulement une heure de début : deux
|
temps : une colonne = 30 min, bornée par jour à la plage réellement utilisée
|
||||||
créneaux qui démarrent en même temps mais durent différemment ont chacun leur
|
(du créneau le plus matinal au plus tardif, arrondi à la demi-heure
|
||||||
colonne (`buildLattice` / `LatticeColumn` dans `frontend/src/lib/planning.ts`).
|
englobante — pas les 24 h complètes). Les créneaux gardent des horaires
|
||||||
Largeur adaptative par jour (`columnWidthPx`) : peu de créneaux dans une
|
libres (`ShiftCreate`, § 2) ; ils sont replacés dans cette grille fixe en
|
||||||
journée ⇒ colonnes larges, beaucoup de créneaux ⇒ largeur compacte avec
|
fusionnant les cases qu'ils couvrent, arrondis à la tranche englobante s'ils
|
||||||
défilement horizontal — jamais de colonnes étroites par défaut.
|
ne tombent pas pile sur un multiple de 30 min (`buildTimeGrid` /
|
||||||
|
`placeInTimeGrid` dans `frontend/src/lib/planning.ts`). **Chaque cellule
|
||||||
|
reçoit sa position de grille explicitement** (`gridColumn`/`gridRow`
|
||||||
|
calculés dans `GrillePage.tsx`, pas seulement `gridColumn: span N`) : un
|
||||||
|
poste dont les créneaux ne totalisent pas exactement le nombre de tranches
|
||||||
|
d'un jour (deux créneaux adjacents mais non alignés sur 30 min, désormais
|
||||||
|
courant depuis la création à la main) ne doit jamais décaler les lignes
|
||||||
|
suivantes — bug corrigé une fois (postes manquants au-delà du premier), un
|
||||||
|
positionnement implicite (auto-flow) recréerait la même classe de bug.
|
||||||
|
Vérifié par `scripts/smoke.tsx:checkGridLayout` (inspecte les `grid-row`
|
||||||
|
rendus, pas seulement l'absence de crash — un décalage CSS n'est pas visible
|
||||||
|
dans un simple rendu SSR). Hiérarchie de `z-index` volontairement large et
|
||||||
|
sans égalité (coins figés 20/21 > en-têtes jour/heure 12/11 > libellés de
|
||||||
|
poste 10 > cellules de créneau, chacune isolée dans son propre contexte
|
||||||
|
d'empilement — `relative z-0` — pour que son infobulle au survol, `z-20`,
|
||||||
|
ne rivalise jamais avec les en-têtes figés) : deux éléments sticky à égalité
|
||||||
|
de `z-index` peuvent se chevaucher de façon imprévisible pendant un
|
||||||
|
défilement selon le navigateur. Largeur adaptative par jour (`columnWidthPx`,
|
||||||
|
en fonction du nombre de tranches) : peu de tranches ⇒ colonnes larges,
|
||||||
|
beaucoup ⇒ largeur compacte avec défilement horizontal — jamais de colonnes
|
||||||
|
étroites par défaut.
|
||||||
|
La « Vue bénévoles » (`VolunteerViewPage.tsx`) reste sur l'ancien modèle,
|
||||||
|
distinct : une colonne par **créneau** (signature `starts_at`/`ends_at`)
|
||||||
|
plutôt qu'un axe temporel continu (`buildLattice` / `LatticeColumn`), plus
|
||||||
|
adapté à son tableau bénévole × créneau.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -385,8 +492,10 @@ défilement horizontal — jamais de colonnes étroites par défaut.
|
|||||||
|
|
||||||
### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte)
|
### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte)
|
||||||
|
|
||||||
SMS · constructeur de formulaire · pointage sur site ·
|
SMS · pointage sur site · fonctions d'IA. *(L'envoi d'emails par SMTP est en
|
||||||
fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)*
|
place — voir § 2. Le formulaire public d'inscription est en place — voir § 2
|
||||||
|
et § 9 ; ce n'était pas prévu à l'origine, retiré de cette liste à
|
||||||
|
l'étape 21.)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -417,13 +526,17 @@ fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)*
|
|||||||
│ │ │ # health, auth, accounts, editions,
|
│ │ │ # health, auth, accounts, editions,
|
||||||
│ │ │ # day_parts, imports, artists, volunteers,
|
│ │ │ # day_parts, imports, artists, volunteers,
|
||||||
│ │ │ # availability, positions, shifts,
|
│ │ │ # availability, positions, shifts,
|
||||||
│ │ │ # assignments, solver, matrix, notifications
|
│ │ │ # assignments, solver, matrix, notifications,
|
||||||
|
│ │ │ # badges, position_sheets, forms
|
||||||
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
|
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
|
||||||
│ │ ├── email/ # SMTP (sender) + rendu des emails (templates)
|
│ │ ├── email/ # SMTP (sender) + rendu des emails (templates)
|
||||||
│ │ ├── importing/ # import CSV/Excel (parser, text, service)
|
│ │ ├── importing/ # import CSV/Excel (parser, text, service)
|
||||||
│ │ ├── models/ # tables SQLModel
|
│ │ ├── models/ # tables SQLModel
|
||||||
│ │ ├── validation/ # moteur pur (models, engine) + loader (base)
|
│ │ ├── validation/ # moteur pur (models, engine) + loader (base)
|
||||||
│ │ ├── solver/ # suggestions OR-Tools (models, engine, loader)
|
│ │ ├── solver/ # suggestions OR-Tools (models, engine, loader)
|
||||||
|
│ │ ├── badges/ # génération PDF des planches (pdf.py, ReportLab)
|
||||||
|
│ │ ├── sheets/ # génération PDF des feuilles de poste (pdf.py)
|
||||||
|
│ │ ├── forms/ # hCaptcha (captcha.py) — voir api/forms.py
|
||||||
│ │ ├── bootstrap.py # instance vierge : édition + 1er compte
|
│ │ ├── bootstrap.py # instance vierge : édition + 1er compte
|
||||||
│ │ ├── seed.py # jeu de démonstration (python -m app.seed)
|
│ │ ├── seed.py # jeu de démonstration (python -m app.seed)
|
||||||
│ │ ├── i18n/
|
│ │ ├── i18n/
|
||||||
@@ -496,10 +609,13 @@ Aucune dépendance locale requise hors Docker.
|
|||||||
- Secrets/variables Actions requis (voir `deploy/README.md`) : secrets
|
- Secrets/variables Actions requis (voir `deploy/README.md`) : secrets
|
||||||
`REGISTRY_TOKEN`, `SSH_KEY` ; variables `REGISTRY_HOST`,
|
`REGISTRY_TOKEN`, `SSH_KEY` ; variables `REGISTRY_HOST`,
|
||||||
`REGISTRY_NAMESPACE`, `REGISTRY_USER`, `SSH_HOST`, `SSH_USER`,
|
`REGISTRY_NAMESPACE`, `REGISTRY_USER`, `SSH_HOST`, `SSH_USER`,
|
||||||
`DEPLOY_PATH`, `VITE_EDITION_ID`.
|
`DEPLOY_PATH`, `VITE_EDITION_ID`, `VITE_HCAPTCHA_SITE_KEY` (optionnelle —
|
||||||
|
clé de site hCaptcha, publique, figée au build du frontend comme
|
||||||
|
`VITE_EDITION_ID` ; vide ⇒ formulaire public sans vérification anti-robot).
|
||||||
- Le VPS porte un fichier `.env` (non versionné) avec `POSTGRES_USER`,
|
- Le VPS porte un fichier `.env` (non versionné) avec `POSTGRES_USER`,
|
||||||
`POSTGRES_PASSWORD`, `POSTGRES_DB`, `JWT_SECRET`, `PUBLIC_BASE_URL` et les
|
`POSTGRES_PASSWORD`, `POSTGRES_DB`, `JWT_SECRET`, `PUBLIC_BASE_URL`,
|
||||||
variables `EMAIL_ENABLED` / `SMTP_*` (voir `deploy/README.md`).
|
`HCAPTCHA_SECRET_KEY` (optionnelle, secret hCaptcha — voir § 2, `FormField`)
|
||||||
|
et les variables `EMAIL_ENABLED` / `SMTP_*` (voir `deploy/README.md`).
|
||||||
- `backend` et `frontend` n'écoutent que sur `127.0.0.1` (voir
|
- `backend` et `frontend` n'écoutent que sur `127.0.0.1` (voir
|
||||||
`docker-compose.prod.yml`) : `deploy/Caddyfile.benevoles` est le bloc à
|
`docker-compose.prod.yml`) : `deploy/Caddyfile.benevoles` est le bloc à
|
||||||
ajouter au Caddyfile du serveur pour les exposer publiquement (`/api/*` vers
|
ajouter au Caddyfile du serveur pour les exposer publiquement (`/api/*` vers
|
||||||
@@ -530,6 +646,11 @@ Aucune dépendance locale requise hors Docker.
|
|||||||
| 17 | Vœu de compagnon (`VolunteerCompanionWish`, 0 à N, à sens unique) : fiche bénévole, pris en compte par le solveur (bonus) et le classement par défaut | à valider |
|
| 17 | Vœu de compagnon (`VolunteerCompanionWish`, 0 à N, à sens unique) : fiche bénévole, pris en compte par le solveur (bonus) et le classement par défaut | à valider |
|
||||||
| 18 | Gestion des référents depuis la fiche bénévole : `PATCH /accounts` (rôle LEAD ↔ VOLUNTEER), `lead_account_id` vérifié (doit être un compte référent de l'édition) | à valider |
|
| 18 | Gestion des référents depuis la fiche bénévole : `PATCH /accounts` (rôle LEAD ↔ VOLUNTEER), `lead_account_id` vérifié (doit être un compte référent de l'édition) | à valider |
|
||||||
| 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend, limiteur de tentatives sur `/auth/login` et `/auth/magic-link` | à valider |
|
| 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend, limiteur de tentatives sur `/auth/login` et `/auth/magic-link` | à valider |
|
||||||
|
| 20 | Planches de badges bénévoles (PDF) : recto personnalisable (fond + champs nom/poste(s)/catégorie/rôle, éditeur glisser-déposer), verso fixe (même image pour tous), écran « Badges » dédié (`/badges`, COORDINATION) | à valider |
|
||||||
|
| 21 | Formulaire public d'inscription (`/inscription`, sans authentification) : champs activables par la coordination (email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir, vœu de compagnon en texte libre, disponibilité/cotisation déclaratives, photo, commentaire), captcha hCaptcha, ouverture/fermeture, lien à partager, écran de configuration dédié (`/inscriptions`, COORDINATION) — les bénévoles créés rejoignent directement la liste | à valider |
|
||||||
|
| 22 | Feuilles de poste (PDF, complément des badges) : une page par poste et par jour avec le référent, les créneaux, les bénévoles affectés et leur téléphone — bouton depuis la grille des besoins (COORDINATION) ; photo du bénévole affichée sur son écran et sur l'écran référent (en face du nom de chaque membre de l'équipe) | à valider |
|
||||||
|
| 23 | Créneaux : plus de génération automatique à la création d'un poste — création manuelle à horaire libre (`ConfigGrid.tsx`), génération depuis les créneaux types conservée en option (« Générer les créneaux ») ; grille des besoins redessinée en vrai axe des temps (une colonne = 30 min, bornée à la plage utilisée par jour, cases fusionnées pour chaque créneau — `buildTimeGrid`/`placeInTimeGrid`) | à valider |
|
||||||
|
| 24 | Corrections de stabilité de la grille des besoins (suite à l'étape 23) : positionnement explicite (`gridColumn`/`gridRow`) de chaque cellule (un poste aux créneaux non alignés sur 30 min ne décale plus les postes suivants) ; hiérarchie de `z-index` sans égalité entre les en-têtes figés et isolation de l'infobulle de survol dans son propre contexte d'empilement (ne recouvrait plus la ligne « Affectés / idéal » pendant un défilement) | à valider |
|
||||||
|
|
||||||
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
||||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
"""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 »."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models import Artist, Position, VolunteerArtist, VolunteerPositionWish
|
||||||
|
|
||||||
|
|
||||||
def owned_or_404(instance, edition_id: uuid.UUID):
|
def owned_or_404(instance, edition_id: uuid.UUID):
|
||||||
@@ -23,3 +30,50 @@ def replace_links(
|
|||||||
)
|
)
|
||||||
for row in pairs:
|
for row in pairs:
|
||||||
session.add(link_model(**row))
|
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],
|
||||||
|
)
|
||||||
|
|||||||
@@ -12,10 +12,22 @@ def _dates(edition: Edition) -> list[dt.date]:
|
|||||||
return [edition.start_date + dt.timedelta(days=i) for i in range(span + 1)]
|
return [edition.start_date + dt.timedelta(days=i) for i in range(span + 1)]
|
||||||
|
|
||||||
|
|
||||||
def _window(day: dt.date, part: DayPart) -> tuple[dt.datetime, dt.datetime]:
|
def _day_boundary(day_parts: list[DayPart]) -> dt.time:
|
||||||
start = dt.datetime.combine(day, part.start_time)
|
"""Début du premier créneau type du jour (le plus petit `order`) — même
|
||||||
|
frontière que `validation.engine._day_boundary`. Un créneau type dont
|
||||||
|
l'heure de début tombe avant cette frontière est la suite de la nuit du
|
||||||
|
jour précédent (ex. `Nuit3` 00 h–01 h après `Soirée` 19 h–00 h) : il doit
|
||||||
|
être généré sur le jour *suivant*, pas sur `day` lui-même."""
|
||||||
|
if not day_parts:
|
||||||
|
return dt.time(0, 0)
|
||||||
|
return min(day_parts, key=lambda part: part.order).start_time
|
||||||
|
|
||||||
|
|
||||||
|
def _window(day: dt.date, part: DayPart, boundary: dt.time) -> tuple[dt.datetime, dt.datetime]:
|
||||||
|
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 = (
|
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)
|
return start, dt.datetime.combine(end_day, part.end_time)
|
||||||
|
|
||||||
@@ -28,6 +40,7 @@ def generate_missing_shifts(
|
|||||||
day_parts = list(
|
day_parts = list(
|
||||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||||
)
|
)
|
||||||
|
boundary = _day_boundary(day_parts)
|
||||||
existing = {
|
existing = {
|
||||||
(s.day_part_id, s.starts_at.date())
|
(s.day_part_id, s.starts_at.date())
|
||||||
for s in session.exec(
|
for s in session.exec(
|
||||||
@@ -37,9 +50,9 @@ def generate_missing_shifts(
|
|||||||
created = 0
|
created = 0
|
||||||
for day in _dates(edition):
|
for day in _dates(edition):
|
||||||
for part in day_parts:
|
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
|
continue
|
||||||
start, end = _window(day, part)
|
|
||||||
session.add(
|
session.add(
|
||||||
Shift(
|
Shift(
|
||||||
edition_id=edition.id,
|
edition_id=edition.id,
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
"""Planches de badges bénévoles : configuration du recto/verso (COORDINATION)
|
||||||
|
et génération du PDF.
|
||||||
|
|
||||||
|
Le verso est une image fixe, la même pour tous les bénévoles. Le recto
|
||||||
|
combine une image de fond et des champs positionnés (nom, poste(s) affectés,
|
||||||
|
catégorie, rôle) — voir `app/badges/pdf.py` pour la composition du PDF et
|
||||||
|
`app/models/domain.py` (`BadgeTemplate`, `BadgeField`) pour le modèle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
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 sqlalchemy.orm import aliased
|
||||||
|
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,
|
||||||
|
generate_badge_sheets_pdf,
|
||||||
|
image_size_mm,
|
||||||
|
)
|
||||||
|
from app.db import get_session
|
||||||
|
from app.i18n import t
|
||||||
|
from app.models import (
|
||||||
|
Account,
|
||||||
|
Assignment,
|
||||||
|
BadgeField,
|
||||||
|
BadgeFieldType,
|
||||||
|
BadgeTemplate,
|
||||||
|
Edition,
|
||||||
|
Position,
|
||||||
|
Role,
|
||||||
|
Shift,
|
||||||
|
Volunteer,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"])
|
||||||
|
|
||||||
|
_MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
||||||
|
_ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg"}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 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)
|
||||||
|
.order_by(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]:
|
||||||
|
seen: set[BadgeFieldType] = set()
|
||||||
|
for f in payload.fields:
|
||||||
|
if 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.field_type)
|
||||||
|
|
||||||
|
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, Volunteer | None] # (poste, créneau, référent)
|
||||||
|
|
||||||
|
|
||||||
|
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 _position_line(
|
||||||
|
position: Position, shift: Shift, referent: Volunteer | None, volunteer_id: uuid.UUID
|
||||||
|
) -> str:
|
||||||
|
"""Une ligne par créneau affecté : poste + horaire, et le référent du
|
||||||
|
poste s'il y en a un — sauf si le bénévole badgé est lui-même ce
|
||||||
|
référent (pas besoin de se l'indiquer à soi-même)."""
|
||||||
|
line = f"{position.name} {shift.starts_at:%H:%M}–{shift.ends_at:%H:%M}"
|
||||||
|
if referent is not None and referent.id != volunteer_id:
|
||||||
|
line += f" ({t('badge.role.referent')} : {referent.first_name} {referent.last_name})"
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def _field_text(
|
||||||
|
field: BadgeField,
|
||||||
|
volunteer: Volunteer,
|
||||||
|
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]],
|
||||||
|
lead_volunteer_ids: set[uuid.UUID],
|
||||||
|
) -> str:
|
||||||
|
if field.field_type == BadgeFieldType.FULL_NAME:
|
||||||
|
return f"{volunteer.first_name} {volunteer.last_name}"
|
||||||
|
assignments = assignments_by_volunteer.get(volunteer.id, [])
|
||||||
|
if field.field_type == BadgeFieldType.POSITIONS:
|
||||||
|
return "\n".join(
|
||||||
|
_position_line(position, shift, referent, volunteer.id)
|
||||||
|
for position, shift, referent in assignments
|
||||||
|
)
|
||||||
|
if field.field_type == BadgeFieldType.CATEGORY:
|
||||||
|
categories = sorted(
|
||||||
|
{_category_label(position.category) for position, _shift, _r 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]
|
||||||
|
Referent = aliased(Volunteer)
|
||||||
|
rows = session.exec(
|
||||||
|
select(Assignment.volunteer_id, Position, Shift, 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.in_(volunteer_ids))
|
||||||
|
.order_by(Shift.starts_at)
|
||||||
|
).all()
|
||||||
|
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]] = defaultdict(list)
|
||||||
|
for volunteer_id, position, shift, referent in rows:
|
||||||
|
assignments_by_volunteer[volunteer_id].append((position, shift, referent))
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
badges = [
|
||||||
|
BadgeSpec(
|
||||||
|
fields=tuple(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
for field in fields
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for volunteer in volunteers
|
||||||
|
]
|
||||||
|
|
||||||
|
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"'},
|
||||||
|
)
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""Formulaire public d'inscription des bénévoles.
|
||||||
|
|
||||||
|
Deux familles d'endpoints sous le même routeur :
|
||||||
|
- configuration (COORDINATION) : quels champs sont activés, formulaire
|
||||||
|
ouvert ou fermé ;
|
||||||
|
- publics (**aucune authentification** — les seuls endpoints de toute
|
||||||
|
l'API dans ce cas) : consultation de la configuration et soumission,
|
||||||
|
protégés par hCaptcha (`app/forms/captcha.py`) et un filet de sécurité
|
||||||
|
global (`app/auth/rate_limit.py:form_submission_limiter`).
|
||||||
|
|
||||||
|
Nom et prénom sont toujours collectés (indispensables à la création du
|
||||||
|
bénévole) : ils ne font pas partie des champs activables, contrairement aux
|
||||||
|
neuf autres (`FormFieldType`). Les vœux de postes/artistes réutilisent la
|
||||||
|
validation de `app/api/_common.py` (partagée avec `volunteers.py`), avec
|
||||||
|
deux règles propres au formulaire : au plus `_MAX_POSITION_WISHES` postes,
|
||||||
|
et au plus un artiste souhaité par jour de festival (`performance_date`,
|
||||||
|
voir `_check_one_artist_wish_per_day`). Le vœu de compagnon est un texte
|
||||||
|
libre (`companion_wish_note`) résolu ensuite manuellement par la
|
||||||
|
coordination, faute de savoir si la personne visée est déjà inscrite (voir
|
||||||
|
CLAUDE.md § 2, `Volunteer`)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
|
Depends,
|
||||||
|
File,
|
||||||
|
Form,
|
||||||
|
HTTPException,
|
||||||
|
Response,
|
||||||
|
UploadFile,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
from sqlalchemy import delete
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.api._common import set_artists, set_wished_positions
|
||||||
|
from app.api.deps import get_edition
|
||||||
|
from app.api.schemas import (
|
||||||
|
FormFieldsSet,
|
||||||
|
FormSubmissionResult,
|
||||||
|
FormTemplateRead,
|
||||||
|
FormTemplateUpdate,
|
||||||
|
PublicArtistOption,
|
||||||
|
PublicFormConfig,
|
||||||
|
PublicPositionOption,
|
||||||
|
)
|
||||||
|
from app.auth.deps import require_coordinator
|
||||||
|
from app.auth.rate_limit import form_submission_limiter
|
||||||
|
from app.db import get_session
|
||||||
|
from app.forms import verify_hcaptcha
|
||||||
|
from app.models import (
|
||||||
|
Account,
|
||||||
|
Artist,
|
||||||
|
Edition,
|
||||||
|
FormField,
|
||||||
|
FormFieldType,
|
||||||
|
FormTemplate,
|
||||||
|
Position,
|
||||||
|
Volunteer,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/editions/{edition_id}/form", tags=["form"])
|
||||||
|
|
||||||
|
_MAX_PHOTO_BYTES = 8 * 1024 * 1024
|
||||||
|
_ALLOWED_PHOTO_TYPES = {"image/png", "image/jpeg"}
|
||||||
|
# Vœux de poste : au plus deux, pour éviter que tout le monde coche tous les
|
||||||
|
# postes « au cas où » — force une vraie priorisation dès le formulaire.
|
||||||
|
_MAX_POSITION_WISHES = 2
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Configuration (COORDINATION) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _get_or_create_template(session: Session, edition_id: uuid.UUID) -> FormTemplate:
|
||||||
|
template = session.exec(
|
||||||
|
select(FormTemplate).where(FormTemplate.edition_id == edition_id)
|
||||||
|
).first()
|
||||||
|
if template is None:
|
||||||
|
template = FormTemplate(edition_id=edition_id)
|
||||||
|
session.add(template)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(template)
|
||||||
|
return template
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled_fields(session: Session, edition_id: uuid.UUID) -> list[FormFieldType]:
|
||||||
|
return list(
|
||||||
|
session.exec(
|
||||||
|
select(FormField.field_type)
|
||||||
|
.where(FormField.edition_id == edition_id)
|
||||||
|
.order_by(FormField.field_type)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormTemplateRead:
|
||||||
|
return FormTemplateRead(
|
||||||
|
edition_id=template.edition_id,
|
||||||
|
is_open=template.is_open,
|
||||||
|
fields=fields,
|
||||||
|
has_logo=template.logo is not None,
|
||||||
|
description=template.description,
|
||||||
|
yapla_url=template.yapla_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=FormTemplateRead)
|
||||||
|
def get_form(
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_coordinator),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> FormTemplateRead:
|
||||||
|
template = _get_or_create_template(session, edition.id)
|
||||||
|
return _template_read(template, _enabled_fields(session, edition.id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/fields", response_model=FormTemplateRead)
|
||||||
|
def set_fields(
|
||||||
|
payload: FormFieldsSet,
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_coordinator),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> FormTemplateRead:
|
||||||
|
template = _get_or_create_template(session, edition.id)
|
||||||
|
session.execute(delete(FormField).where(FormField.edition_id == edition.id))
|
||||||
|
for field_type in dict.fromkeys(payload.fields): # dé-doublonne, garde l'ordre
|
||||||
|
session.add(FormField(edition_id=edition.id, field_type=field_type))
|
||||||
|
session.commit()
|
||||||
|
return _template_read(template, _enabled_fields(session, edition.id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("", response_model=FormTemplateRead)
|
||||||
|
def update_form(
|
||||||
|
payload: FormTemplateUpdate,
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_coordinator),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> FormTemplateRead:
|
||||||
|
template = _get_or_create_template(session, edition.id)
|
||||||
|
changes = payload.model_dump(exclude_unset=True)
|
||||||
|
if changes.get("yapla_url"):
|
||||||
|
if not changes["yapla_url"].startswith(("http://", "https://")):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail={"code": "form.invalid_yapla_url"}
|
||||||
|
)
|
||||||
|
for field, value in changes.items():
|
||||||
|
setattr(template, field, value)
|
||||||
|
session.add(template)
|
||||||
|
session.commit()
|
||||||
|
return _template_read(template, _enabled_fields(session, edition.id))
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_logo(file: UploadFile) -> tuple[bytes, str]:
|
||||||
|
content_type = file.content_type or ""
|
||||||
|
if content_type not in _ALLOWED_PHOTO_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail={"code": "form.logo_type_not_supported"}
|
||||||
|
)
|
||||||
|
content = await file.read()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=400, detail={"code": "form.logo_empty"})
|
||||||
|
if len(content) > _MAX_PHOTO_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail={"code": "form.logo_too_large"})
|
||||||
|
return content, content_type
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/logo", response_model=FormTemplateRead)
|
||||||
|
async def set_logo(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_coordinator),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> FormTemplateRead:
|
||||||
|
template = _get_or_create_template(session, edition.id)
|
||||||
|
content, content_type = await _read_logo(file)
|
||||||
|
template.logo = content
|
||||||
|
template.logo_content_type = content_type
|
||||||
|
session.add(template)
|
||||||
|
session.commit()
|
||||||
|
return _template_read(template, _enabled_fields(session, edition.id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logo")
|
||||||
|
def get_logo(
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> Response:
|
||||||
|
"""Public (aucune authentification) : affiché sur `/inscription`, comme
|
||||||
|
`GET .../form/public` — voir le commentaire du module."""
|
||||||
|
template = _get_or_create_template(session, edition.id)
|
||||||
|
if template.logo is None:
|
||||||
|
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
|
||||||
|
return Response(
|
||||||
|
content=template.logo,
|
||||||
|
media_type=template.logo_content_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Formulaire public (aucune authentification) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.get("/public", response_model=PublicFormConfig)
|
||||||
|
def get_public_form(
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> PublicFormConfig:
|
||||||
|
template = _get_or_create_template(session, edition.id)
|
||||||
|
fields = _enabled_fields(session, edition.id)
|
||||||
|
|
||||||
|
positions: list[PublicPositionOption] = []
|
||||||
|
if FormFieldType.POSITION_WISHES in fields:
|
||||||
|
positions = [
|
||||||
|
PublicPositionOption(id=p.id, name=p.name)
|
||||||
|
for p in session.exec(
|
||||||
|
select(Position)
|
||||||
|
.where(Position.edition_id == edition.id)
|
||||||
|
.order_by(Position.name)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
artists: list[PublicArtistOption] = []
|
||||||
|
if FormFieldType.ARTIST_WISHES in fields:
|
||||||
|
artists = [
|
||||||
|
PublicArtistOption(
|
||||||
|
id=a.id, name=a.name, performance_date=a.performance_date
|
||||||
|
)
|
||||||
|
for a in session.exec(
|
||||||
|
select(Artist)
|
||||||
|
.where(Artist.edition_id == edition.id)
|
||||||
|
.order_by(Artist.performance_date, Artist.name)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
return PublicFormConfig(
|
||||||
|
is_open=template.is_open,
|
||||||
|
fields=fields,
|
||||||
|
positions=positions,
|
||||||
|
artists=artists,
|
||||||
|
has_logo=template.logo is not None,
|
||||||
|
description=template.description,
|
||||||
|
yapla_url=template.yapla_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_id_list(raw: str | None) -> list[uuid.UUID]:
|
||||||
|
"""`position_wish_ids`/`artist_wish_ids` arrivent en JSON dans un champ
|
||||||
|
`multipart/form-data` (impossible d'y envoyer un tableau natif)."""
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
values = json.loads(raw)
|
||||||
|
if not isinstance(values, list):
|
||||||
|
raise ValueError
|
||||||
|
return [uuid.UUID(str(v)) for v in values]
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail={"code": "form.invalid_payload"}
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _check_one_artist_wish_per_day(
|
||||||
|
session: Session, edition_id: uuid.UUID, artist_ids: list[uuid.UUID]
|
||||||
|
) -> None:
|
||||||
|
"""Un seul vœu d'artiste par soir de festival — `set_artists` (validation
|
||||||
|
d'appartenance à l'édition) s'occupe du reste."""
|
||||||
|
dates = list(
|
||||||
|
session.exec(
|
||||||
|
select(Artist.performance_date).where(
|
||||||
|
Artist.edition_id == edition_id, Artist.id.in_(artist_ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(dates) != len(set(dates)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail={"code": "form.too_many_artists_same_day"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/public/submit",
|
||||||
|
response_model=FormSubmissionResult,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def submit_public_form(
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
first_name: str = Form(...),
|
||||||
|
last_name: str = Form(...),
|
||||||
|
hcaptcha_token: str = Form(...),
|
||||||
|
email: str | None = Form(None),
|
||||||
|
phone: str | None = Form(None),
|
||||||
|
comment: str | None = Form(None),
|
||||||
|
companion_wish_note: str | None = Form(None),
|
||||||
|
available: bool = Form(False),
|
||||||
|
willing_to_pay_fee: bool = Form(False),
|
||||||
|
position_wish_ids: str | None = Form(None),
|
||||||
|
artist_wish_ids: str | None = Form(None),
|
||||||
|
photo: UploadFile | None = File(None),
|
||||||
|
) -> FormSubmissionResult:
|
||||||
|
# Filet de sécurité global (par édition) : hCaptcha est la défense
|
||||||
|
# principale, voir `app/auth/rate_limit.py`.
|
||||||
|
form_submission_limiter.hit(str(edition.id))
|
||||||
|
|
||||||
|
template = _get_or_create_template(session, edition.id)
|
||||||
|
if not template.is_open:
|
||||||
|
raise HTTPException(status_code=422, detail={"code": "form.closed"})
|
||||||
|
|
||||||
|
if not verify_hcaptcha(hcaptcha_token):
|
||||||
|
raise HTTPException(status_code=422, detail={"code": "form.captcha_failed"})
|
||||||
|
|
||||||
|
if not first_name.strip() or not last_name.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"code": "import.row.missing_required_field",
|
||||||
|
"params": {"field": "first_name"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fields = set(_enabled_fields(session, edition.id))
|
||||||
|
|
||||||
|
position_ids = (
|
||||||
|
_parse_id_list(position_wish_ids) if FormFieldType.POSITION_WISHES in fields else []
|
||||||
|
)
|
||||||
|
if len(position_ids) > _MAX_POSITION_WISHES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail={"code": "form.too_many_position_wishes"}
|
||||||
|
)
|
||||||
|
|
||||||
|
artist_ids = (
|
||||||
|
_parse_id_list(artist_wish_ids) if FormFieldType.ARTIST_WISHES in fields else []
|
||||||
|
)
|
||||||
|
if artist_ids:
|
||||||
|
_check_one_artist_wish_per_day(session, edition.id, artist_ids)
|
||||||
|
|
||||||
|
volunteer = Volunteer(
|
||||||
|
edition_id=edition.id,
|
||||||
|
first_name=first_name.strip().title(),
|
||||||
|
last_name=last_name.strip().title(),
|
||||||
|
email=((email or "").strip() or None) if FormFieldType.EMAIL in fields else None,
|
||||||
|
phone=((phone or "").strip() or None) if FormFieldType.PHONE in fields else None,
|
||||||
|
comment=(
|
||||||
|
(comment or "").strip() or None if FormFieldType.COMMENT in fields else None
|
||||||
|
),
|
||||||
|
companion_wish_note=(
|
||||||
|
(companion_wish_note or "").strip() or None
|
||||||
|
if FormFieldType.COMPANION_WISH in fields
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
self_declared_available=(
|
||||||
|
available if FormFieldType.AVAILABLE in fields else False
|
||||||
|
),
|
||||||
|
willing_to_pay_fee=(
|
||||||
|
willing_to_pay_fee if FormFieldType.WILLING_TO_PAY_FEE in fields else False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if FormFieldType.PHOTO in fields and photo is not None and photo.filename:
|
||||||
|
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 content:
|
||||||
|
volunteer.photo = content
|
||||||
|
volunteer.photo_content_type = content_type
|
||||||
|
|
||||||
|
session.add(volunteer)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
if FormFieldType.POSITION_WISHES in fields:
|
||||||
|
set_wished_positions(session, volunteer, position_ids)
|
||||||
|
if FormFieldType.ARTIST_WISHES in fields:
|
||||||
|
set_artists(session, volunteer, artist_ids)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
session.refresh(volunteer)
|
||||||
|
return FormSubmissionResult(volunteer_id=volunteer.id)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""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.models import Account, Artist, Assignment, Edition, Position, Shift, Volunteer
|
||||||
|
from app.sheets.pdf import (
|
||||||
|
PositionDaySheet,
|
||||||
|
SheetArtist,
|
||||||
|
SheetShift,
|
||||||
|
SheetVolunteer,
|
||||||
|
generate_position_sheets_pdf,
|
||||||
|
)
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
# 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
|
||||||
|
shifts_by_position_day[(shift.position_id, shift.starts_at.date())].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"}
|
||||||
|
)
|
||||||
|
|
||||||
|
pdf_bytes = generate_position_sheets_pdf(sheets)
|
||||||
|
return Response(
|
||||||
|
content=pdf_bytes,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="feuilles-de-poste.pdf"'},
|
||||||
|
)
|
||||||
@@ -58,9 +58,9 @@ def create_position(
|
|||||||
_check_lead_account(session, edition, payload.lead_account_id)
|
_check_lead_account(session, edition, payload.lead_account_id)
|
||||||
position = Position(edition_id=edition.id, **payload.model_dump())
|
position = Position(edition_id=edition.id, **payload.model_dump())
|
||||||
session.add(position)
|
session.add(position)
|
||||||
session.flush()
|
# Pas de créneaux générés automatiquement : la coordination choisit
|
||||||
# Un créneau à besoin 0/0 par jour et par créneau type de l'édition.
|
# elle-même ceux dont elle a besoin (créés à la main, ou en repartant des
|
||||||
generate_missing_shifts(session, edition, position)
|
# créneaux types via `generate_shifts` ci-dessous).
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(position)
|
session.refresh(position)
|
||||||
return position
|
return position
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
|
BadgeAlign,
|
||||||
|
BadgeFieldType,
|
||||||
|
FormFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Role,
|
Role,
|
||||||
)
|
)
|
||||||
@@ -67,6 +70,11 @@ class VolunteerUpdate(ApiModel):
|
|||||||
artist_ids: list[uuid.UUID] | None = None
|
artist_ids: list[uuid.UUID] | None = None
|
||||||
wished_position_ids: list[uuid.UUID] | None = None
|
wished_position_ids: list[uuid.UUID] | None = None
|
||||||
wished_companion_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
|
||||||
|
self_declared_available: bool | None = None
|
||||||
|
willing_to_pay_fee: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class VolunteerRead(ApiModel):
|
class VolunteerRead(ApiModel):
|
||||||
@@ -80,6 +88,10 @@ class VolunteerRead(ApiModel):
|
|||||||
artist_ids: list[uuid.UUID]
|
artist_ids: list[uuid.UUID]
|
||||||
wished_position_ids: list[uuid.UUID]
|
wished_position_ids: list[uuid.UUID]
|
||||||
wished_companion_ids: list[uuid.UUID]
|
wished_companion_ids: list[uuid.UUID]
|
||||||
|
has_photo: bool
|
||||||
|
companion_wish_note: str | None
|
||||||
|
self_declared_available: bool
|
||||||
|
willing_to_pay_fee: bool
|
||||||
created_at: dt.datetime
|
created_at: dt.datetime
|
||||||
updated_at: dt.datetime
|
updated_at: dt.datetime
|
||||||
|
|
||||||
@@ -334,6 +346,66 @@ class MatrixResponse(ApiModel):
|
|||||||
positions: list[MatrixPosition]
|
positions: list[MatrixPosition]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Badges (planches PDF) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class BadgeFieldIn(ApiModel):
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
class BadgeFieldsSet(ApiModel):
|
||||||
|
"""Remplace l'intégralité des champs du recto — au plus un par
|
||||||
|
`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 #
|
# Authentification et comptes #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -403,3 +475,60 @@ class PlanningNotificationResult(ApiModel):
|
|||||||
queued: int
|
queued: int
|
||||||
skipped_no_email: int
|
skipped_no_email: int
|
||||||
recipients: list[uuid.UUID] = Field(default_factory=list)
|
recipients: list[uuid.UUID] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 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
|
||||||
|
yapla_url: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class FormTemplateUpdate(ApiModel):
|
||||||
|
"""`None`/absent = inchangé pour chaque champ (`exclude_unset`) — permet
|
||||||
|
de changer `is_open` sans toucher `description`/`yapla_url`, etc."""
|
||||||
|
|
||||||
|
is_open: bool | None = None
|
||||||
|
description: str | None = None
|
||||||
|
yapla_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PublicPositionOption(ApiModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class PublicArtistOption(ApiModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
performance_date: dt.date
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
yapla_url: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class FormSubmissionResult(ApiModel):
|
||||||
|
volunteer_id: uuid.UUID
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ from fastapi import APIRouter, Depends, HTTPException, Response, status
|
|||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.api._common import owned_or_404, replace_links
|
from app.api._common import (
|
||||||
|
owned_or_404,
|
||||||
|
replace_links,
|
||||||
|
resolve_owned_ids,
|
||||||
|
set_artists,
|
||||||
|
set_wished_positions,
|
||||||
|
)
|
||||||
from app.api.deps import get_edition
|
from app.api.deps import get_edition
|
||||||
from app.api.schemas import (
|
from app.api.schemas import (
|
||||||
VolunteerCreate,
|
VolunteerCreate,
|
||||||
@@ -93,74 +99,18 @@ def _companion_ids(session: Session, volunteer_id: uuid.UUID) -> list[uuid.UUID]
|
|||||||
|
|
||||||
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
|
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
|
||||||
return VolunteerRead(
|
return VolunteerRead(
|
||||||
**volunteer.model_dump(),
|
**volunteer.model_dump(exclude={"photo", "photo_content_type"}),
|
||||||
artist_ids=_artist_ids(session, volunteer.id),
|
artist_ids=_artist_ids(session, volunteer.id),
|
||||||
wished_position_ids=_wished_position_ids(session, volunteer.id),
|
wished_position_ids=_wished_position_ids(session, volunteer.id),
|
||||||
wished_companion_ids=_companion_ids(session, volunteer.id),
|
wished_companion_ids=_companion_ids(session, volunteer.id),
|
||||||
)
|
has_photo=volunteer.photo is not None,
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _set_companions(
|
def _set_companions(
|
||||||
session: Session, volunteer: Volunteer, companion_ids: list[uuid.UUID]
|
session: Session, volunteer: Volunteer, companion_ids: list[uuid.UUID]
|
||||||
) -> None:
|
) -> None:
|
||||||
ids = _resolve_owned_ids(
|
ids = resolve_owned_ids(
|
||||||
session, Volunteer, volunteer.edition_id, companion_ids, "volunteer.unknown"
|
session, Volunteer, volunteer.edition_id, companion_ids, "volunteer.unknown"
|
||||||
)
|
)
|
||||||
if volunteer.id in ids:
|
if volunteer.id in ids:
|
||||||
@@ -195,8 +145,8 @@ def create_volunteer(
|
|||||||
)
|
)
|
||||||
session.add(volunteer)
|
session.add(volunteer)
|
||||||
session.flush()
|
session.flush()
|
||||||
_set_artists(session, volunteer, payload.artist_ids)
|
set_artists(session, volunteer, payload.artist_ids)
|
||||||
_set_wished_positions(session, volunteer, payload.wished_position_ids)
|
set_wished_positions(session, volunteer, payload.wished_position_ids)
|
||||||
_set_companions(session, volunteer, payload.wished_companion_ids)
|
_set_companions(session, volunteer, payload.wished_companion_ids)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(volunteer)
|
session.refresh(volunteer)
|
||||||
@@ -272,6 +222,27 @@ def get_volunteer_planning(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@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.patch("/{volunteer_id}", response_model=VolunteerRead)
|
@router.patch("/{volunteer_id}", response_model=VolunteerRead)
|
||||||
def update_volunteer(
|
def update_volunteer(
|
||||||
volunteer_id: uuid.UUID,
|
volunteer_id: uuid.UUID,
|
||||||
@@ -289,9 +260,9 @@ def update_volunteer(
|
|||||||
setattr(volunteer, field, value)
|
setattr(volunteer, field, value)
|
||||||
session.flush()
|
session.flush()
|
||||||
if artist_ids is not None:
|
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:
|
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:
|
if wished_companion_ids is not None:
|
||||||
_set_companions(session, volunteer, wished_companion_ids)
|
_set_companions(session, volunteer, wished_companion_ids)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -60,3 +60,10 @@ login_limiter = RateLimiter(max_attempts=5, window_seconds=60)
|
|||||||
# trois fois ; au-delà c'est soit un abus, soit un email qui n'arrivera pas
|
# trois fois ; au-delà c'est soit un abus, soit un email qui n'arrivera pas
|
||||||
# plus vite en insistant.
|
# plus vite en insistant.
|
||||||
magic_link_limiter = RateLimiter(max_attempts=3, window_seconds=300)
|
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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""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. Le verso est identique
|
||||||
|
pour tous les bénévoles : la planche imprime toutes les pages de recto
|
||||||
|
(personnalisées) puis, pour chacune, la page de verso correspondante (même
|
||||||
|
disposition, même image répétée à chaque emplacement rempli) — impression
|
||||||
|
recto-verso classique, page de verso n directement après la page de recto n.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
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.pdfgen import canvas
|
||||||
|
|
||||||
|
DEFAULT_BADGE_WIDTH_MM = 85.6
|
||||||
|
DEFAULT_BADGE_HEIGHT_MM = 54.0
|
||||||
|
_MARGIN_MM = 10.0
|
||||||
|
_GUTTER_MM = 4.0
|
||||||
|
_LINE_HEIGHT_FACTOR = 1.25
|
||||||
|
|
||||||
|
# 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 BadgeFieldSpec:
|
||||||
|
"""Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n`
|
||||||
|
possibles — ex. un poste par ligne avec son horaire), positionné depuis
|
||||||
|
le coin haut-gauche du badge (comme dans l'éditeur visuel du front-end)."""
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BadgeSpec:
|
||||||
|
"""Un badge = un bénévole, avec ses champs déjà résolus."""
|
||||||
|
|
||||||
|
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 _cell_origin(
|
||||||
|
index: int, cols: int, badge_width_mm: float, badge_height_mm: float
|
||||||
|
) -> 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."""
|
||||||
|
col, row = index % cols, index // cols
|
||||||
|
x0 = _MARGIN_MM + col * (badge_width_mm + _GUTTER_MM)
|
||||||
|
y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * badge_height_mm - row * _GUTTER_MM
|
||||||
|
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 _draw_fields(
|
||||||
|
c: canvas.Canvas,
|
||||||
|
fields: tuple[BadgeFieldSpec, ...],
|
||||||
|
x0_mm: float,
|
||||||
|
y0_mm: float,
|
||||||
|
badge_height_mm: float,
|
||||||
|
) -> None:
|
||||||
|
for f in fields:
|
||||||
|
c.setFont("Helvetica-Bold" if f.bold else "Helvetica", f.font_size)
|
||||||
|
c.setFillColor(HexColor(f.color))
|
||||||
|
left_x = x0_mm + f.x_mm
|
||||||
|
# `f.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 = f.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
|
||||||
|
bottom_limit = top_y - f.height_mm
|
||||||
|
for i, line in enumerate(f.text.split("\n")):
|
||||||
|
baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm
|
||||||
|
if baseline_y < bottom_limit:
|
||||||
|
break # la ligne déborderait de la boîte du champ
|
||||||
|
if f.align == "CENTER":
|
||||||
|
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 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)
|
||||||
|
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)
|
||||||
|
c.showPage()
|
||||||
|
|
||||||
|
for i in range(len(page_badges)):
|
||||||
|
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
|
||||||
|
_draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm)
|
||||||
|
c.showPage()
|
||||||
|
c.save()
|
||||||
|
return buffer.getvalue()
|
||||||
@@ -49,6 +49,12 @@ class Settings(BaseSettings):
|
|||||||
# (ou un alias validé). En cas de rejet, mettre SMTP_FROM = SMTP_USER.
|
# (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>"
|
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"
|
log_level: str = "INFO"
|
||||||
cors_origins: list[str] = ["http://localhost:5173"]
|
cors_origins: list[str] = ["http://localhost:5173"]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.forms.captcha import verify_hcaptcha
|
||||||
|
|
||||||
|
__all__ = ["verify_hcaptcha"]
|
||||||
@@ -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)
|
||||||
@@ -54,5 +54,37 @@
|
|||||||
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
|
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
|
||||||
"email.planning.shift_line": "- {position} : {start} → {end}",
|
"email.planning.shift_line": "- {position} : {start} → {end}",
|
||||||
"availability.date_outside_edition": "La date {date} est hors des dates de l'édition.",
|
"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."
|
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes.",
|
||||||
|
"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.no_fields": "Aucun champ n'est configuré sur le recto du 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.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.",
|
||||||
|
"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_yapla_url": "Le lien Yapla doit commencer par http:// ou https://.",
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ from app.api.artists import router as artist_router
|
|||||||
from app.api.assignments import router as assignment_router
|
from app.api.assignments import router as assignment_router
|
||||||
from app.api.auth import router as auth_router
|
from app.api.auth import router as auth_router
|
||||||
from app.api.availability import router as availability_router
|
from app.api.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.day_parts import router as day_part_router
|
||||||
from app.api.editions import router as edition_router
|
from app.api.editions import router as edition_router
|
||||||
|
from app.api.forms import router as form_router
|
||||||
from app.api.health import router as health_router
|
from app.api.health import router as health_router
|
||||||
from app.api.imports import router as import_router
|
from app.api.imports import router as import_router
|
||||||
from app.api.matrix import router as matrix_router
|
from app.api.matrix import router as matrix_router
|
||||||
from app.api.notifications import router as notification_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.positions import router as position_router
|
||||||
from app.api.shifts import router as shift_router
|
from app.api.shifts import router as shift_router
|
||||||
from app.api.solver import router as solver_router
|
from app.api.solver import router as solver_router
|
||||||
@@ -36,6 +39,15 @@ if settings.is_production and not settings.email_enabled:
|
|||||||
"le SMTP avant l'ouverture au public."
|
"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 = FastAPI(title="Gestion des bénévoles", version="0.1.0")
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -61,3 +73,6 @@ app.include_router(assignment_router)
|
|||||||
app.include_router(solver_router)
|
app.include_router(solver_router)
|
||||||
app.include_router(matrix_router)
|
app.include_router(matrix_router)
|
||||||
app.include_router(notification_router)
|
app.include_router(notification_router)
|
||||||
|
app.include_router(badge_router)
|
||||||
|
app.include_router(form_router)
|
||||||
|
app.include_router(position_sheets_router)
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -10,8 +10,12 @@ from app.models.domain import (
|
|||||||
Artist,
|
Artist,
|
||||||
Assignment,
|
Assignment,
|
||||||
Availability,
|
Availability,
|
||||||
|
BadgeField,
|
||||||
|
BadgeTemplate,
|
||||||
DayPart,
|
DayPart,
|
||||||
Edition,
|
Edition,
|
||||||
|
FormField,
|
||||||
|
FormTemplate,
|
||||||
MagicLinkToken,
|
MagicLinkToken,
|
||||||
Position,
|
Position,
|
||||||
Shift,
|
Shift,
|
||||||
@@ -22,6 +26,9 @@ from app.models.domain import (
|
|||||||
)
|
)
|
||||||
from app.models.enums import (
|
from app.models.enums import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
|
BadgeAlign,
|
||||||
|
BadgeFieldType,
|
||||||
|
FormFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Role,
|
Role,
|
||||||
)
|
)
|
||||||
@@ -32,8 +39,15 @@ __all__ = [
|
|||||||
"Assignment",
|
"Assignment",
|
||||||
"AssignmentSource",
|
"AssignmentSource",
|
||||||
"Availability",
|
"Availability",
|
||||||
|
"BadgeAlign",
|
||||||
|
"BadgeField",
|
||||||
|
"BadgeFieldType",
|
||||||
|
"BadgeTemplate",
|
||||||
"DayPart",
|
"DayPart",
|
||||||
"Edition",
|
"Edition",
|
||||||
|
"FormField",
|
||||||
|
"FormFieldType",
|
||||||
|
"FormTemplate",
|
||||||
"MagicLinkToken",
|
"MagicLinkToken",
|
||||||
"Position",
|
"Position",
|
||||||
"PositionCategory",
|
"PositionCategory",
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ au niveau base (`ondelete="CASCADE"`) ; les relations ORM utilisent
|
|||||||
import datetime as dt
|
import datetime as dt
|
||||||
import uuid
|
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 sqlalchemy import Enum as SAEnum
|
||||||
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
|
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
|
||||||
|
|
||||||
from app.models.base import TimestampMixin
|
from app.models.base import TimestampMixin
|
||||||
from app.models.enums import (
|
from app.models.enums import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
|
BadgeAlign,
|
||||||
|
BadgeFieldType,
|
||||||
|
FormFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Role,
|
Role,
|
||||||
)
|
)
|
||||||
@@ -168,6 +171,27 @@ class Volunteer(TimestampMixin, table=True):
|
|||||||
last_name: str
|
last_name: str
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
comment: 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
|
||||||
|
# Cases à cocher du formulaire public — purement informatives : aucune
|
||||||
|
# logique n'y est attachée (pas de lien avec `Availability`, 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"),
|
||||||
|
)
|
||||||
|
|
||||||
wished_artists: list[Artist] = Relationship(
|
wished_artists: list[Artist] = Relationship(
|
||||||
back_populates="fans",
|
back_populates="fans",
|
||||||
@@ -364,3 +388,128 @@ class Assignment(TimestampMixin, table=True):
|
|||||||
|
|
||||||
volunteer: Volunteer = Relationship(back_populates="assignments")
|
volunteer: Volunteer = Relationship(back_populates="assignments")
|
||||||
shift: Shift = 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 le recto du badge (nom, poste(s), catégorie,
|
||||||
|
rôle — voir `BadgeFieldType`). Coordonnées en millimètres depuis le coin
|
||||||
|
haut-gauche du badge (format CR80, 85,6 × 54 mm — voir `app/badges/pdf.py`).
|
||||||
|
Au plus un champ par `field_type` et par édition : décocher un champ dans
|
||||||
|
l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`)."""
|
||||||
|
|
||||||
|
__tablename__ = "badge_field"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"edition_id", "field_type", name="uq_badge_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: 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")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 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`, `description` et `yapla_url` 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."""
|
||||||
|
|
||||||
|
__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
|
||||||
|
# Lien vers la page de cotisation Yapla (yapla.com, plateforme externe de
|
||||||
|
# paiement pour associations) — encart affiché sur `/inscription` près de
|
||||||
|
# la case « je participe à la cotisation » (`FormFieldType.
|
||||||
|
# WILLING_TO_PAY_FEE`). Rien n'est vérifié côté appli : la case reste
|
||||||
|
# une déclaration, le paiement se passe entièrement chez Yapla.
|
||||||
|
yapla_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
|||||||
@@ -28,3 +28,34 @@ class PositionCategory(enum.StrEnum):
|
|||||||
SETUP = "SETUP"
|
SETUP = "SETUP"
|
||||||
STAGE = "STAGE"
|
STAGE = "STAGE"
|
||||||
OTHER = "OTHER"
|
OTHER = "OTHER"
|
||||||
|
|
||||||
|
|
||||||
|
class BadgeFieldType(enum.StrEnum):
|
||||||
|
"""Champ affichable sur le recto d'un badge (voir `app/badges/`)."""
|
||||||
|
|
||||||
|
FULL_NAME = "FULL_NAME"
|
||||||
|
POSITIONS = "POSITIONS"
|
||||||
|
CATEGORY = "CATEGORY"
|
||||||
|
ROLE = "ROLE"
|
||||||
|
|
||||||
|
|
||||||
|
class BadgeAlign(enum.StrEnum):
|
||||||
|
LEFT = "LEFT"
|
||||||
|
CENTER = "CENTER"
|
||||||
|
RIGHT = "RIGHT"
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from app.sheets.pdf import (
|
||||||
|
PositionDaySheet,
|
||||||
|
SheetShift,
|
||||||
|
SheetVolunteer,
|
||||||
|
generate_position_sheets_pdf,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PositionDaySheet",
|
||||||
|
"SheetShift",
|
||||||
|
"SheetVolunteer",
|
||||||
|
"generate_position_sheets_pdf",
|
||||||
|
]
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""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 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.lib.utils import ImageReader
|
||||||
|
from reportlab.platypus import (
|
||||||
|
Image,
|
||||||
|
PageBreak,
|
||||||
|
Paragraph,
|
||||||
|
SimpleDocTemplate,
|
||||||
|
Spacer,
|
||||||
|
Table,
|
||||||
|
TableStyle,
|
||||||
|
)
|
||||||
|
|
||||||
|
_PHOTO_BOX_MM = 14 * mm
|
||||||
|
|
||||||
|
_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=22, spaceAfter=2
|
||||||
|
),
|
||||||
|
"subtitle": ParagraphStyle(
|
||||||
|
"SheetSubtitle", parent=base["Normal"],
|
||||||
|
fontSize=12, textColor=colors.grey, spaceAfter=4,
|
||||||
|
),
|
||||||
|
"referent": ParagraphStyle(
|
||||||
|
"SheetReferent", parent=base["Normal"],
|
||||||
|
fontSize=11.5, fontName="Helvetica-Bold", spaceAfter=6,
|
||||||
|
),
|
||||||
|
"artists": ParagraphStyle(
|
||||||
|
"SheetArtists", parent=base["Normal"],
|
||||||
|
fontSize=9.5, textColor=colors.HexColor("#4a4a4a"), spaceAfter=16,
|
||||||
|
),
|
||||||
|
"shiftHeader": ParagraphStyle(
|
||||||
|
"ShiftHeader", parent=base["Heading3"], fontSize=13, spaceBefore=12, spaceAfter=4
|
||||||
|
),
|
||||||
|
"shiftHeaderWarning": ParagraphStyle(
|
||||||
|
"ShiftHeaderWarning", parent=base["Heading3"],
|
||||||
|
fontSize=13, spaceBefore=12, spaceAfter=4, textColor=colors.HexColor("#b23a1f"),
|
||||||
|
),
|
||||||
|
"normal": base["Normal"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _photo_thumbnail(photo: bytes) -> Image:
|
||||||
|
"""Vignette carrée (14 mm de côté max), proportions préservées."""
|
||||||
|
width_px, height_px = ImageReader(io.BytesIO(photo)).getSize()
|
||||||
|
scale = _PHOTO_BOX_MM / max(width_px, height_px)
|
||||||
|
return Image(io.BytesIO(photo), width=width_px * scale, height=height_px * scale)
|
||||||
|
|
||||||
|
|
||||||
|
def _shift_table(shift: SheetShift) -> Table:
|
||||||
|
rows = [["", "Bénévole", "Téléphone"]] + [
|
||||||
|
[_photo_thumbnail(v.photo) if v.photo else "", v.name, v.phone or "—"]
|
||||||
|
for v in shift.volunteers
|
||||||
|
]
|
||||||
|
table = Table(rows, colWidths=[18 * mm, 92 * mm, 55 * mm])
|
||||||
|
table.setStyle(
|
||||||
|
TableStyle(
|
||||||
|
[
|
||||||
|
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||||
|
("FONTSIZE", (0, 0), (-1, -1), 10.5),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 5),
|
||||||
|
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||||||
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||||
|
("LINEBELOW", (0, 0), (-1, 0), 0.75, colors.black),
|
||||||
|
("GRID", (0, 1), (-1, -1), 0.25, colors.lightgrey),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes:
|
||||||
|
styles = _styles()
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
doc = SimpleDocTemplate(
|
||||||
|
buffer, pagesize=A4,
|
||||||
|
leftMargin=18 * mm, rightMargin=18 * mm, topMargin=16 * mm, bottomMargin=16 * mm,
|
||||||
|
)
|
||||||
|
|
||||||
|
story = []
|
||||||
|
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))
|
||||||
|
else:
|
||||||
|
story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"]))
|
||||||
|
story.append(Spacer(1, 0))
|
||||||
|
|
||||||
|
doc.build(story)
|
||||||
|
return buffer.getvalue()
|
||||||
@@ -15,13 +15,15 @@ dependencies = [
|
|||||||
"pyjwt>=2.9",
|
"pyjwt>=2.9",
|
||||||
"bcrypt>=4.1",
|
"bcrypt>=4.1",
|
||||||
"ortools>=9.11",
|
"ortools>=9.11",
|
||||||
|
"reportlab>=4.2",
|
||||||
|
"pillow>=11.0",
|
||||||
|
"httpx>=0.27",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.3",
|
"pytest>=8.3",
|
||||||
"pytest-cov>=6.0",
|
"pytest-cov>=6.0",
|
||||||
"httpx>=0.27",
|
|
||||||
"ruff>=0.8",
|
"ruff>=0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ from sqlmodel import Session, SQLModel, create_engine, select
|
|||||||
|
|
||||||
from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata)
|
from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata)
|
||||||
from app.auth.deps import get_current_account
|
from app.auth.deps import get_current_account
|
||||||
from app.auth.rate_limit import login_limiter, magic_link_limiter
|
from app.auth.rate_limit import (
|
||||||
|
form_submission_limiter,
|
||||||
|
login_limiter,
|
||||||
|
magic_link_limiter,
|
||||||
|
)
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.main import app
|
from app.main import app
|
||||||
@@ -31,6 +35,7 @@ def _reset_rate_limiters():
|
|||||||
déclenche un ne doit pas polluer les suivants."""
|
déclenche un ne doit pas polluer les suivants."""
|
||||||
login_limiter.reset()
|
login_limiter.reset()
|
||||||
magic_link_limiter.reset()
|
magic_link_limiter.reset()
|
||||||
|
form_submission_limiter.reset()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,22 @@ def test_volunteer_scope(session, edition, make_account, as_account):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_volunteer_sees_only_own_photo(session, edition, make_account, as_account):
|
||||||
|
me = make_volunteer(session, edition, email="me@e.fr")
|
||||||
|
other = make_volunteer(session, edition, email="other@e.fr")
|
||||||
|
me.photo = b"fake-bytes"
|
||||||
|
me.photo_content_type = "image/png"
|
||||||
|
other.photo = b"fake-bytes"
|
||||||
|
other.photo_content_type = "image/png"
|
||||||
|
session.add_all([me, other])
|
||||||
|
session.commit()
|
||||||
|
account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="me@e.fr")
|
||||||
|
client = as_account(account)
|
||||||
|
|
||||||
|
assert client.get(_e(edition, f"/volunteers/{me.id}/photo")).status_code == 200
|
||||||
|
assert client.get(_e(edition, f"/volunteers/{other.id}/photo")).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_volunteer_sees_only_own_assignments(
|
def test_volunteer_sees_only_own_assignments(
|
||||||
session, edition, make_account, as_account
|
session, edition, make_account, as_account
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""Configuration et génération des planches de badges (API)."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import Role
|
||||||
|
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
||||||
|
|
||||||
|
# PNG 1×1 valide (transparent), le plus petit possible.
|
||||||
|
_PNG = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
|
||||||
|
"42YAAAAASUVORK5CYII="
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _badges_url(edition):
|
||||||
|
return f"/editions/{edition.id}/badges"
|
||||||
|
|
||||||
|
|
||||||
|
def _set_full_name_field(client, edition):
|
||||||
|
return client.put(
|
||||||
|
f"{_badges_url(edition)}/template/fields",
|
||||||
|
json={
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"field_type": "FULL_NAME",
|
||||||
|
"x_mm": 5,
|
||||||
|
"y_mm": 5,
|
||||||
|
"width_mm": 60,
|
||||||
|
"height_mm": 10,
|
||||||
|
"font_size": 12,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_is_coordinator_only(edition, make_account, as_account):
|
||||||
|
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||||
|
client = as_account(lead)
|
||||||
|
assert client.get(f"{_badges_url(edition)}/template").status_code == 403
|
||||||
|
resp = client.put(f"{_badges_url(edition)}/template/fields", json={"fields": []})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_starts_empty(client, edition):
|
||||||
|
resp = client.get(f"{_badges_url(edition)}/template")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["has_front_image"] is False
|
||||||
|
assert body["has_back_image"] is False
|
||||||
|
assert body["fields"] == []
|
||||||
|
# Format CR80 par défaut tant qu'aucune image n'a été importée.
|
||||||
|
assert body["badge_width_mm"] == 85.6
|
||||||
|
assert body["badge_height_mm"] == 54.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_uploading_front_image_derives_the_badge_format_from_its_pixels(client, edition):
|
||||||
|
resp = client.put(
|
||||||
|
f"{_badges_url(edition)}/template/front-image",
|
||||||
|
files={"file": ("front.png", _PNG, "image/png")},
|
||||||
|
)
|
||||||
|
body = resp.json()
|
||||||
|
# PNG 1×1 px, résolution supposée 300 dpi ⇒ 25,4/300 mm de côté.
|
||||||
|
assert body["badge_width_mm"] == pytest.approx(25.4 / 300)
|
||||||
|
assert body["badge_height_mm"] == pytest.approx(25.4 / 300)
|
||||||
|
|
||||||
|
|
||||||
|
def test_uploading_front_and_back_images(client, edition):
|
||||||
|
front = client.put(
|
||||||
|
f"{_badges_url(edition)}/template/front-image",
|
||||||
|
files={"file": ("front.png", _PNG, "image/png")},
|
||||||
|
)
|
||||||
|
assert front.status_code == 200
|
||||||
|
assert front.json()["has_front_image"] is True
|
||||||
|
|
||||||
|
back = client.put(
|
||||||
|
f"{_badges_url(edition)}/template/back-image",
|
||||||
|
files={"file": ("back.png", _PNG, "image/png")},
|
||||||
|
)
|
||||||
|
assert back.status_code == 200
|
||||||
|
assert back.json()["has_back_image"] is True
|
||||||
|
|
||||||
|
served = client.get(f"{_badges_url(edition)}/template/front-image")
|
||||||
|
assert served.status_code == 200
|
||||||
|
assert served.headers["content-type"] == "image/png"
|
||||||
|
assert served.content == _PNG
|
||||||
|
|
||||||
|
|
||||||
|
def test_uploading_an_unsupported_image_type_is_rejected(client, edition):
|
||||||
|
resp = client.put(
|
||||||
|
f"{_badges_url(edition)}/template/front-image",
|
||||||
|
files={"file": ("front.gif", b"not-a-real-gif", "image/gif")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "badge.image_type_not_supported"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resizing_the_badge_scales_it_independently_of_the_image(client, edition):
|
||||||
|
client.put(
|
||||||
|
f"{_badges_url(edition)}/template/front-image",
|
||||||
|
files={"file": ("front.png", _PNG, "image/png")},
|
||||||
|
)
|
||||||
|
# Image 1×1 px à 300 dpi supposés ⇒ badge minuscule (~0,08 mm) : on
|
||||||
|
# l'agrandit à un format exploitable, en conservant le ratio (ici 1:1).
|
||||||
|
resp = client.put(
|
||||||
|
f"{_badges_url(edition)}/template/size",
|
||||||
|
json={"badge_width_mm": 90, "badge_height_mm": 90},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["badge_width_mm"] == 90
|
||||||
|
assert body["badge_height_mm"] == 90
|
||||||
|
|
||||||
|
# Persisté : une relecture du gabarit renvoie toujours la taille agrandie.
|
||||||
|
again = client.get(f"{_badges_url(edition)}/template").json()
|
||||||
|
assert again["badge_width_mm"] == 90
|
||||||
|
assert again["badge_height_mm"] == 90
|
||||||
|
|
||||||
|
|
||||||
|
def test_resizing_the_badge_to_a_non_positive_size_is_rejected(client, edition):
|
||||||
|
resp = client.put(
|
||||||
|
f"{_badges_url(edition)}/template/size",
|
||||||
|
json={"badge_width_mm": 0, "badge_height_mm": 54},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "badge.invalid_size"
|
||||||
|
|
||||||
|
|
||||||
|
def test_setting_the_same_field_type_twice_is_rejected(client, edition):
|
||||||
|
resp = client.put(
|
||||||
|
f"{_badges_url(edition)}/template/fields",
|
||||||
|
json={
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"field_type": "FULL_NAME", "x_mm": 0, "y_mm": 0,
|
||||||
|
"width_mm": 10, "height_mm": 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field_type": "FULL_NAME", "x_mm": 5, "y_mm": 5,
|
||||||
|
"width_mm": 10, "height_mm": 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "badge.duplicate_field_type"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generating_without_any_field_configured_is_rejected(client, edition, session):
|
||||||
|
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||||
|
resp = client.post(f"{_badges_url(edition)}/generate", json={})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "badge.no_fields"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generating_with_no_matching_volunteers_is_rejected(client, edition, session):
|
||||||
|
_set_full_name_field(client, edition)
|
||||||
|
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||||
|
resp = client.post(
|
||||||
|
f"{_badges_url(edition)}/generate",
|
||||||
|
json={"volunteer_ids": ["00000000-0000-0000-0000-000000000000"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "badge.no_volunteers"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generating_produces_a_pdf(client, edition, session):
|
||||||
|
from app.models import Assignment
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"{_badges_url(edition)}/template/fields",
|
||||||
|
json={
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"field_type": "FULL_NAME", "x_mm": 5, "y_mm": 5,
|
||||||
|
"width_mm": 60, "height_mm": 10, "font_size": 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field_type": "POSITIONS", "x_mm": 5, "y_mm": 20,
|
||||||
|
"width_mm": 60, "height_mm": 10, "font_size": 9,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
client.put(
|
||||||
|
f"{_badges_url(edition)}/template/front-image",
|
||||||
|
files={"file": ("front.png", _PNG, "image/png")},
|
||||||
|
)
|
||||||
|
client.put(
|
||||||
|
f"{_badges_url(edition)}/template/back-image",
|
||||||
|
files={"file": ("back.png", _PNG, "image/png")},
|
||||||
|
)
|
||||||
|
|
||||||
|
volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||||
|
position = make_position(session, edition, name="Bar central")
|
||||||
|
shift = make_shift(session, edition, position, start=dt_at(9, 18), end=dt_at(9, 22))
|
||||||
|
session.add(Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"{_badges_url(edition)}/generate",
|
||||||
|
json={"volunteer_ids": [str(volunteer.id)]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"] == "application/pdf"
|
||||||
|
assert resp.content.startswith(b"%PDF")
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Texte résolu des champs du badge — fonctions pures (pas de base)."""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.api.badges import _field_text, _position_line
|
||||||
|
from app.models import (
|
||||||
|
BadgeField,
|
||||||
|
BadgeFieldType,
|
||||||
|
Position,
|
||||||
|
PositionCategory,
|
||||||
|
Shift,
|
||||||
|
Volunteer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _volunteer(**kw) -> Volunteer:
|
||||||
|
return Volunteer(id=uuid.uuid4(), edition_id=uuid.uuid4(), **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def _position(**kw) -> Position:
|
||||||
|
return Position(id=uuid.uuid4(), edition_id=uuid.uuid4(), category=PositionCategory.BAR, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def _shift(start: dt.datetime, end: dt.datetime) -> Shift:
|
||||||
|
return Shift(
|
||||||
|
id=uuid.uuid4(), edition_id=uuid.uuid4(), position_id=uuid.uuid4(),
|
||||||
|
starts_at=start, ends_at=end,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_position_line_shows_the_schedule():
|
||||||
|
volunteer = _volunteer(first_name="Alex", last_name="Martin")
|
||||||
|
position = _position(name="Bar central")
|
||||||
|
shift = _shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0))
|
||||||
|
|
||||||
|
line = _position_line(position, shift, referent=None, volunteer_id=volunteer.id)
|
||||||
|
|
||||||
|
assert line == "Bar central 19:00–22:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_position_line_shows_the_referent_when_there_is_one():
|
||||||
|
volunteer = _volunteer(first_name="Alex", last_name="Martin")
|
||||||
|
position = _position(name="Bar central")
|
||||||
|
shift = _shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0))
|
||||||
|
referent = _volunteer(first_name="Sam", last_name="Nuit")
|
||||||
|
|
||||||
|
line = _position_line(position, shift, referent=referent, volunteer_id=volunteer.id)
|
||||||
|
|
||||||
|
assert line == "Bar central 19:00–22:00 (Référent : Sam Nuit)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_position_line_hides_the_referent_when_it_is_the_badged_volunteer():
|
||||||
|
# Pas besoin de se dire à soi-même qu'on est referent de son propre poste.
|
||||||
|
volunteer = _volunteer(first_name="Sam", last_name="Nuit")
|
||||||
|
|
||||||
|
line = _position_line(
|
||||||
|
_position(name="Bar central"),
|
||||||
|
_shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0)),
|
||||||
|
referent=volunteer,
|
||||||
|
volunteer_id=volunteer.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Référent" not in line
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_text_positions_lists_one_line_per_shift_in_chronological_order():
|
||||||
|
volunteer = _volunteer(first_name="Alex", last_name="Martin")
|
||||||
|
field = BadgeField(
|
||||||
|
edition_id=uuid.uuid4(), field_type=BadgeFieldType.POSITIONS,
|
||||||
|
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
|
||||||
|
)
|
||||||
|
morning = (
|
||||||
|
_position(name="Accueil"),
|
||||||
|
_shift(dt.datetime(2026, 7, 9, 9, 0), dt.datetime(2026, 7, 9, 13, 0)),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
evening = (
|
||||||
|
_position(name="Bar central"),
|
||||||
|
_shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0)),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = _field_text(
|
||||||
|
field, volunteer,
|
||||||
|
assignments_by_volunteer={volunteer.id: [morning, evening]},
|
||||||
|
lead_volunteer_ids=set(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert text == "Accueil 09:00–13:00\nBar central 19:00–22:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_text_role_is_referent_only_for_lead_accounts():
|
||||||
|
volunteer = _volunteer(first_name="Sam", last_name="Nuit")
|
||||||
|
field = BadgeField(
|
||||||
|
edition_id=uuid.uuid4(), field_type=BadgeFieldType.ROLE,
|
||||||
|
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _field_text(field, volunteer, {}, lead_volunteer_ids=set()) == "Bénévole"
|
||||||
|
assert _field_text(field, volunteer, {}, lead_volunteer_ids={volunteer.id}) == "Référent"
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Génération des planches de badges (module pur, sans base)."""
|
||||||
|
|
||||||
|
from app.badges.pdf import (
|
||||||
|
DEFAULT_BADGE_HEIGHT_MM,
|
||||||
|
DEFAULT_BADGE_WIDTH_MM,
|
||||||
|
BadgeFieldSpec,
|
||||||
|
BadgeSpec,
|
||||||
|
_cell_origin,
|
||||||
|
_grid_size,
|
||||||
|
generate_badge_sheets_pdf,
|
||||||
|
image_size_mm,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _badge(text="Alex Martin") -> BadgeSpec:
|
||||||
|
return BadgeSpec(
|
||||||
|
fields=(
|
||||||
|
BadgeFieldSpec(
|
||||||
|
text=text,
|
||||||
|
x_mm=5,
|
||||||
|
y_mm=5,
|
||||||
|
width_mm=60,
|
||||||
|
height_mm=10,
|
||||||
|
font_size=12,
|
||||||
|
align="LEFT",
|
||||||
|
bold=True,
|
||||||
|
color="#000000",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_size_mm_uses_the_assumed_print_resolution():
|
||||||
|
# 300 dpi (résolution d'impression standard) : 1013 px / 300 * 25,4 mm.
|
||||||
|
width_mm, height_mm = image_size_mm(1013, 638, dpi=300)
|
||||||
|
assert width_mm == 1013 / 300 * 25.4
|
||||||
|
assert height_mm == 638 / 300 * 25.4
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_size_fits_a4_with_cr80_badges():
|
||||||
|
# A4 (210 × 297 mm), marge 10 mm, gouttière 4 mm, badge CR80 85,6 × 54 mm
|
||||||
|
# ⇒ 2 colonnes (190 mm utiles / 89,6 mm par badge+gouttière) x 4 rangées
|
||||||
|
# (277 mm utiles / 58 mm par badge+gouttière).
|
||||||
|
assert _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM) == (2, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_size_adapts_to_a_different_badge_format():
|
||||||
|
# Badge carré 50 × 50 mm : plus de colonnes/rangées qu'un CR80.
|
||||||
|
assert _grid_size(50, 50) == (3, 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cell_origin_places_badges_left_to_right_top_to_bottom():
|
||||||
|
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||||
|
x0, y0 = _cell_origin(0, cols, DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||||
|
x1, _ = _cell_origin(1, cols, DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||||
|
_, y_row2 = _cell_origin(cols, cols, DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||||
|
assert x1 > x0 # la 2e colonne est à droite de la 1re
|
||||||
|
assert y_row2 < y0 # la 2e rangée est plus bas (repère PDF : Y croît vers le haut)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generates_a_valid_pdf_for_a_single_badge():
|
||||||
|
pdf = generate_badge_sheets_pdf([_badge()], front_image=None, back_image=None)
|
||||||
|
assert pdf.startswith(b"%PDF")
|
||||||
|
assert pdf.rstrip().endswith(b"%%EOF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_generates_a_valid_pdf_with_a_custom_badge_format():
|
||||||
|
pdf = generate_badge_sheets_pdf(
|
||||||
|
[_badge()], front_image=None, back_image=None,
|
||||||
|
badge_width_mm=50, badge_height_mm=50,
|
||||||
|
)
|
||||||
|
assert pdf.startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiline_field_text_does_not_crash_generation():
|
||||||
|
badge = BadgeSpec(
|
||||||
|
fields=(
|
||||||
|
BadgeFieldSpec(
|
||||||
|
text="Bar central 19:00–22:00\nAccueil 09:00–13:00 (Référent : Sam Nuit)",
|
||||||
|
x_mm=5, y_mm=5, width_mm=70, height_mm=20,
|
||||||
|
font_size=9, align="LEFT", bold=False, color="#000000",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pdf = generate_badge_sheets_pdf([badge], front_image=None, back_image=None)
|
||||||
|
assert pdf.startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_more_badges_than_one_sheet_produces_a_larger_pdf():
|
||||||
|
cols, rows = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||||
|
per_page = cols * rows
|
||||||
|
one_page = generate_badge_sheets_pdf(
|
||||||
|
[_badge() for _ in range(per_page)], front_image=None, back_image=None
|
||||||
|
)
|
||||||
|
# Un badge de plus déborde sur une 2e planche (recto + verso en plus).
|
||||||
|
two_pages = generate_badge_sheets_pdf(
|
||||||
|
[_badge() for _ in range(per_page + 1)], front_image=None, back_image=None
|
||||||
|
)
|
||||||
|
assert len(two_pages) > len(one_page)
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
"""Formulaire public d'inscription : configuration (COORDINATION) et
|
||||||
|
soumission publique (aucune authentification)."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import datetime as dt
|
||||||
|
import io
|
||||||
|
|
||||||
|
from app.auth.rate_limit import form_submission_limiter
|
||||||
|
from app.models import Role
|
||||||
|
from tests.factories import make_artist, make_position
|
||||||
|
|
||||||
|
# PNG 1×1 valide (transparent), le plus petit possible.
|
||||||
|
_PNG = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
|
||||||
|
"42YAAAAASUVORK5CYII="
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _form_url(edition, suffix=""):
|
||||||
|
return f"/editions/{edition.id}/form{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _submit(client, edition, **fields):
|
||||||
|
return client.post(_form_url(edition, "/public/submit"), data=fields)
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_starts_open_with_no_fields(client, edition):
|
||||||
|
resp = client.get(_form_url(edition))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["is_open"] is True
|
||||||
|
assert body["fields"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_is_coordinator_only(edition, make_account, as_account):
|
||||||
|
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||||
|
lead_client = as_account(lead)
|
||||||
|
assert lead_client.get(_form_url(edition)).status_code == 403
|
||||||
|
resp = lead_client.put(_form_url(edition, "/fields"), json={"fields": []})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_setting_fields_replaces_them_entirely(client, edition):
|
||||||
|
resp = client.put(
|
||||||
|
_form_url(edition, "/fields"),
|
||||||
|
json={"fields": ["EMAIL", "PHONE", "PHOTO"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert sorted(resp.json()["fields"]) == ["EMAIL", "PHONE", "PHOTO"]
|
||||||
|
|
||||||
|
# Un second PUT remplace, ne cumule pas.
|
||||||
|
resp = client.put(_form_url(edition, "/fields"), json={"fields": ["EMAIL"]})
|
||||||
|
assert resp.json()["fields"] == ["EMAIL"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_closing_the_form(client, edition):
|
||||||
|
resp = client.patch(_form_url(edition), json={"is_open": False})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["is_open"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_setting_description_does_not_touch_is_open(client, edition):
|
||||||
|
client.patch(_form_url(edition), json={"is_open": False})
|
||||||
|
resp = client.patch(_form_url(edition), json={"description": "Bienvenue !"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["description"] == "Bienvenue !"
|
||||||
|
assert resp.json()["is_open"] is False # inchangé, absent du corps
|
||||||
|
|
||||||
|
|
||||||
|
def test_clearing_description_with_an_explicit_null(client, edition):
|
||||||
|
client.patch(_form_url(edition), json={"description": "Bienvenue !"})
|
||||||
|
resp = client.patch(_form_url(edition), json={"description": None})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["description"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_yapla_link_by_default(client, raw_client, edition):
|
||||||
|
assert client.get(_form_url(edition)).json()["yapla_url"] is None
|
||||||
|
assert raw_client.get(_form_url(edition, "/public")).json()["yapla_url"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_setting_the_yapla_link(client, raw_client, edition):
|
||||||
|
resp = client.patch(
|
||||||
|
_form_url(edition), json={"yapla_url": "https://yapla.com/exemple/cotisation"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["yapla_url"] == "https://yapla.com/exemple/cotisation"
|
||||||
|
|
||||||
|
body = raw_client.get(_form_url(edition, "/public")).json()
|
||||||
|
assert body["yapla_url"] == "https://yapla.com/exemple/cotisation"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_a_yapla_link_without_a_scheme(client, edition):
|
||||||
|
resp = client.patch(_form_url(edition), json={"yapla_url": "yapla.com/exemple"})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "form.invalid_yapla_url"
|
||||||
|
|
||||||
|
|
||||||
|
def test_logo_is_coordinator_only(edition, make_account, as_account):
|
||||||
|
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||||
|
lead_client = as_account(lead)
|
||||||
|
resp = lead_client.put(
|
||||||
|
_form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_logo_by_default(client, raw_client, edition):
|
||||||
|
assert client.get(_form_url(edition)).json()["has_logo"] is False
|
||||||
|
resp = raw_client.get(_form_url(edition, "/logo"))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_uploading_a_logo_and_reading_it_back_publicly(client, raw_client, edition):
|
||||||
|
resp = client.put(
|
||||||
|
_form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["has_logo"] is True
|
||||||
|
|
||||||
|
assert client.get(_form_url(edition)).json()["has_logo"] is True
|
||||||
|
|
||||||
|
logo = raw_client.get(_form_url(edition, "/logo"))
|
||||||
|
assert logo.status_code == 200
|
||||||
|
assert logo.headers["content-type"] == "image/png"
|
||||||
|
assert logo.content == _PNG
|
||||||
|
|
||||||
|
|
||||||
|
def test_uploading_an_unsupported_logo_type_is_rejected(client, edition):
|
||||||
|
resp = client.put(
|
||||||
|
_form_url(edition, "/logo"),
|
||||||
|
files={"file": ("logo.gif", b"not-a-real-gif", "image/gif")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "form.logo_type_not_supported"
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_config_reports_the_logo_and_description(client, raw_client, edition):
|
||||||
|
client.patch(_form_url(edition), json={"description": "Bienvenue au festival !"})
|
||||||
|
client.put(
|
||||||
|
_form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")}
|
||||||
|
)
|
||||||
|
|
||||||
|
body = raw_client.get(_form_url(edition, "/public")).json()
|
||||||
|
assert body["description"] == "Bienvenue au festival !"
|
||||||
|
assert body["has_logo"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_form_hides_positions_and_artists_unless_enabled(
|
||||||
|
client, raw_client, edition, session
|
||||||
|
):
|
||||||
|
make_position(session, edition, name="Bar central")
|
||||||
|
make_artist(session, edition, name="Chilla")
|
||||||
|
|
||||||
|
resp = raw_client.get(_form_url(edition, "/public"))
|
||||||
|
body = resp.json()
|
||||||
|
assert body["positions"] == []
|
||||||
|
assert body["artists"] == []
|
||||||
|
|
||||||
|
client.put(
|
||||||
|
_form_url(edition, "/fields"),
|
||||||
|
json={"fields": ["POSITION_WISHES", "ARTIST_WISHES"]},
|
||||||
|
)
|
||||||
|
resp = raw_client.get(_form_url(edition, "/public"))
|
||||||
|
body = resp.json()
|
||||||
|
assert [p["name"] for p in body["positions"]] == ["Bar central"]
|
||||||
|
assert [a["name"] for a in body["artists"]] == ["Chilla"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_creates_a_volunteer(raw_client, client, edition, session):
|
||||||
|
client.put(
|
||||||
|
_form_url(edition, "/fields"),
|
||||||
|
json={"fields": ["EMAIL", "PHONE", "AVAILABLE", "WILLING_TO_PAY_FEE"]},
|
||||||
|
)
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="alex", last_name="martin", hcaptcha_token="x",
|
||||||
|
email="alex@example.org", phone="0600000000",
|
||||||
|
available="true", willing_to_pay_fee="true",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
volunteer_id = resp.json()["volunteer_id"]
|
||||||
|
|
||||||
|
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||||
|
assert fiche["first_name"] == "Alex"
|
||||||
|
assert fiche["last_name"] == "Martin"
|
||||||
|
assert fiche["email"] == "alex@example.org"
|
||||||
|
assert fiche["self_declared_available"] is True
|
||||||
|
assert fiche["willing_to_pay_fee"] is True
|
||||||
|
|
||||||
|
# Le bénévole créé apparaît dans la liste de la coordination.
|
||||||
|
volunteers = client.get(f"/editions/{edition.id}/volunteers").json()
|
||||||
|
assert any(v["id"] == volunteer_id for v in volunteers)
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_ignores_fields_not_enabled(raw_client, client, edition):
|
||||||
|
# Aucun champ activé : email/téléphone envoyés quand même, ignorés.
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
email="alex@example.org", phone="0600000000",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
volunteer_id = resp.json()["volunteer_id"]
|
||||||
|
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||||
|
assert fiche["email"] is None
|
||||||
|
assert fiche["phone"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_resolves_position_and_artist_wishes(
|
||||||
|
raw_client, client, edition, session
|
||||||
|
):
|
||||||
|
position = make_position(session, edition, name="Bar central")
|
||||||
|
artist = make_artist(session, edition, name="Chilla")
|
||||||
|
client.put(
|
||||||
|
_form_url(edition, "/fields"),
|
||||||
|
json={"fields": ["POSITION_WISHES", "ARTIST_WISHES", "COMPANION_WISH"]},
|
||||||
|
)
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
position_wish_ids=f'["{position.id}"]',
|
||||||
|
artist_wish_ids=f'["{artist.id}"]',
|
||||||
|
companion_wish_note="Sam, il/elle s'inscrira bientôt",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
volunteer_id = resp.json()["volunteer_id"]
|
||||||
|
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||||
|
assert fiche["wished_position_ids"] == [str(position.id)]
|
||||||
|
assert fiche["artist_ids"] == [str(artist.id)]
|
||||||
|
assert fiche["companion_wish_note"] == "Sam, il/elle s'inscrira bientôt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_with_a_photo(raw_client, client, edition):
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]})
|
||||||
|
resp = raw_client.post(
|
||||||
|
_form_url(edition, "/public/submit"),
|
||||||
|
data={"first_name": "Alex", "last_name": "Martin", "hcaptcha_token": "x"},
|
||||||
|
files={"photo": ("selfie.png", io.BytesIO(_PNG), "image/png")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
volunteer_id = resp.json()["volunteer_id"]
|
||||||
|
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||||
|
assert fiche["has_photo"] is True
|
||||||
|
|
||||||
|
photo = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}/photo")
|
||||||
|
assert photo.status_code == 200
|
||||||
|
assert photo.headers["content-type"] == "image/png"
|
||||||
|
assert photo.content == _PNG
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_an_unsupported_photo_type_is_rejected(raw_client, client, edition):
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]})
|
||||||
|
resp = raw_client.post(
|
||||||
|
_form_url(edition, "/public/submit"),
|
||||||
|
data={"first_name": "Alex", "last_name": "Martin", "hcaptcha_token": "x"},
|
||||||
|
files={"photo": ("selfie.gif", b"not-a-real-gif", "image/gif")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "form.photo_type_not_supported"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_more_than_two_position_wishes_is_rejected(
|
||||||
|
raw_client, client, edition, session
|
||||||
|
):
|
||||||
|
positions = [
|
||||||
|
make_position(session, edition, name=f"Poste {i}") for i in range(3)
|
||||||
|
]
|
||||||
|
ids = "[" + ",".join(f'"{p.id}"' for p in positions) + "]"
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["POSITION_WISHES"]})
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
position_wish_ids=ids,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "form.too_many_position_wishes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_two_position_wishes_is_accepted(raw_client, client, edition, session):
|
||||||
|
positions = [make_position(session, edition, name=f"Poste {i}") for i in range(2)]
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["POSITION_WISHES"]})
|
||||||
|
ids = "[" + ",".join(f'"{p.id}"' for p in positions) + "]"
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
position_wish_ids=ids,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_two_artist_wishes_on_the_same_day_is_rejected(
|
||||||
|
raw_client, client, edition, session
|
||||||
|
):
|
||||||
|
a1 = make_artist(session, edition, name="Chilla")
|
||||||
|
a2 = make_artist(session, edition, name="Kntongo") # même jour par défaut
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["ARTIST_WISHES"]})
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
artist_wish_ids=f'["{a1.id}", "{a2.id}"]',
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "form.too_many_artists_same_day"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_artist_wishes_on_different_days_is_accepted(
|
||||||
|
raw_client, client, edition, session
|
||||||
|
):
|
||||||
|
a1 = make_artist(session, edition, name="Chilla", performance_date=dt.date(2026, 7, 9))
|
||||||
|
a2 = make_artist(session, edition, name="Kntongo", performance_date=dt.date(2026, 7, 10))
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["ARTIST_WISHES"]})
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
artist_wish_ids=f'["{a1.id}", "{a2.id}"]',
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_a_comment(raw_client, client, edition):
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["COMMENT"]})
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
comment="Dispo seulement le samedi",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
volunteer_id = resp.json()["volunteer_id"]
|
||||||
|
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||||
|
assert fiche["comment"] == "Dispo seulement le samedi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_with_an_unknown_position_id_is_rejected(raw_client, client, edition):
|
||||||
|
client.put(_form_url(edition, "/fields"), json={"fields": ["POSITION_WISHES"]})
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
position_wish_ids='["00000000-0000-0000-0000-000000000000"]',
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "position.unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_when_the_form_is_closed_is_rejected(raw_client, client, edition):
|
||||||
|
client.patch(_form_url(edition), json={"is_open": False})
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "form.closed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_with_a_rejected_captcha(raw_client, edition, monkeypatch):
|
||||||
|
monkeypatch.setattr("app.api.forms.verify_hcaptcha", lambda token: False)
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "form.captcha_failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submitting_is_rate_limited_per_edition(raw_client, edition):
|
||||||
|
for _ in range(form_submission_limiter.max_attempts):
|
||||||
|
form_submission_limiter.hit(str(edition.id))
|
||||||
|
resp = _submit(
|
||||||
|
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 429
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Vérification hCaptcha (module pur, pas de base) — voir `app/forms/captcha.py`."""
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.forms.captcha import verify_hcaptcha
|
||||||
|
|
||||||
|
|
||||||
|
def test_verification_is_skipped_without_a_configured_secret(monkeypatch):
|
||||||
|
monkeypatch.delenv("HCAPTCHA_SECRET_KEY", raising=False)
|
||||||
|
get_settings.cache_clear()
|
||||||
|
assert verify_hcaptcha(None) is True
|
||||||
|
assert verify_hcaptcha("") is True
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_token_is_rejected_when_a_secret_is_configured(monkeypatch):
|
||||||
|
monkeypatch.setenv("HCAPTCHA_SECRET_KEY", "secret")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
assert verify_hcaptcha(None) is False
|
||||||
|
assert verify_hcaptcha("") is False
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delegates_to_the_hcaptcha_api_when_a_secret_is_configured(monkeypatch):
|
||||||
|
monkeypatch.setenv("HCAPTCHA_SECRET_KEY", "secret")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.forms.captcha._verify_with_hcaptcha",
|
||||||
|
lambda token, secret: calls.append((token, secret)) or True,
|
||||||
|
)
|
||||||
|
assert verify_hcaptcha("a-token") is True
|
||||||
|
assert calls == [("a-token", "secret")]
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_failure_is_treated_as_a_failed_verification(monkeypatch):
|
||||||
|
monkeypatch.setenv("HCAPTCHA_SECRET_KEY", "secret")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
def _raise(*args, **kwargs):
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
raise httpx.ConnectError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr("httpx.post", _raise)
|
||||||
|
assert verify_hcaptcha("a-token") is False
|
||||||
|
get_settings.cache_clear()
|
||||||
@@ -27,6 +27,10 @@ EXPECTED_TABLES = {
|
|||||||
"shift",
|
"shift",
|
||||||
"availability",
|
"availability",
|
||||||
"assignment",
|
"assignment",
|
||||||
|
"badge_template",
|
||||||
|
"badge_field",
|
||||||
|
"form_template",
|
||||||
|
"form_field",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Feuilles de poste (PDF) — API."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from app.models import Assignment, Role
|
||||||
|
from tests.factories import dt_at, make_artist, make_position, make_shift, make_volunteer
|
||||||
|
|
||||||
|
# PNG 1×1 valide (transparent), le plus petit possible.
|
||||||
|
_PNG = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
|
||||||
|
"42YAAAAASUVORK5CYII="
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _url(edition):
|
||||||
|
return f"/editions/{edition.id}/position-sheets/generate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_coordinator_only(edition, make_account, as_account):
|
||||||
|
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||||
|
client = as_account(lead)
|
||||||
|
resp = client.post(_url(edition), json={})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_when_no_position_matches(client, edition):
|
||||||
|
resp = client.post(
|
||||||
|
_url(edition),
|
||||||
|
json={"position_ids": ["00000000-0000-0000-0000-000000000000"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "position_sheets.no_positions"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_when_no_real_need_to_print(client, edition, session):
|
||||||
|
# Créneaux générés à 0/0, jamais remplis : rien à imprimer.
|
||||||
|
make_position(session, edition, name="Bar central")
|
||||||
|
resp = client.post(_url(edition), json={})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "position_sheets.no_shifts"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generates_a_pdf_with_assigned_volunteers(client, edition, session):
|
||||||
|
position = make_position(session, edition, name="Bar central")
|
||||||
|
shift = make_shift(
|
||||||
|
session, edition, position, start=dt_at(9, 19), end=dt_at(9, 22),
|
||||||
|
min_staff=1, ideal_staff=2,
|
||||||
|
)
|
||||||
|
volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||||
|
session.add(
|
||||||
|
Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
resp = client.post(_url(edition), json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"] == "application/pdf"
|
||||||
|
assert resp.content.startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_generates_a_pdf_with_a_referent(client, edition, session, make_account):
|
||||||
|
position = make_position(session, edition, name="Bar central")
|
||||||
|
make_shift(
|
||||||
|
session, edition, position, start=dt_at(9, 19), end=dt_at(9, 22),
|
||||||
|
min_staff=1, ideal_staff=2,
|
||||||
|
)
|
||||||
|
referent_volunteer = make_volunteer(session, edition, first_name="Sam", last_name="Nuit")
|
||||||
|
referent_account = make_account(
|
||||||
|
Role.LEAD, volunteer_id=referent_volunteer.id, email="referent@e.fr"
|
||||||
|
)
|
||||||
|
client.patch(
|
||||||
|
f"/editions/{edition.id}/positions/{position.id}",
|
||||||
|
json={"lead_account_id": str(referent_account.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(_url(edition), json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.content.startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_includes_a_volunteer_photo_when_present(client, edition, session):
|
||||||
|
position = make_position(session, edition, name="Bar central")
|
||||||
|
shift = make_shift(
|
||||||
|
session, edition, position, start=dt_at(9, 19), end=dt_at(9, 22),
|
||||||
|
min_staff=1, ideal_staff=2,
|
||||||
|
)
|
||||||
|
with_photo = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||||
|
with_photo.photo = _PNG
|
||||||
|
with_photo.photo_content_type = "image/png"
|
||||||
|
without_photo = make_volunteer(session, edition, first_name="Sam", last_name="Nuit")
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
Assignment(edition_id=edition.id, volunteer_id=with_photo.id, shift_id=shift.id),
|
||||||
|
Assignment(edition_id=edition.id, volunteer_id=without_photo.id, shift_id=shift.id),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
resp = client.post(_url(edition), json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.content.startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_includes_the_day_lineup_across_all_positions(client, edition, session):
|
||||||
|
bar = make_position(session, edition, name="Bar central")
|
||||||
|
welcome = make_position(session, edition, name="Accueil")
|
||||||
|
make_shift(
|
||||||
|
session, edition, bar, start=dt_at(9, 19), end=dt_at(9, 22),
|
||||||
|
min_staff=1, ideal_staff=2,
|
||||||
|
)
|
||||||
|
make_shift(
|
||||||
|
session, edition, welcome, start=dt_at(9, 9), end=dt_at(9, 13),
|
||||||
|
min_staff=1, ideal_staff=2,
|
||||||
|
)
|
||||||
|
# Passage le 9 (jour des deux créneaux) : doit apparaître sur les deux
|
||||||
|
# feuilles, pas seulement celle du poste « Régie ».
|
||||||
|
make_artist(
|
||||||
|
session, edition, name="Chilla",
|
||||||
|
performance_date=dt.date(2026, 7, 9),
|
||||||
|
start_time=dt.time(20, 0), end_time=dt.time(21, 0),
|
||||||
|
)
|
||||||
|
# Passage un autre jour : ne doit apparaître sur aucune des deux.
|
||||||
|
make_artist(
|
||||||
|
session, edition, name="Hors sujet",
|
||||||
|
performance_date=dt.date(2026, 7, 11),
|
||||||
|
start_time=dt.time(20, 0), end_time=dt.time(21, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(_url(edition), json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.content.startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_position_ids_filter_scopes_the_sheets(client, edition, session):
|
||||||
|
bar = make_position(session, edition, name="Bar central")
|
||||||
|
welcome = make_position(session, edition, name="Accueil")
|
||||||
|
make_shift(
|
||||||
|
session, edition, bar, start=dt_at(9, 19), end=dt_at(9, 22),
|
||||||
|
min_staff=1, ideal_staff=2,
|
||||||
|
)
|
||||||
|
make_shift(
|
||||||
|
session, edition, welcome, start=dt_at(9, 9), end=dt_at(9, 13),
|
||||||
|
min_staff=1, ideal_staff=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(_url(edition), json={"position_ids": [str(bar.id)]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
resp = client.post(_url(edition), json={"position_ids": [str(welcome.id)]})
|
||||||
|
assert resp.status_code == 200
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Génération des feuilles de poste (module pur, sans base)."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from app.sheets.pdf import (
|
||||||
|
PositionDaySheet,
|
||||||
|
SheetArtist,
|
||||||
|
SheetShift,
|
||||||
|
SheetVolunteer,
|
||||||
|
generate_position_sheets_pdf,
|
||||||
|
)
|
||||||
|
|
||||||
|
# PNG 1×1 valide (transparent), le plus petit possible.
|
||||||
|
_PNG = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
|
||||||
|
"42YAAAAASUVORK5CYII="
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sheet(volunteers=(), min_staff=1, ideal_staff=2) -> PositionDaySheet:
|
||||||
|
return PositionDaySheet(
|
||||||
|
position_name="Bar central",
|
||||||
|
position_location="Chapiteau",
|
||||||
|
date=dt.date(2026, 7, 10),
|
||||||
|
shifts=(
|
||||||
|
SheetShift(
|
||||||
|
starts_at=dt.datetime(2026, 7, 10, 19, 0),
|
||||||
|
ends_at=dt.datetime(2026, 7, 10, 22, 0),
|
||||||
|
min_staff=min_staff,
|
||||||
|
ideal_staff=ideal_staff,
|
||||||
|
volunteers=volunteers,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_shift_is_understaffed_when_below_min_staff():
|
||||||
|
fully_staffed = _sheet(
|
||||||
|
volunteers=(SheetVolunteer(name="Alex Martin", phone="0600000000"),),
|
||||||
|
min_staff=1,
|
||||||
|
)
|
||||||
|
assert fully_staffed.shifts[0].understaffed is False
|
||||||
|
|
||||||
|
understaffed = _sheet(volunteers=(), min_staff=1)
|
||||||
|
assert understaffed.shifts[0].understaffed is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_generates_a_valid_pdf_with_volunteers():
|
||||||
|
sheet = _sheet(
|
||||||
|
volunteers=(
|
||||||
|
SheetVolunteer(name="Alex Martin", phone="0600000000"),
|
||||||
|
SheetVolunteer(name="Sam Nuit", phone=None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pdf = generate_position_sheets_pdf([sheet])
|
||||||
|
assert pdf.startswith(b"%PDF")
|
||||||
|
assert pdf.rstrip().endswith(b"%%EOF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_generates_a_valid_pdf_with_no_volunteers_assigned():
|
||||||
|
pdf = generate_position_sheets_pdf([_sheet(volunteers=())])
|
||||||
|
assert pdf.startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_generates_a_valid_pdf_with_no_shifts_that_day():
|
||||||
|
sheet = PositionDaySheet(
|
||||||
|
position_name="Bar central", position_location=None,
|
||||||
|
date=dt.date(2026, 7, 10), shifts=(),
|
||||||
|
)
|
||||||
|
pdf = generate_position_sheets_pdf([sheet])
|
||||||
|
assert pdf.startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_sheets_produce_a_larger_pdf():
|
||||||
|
one = generate_position_sheets_pdf([_sheet()])
|
||||||
|
two = generate_position_sheets_pdf([_sheet(), _sheet()])
|
||||||
|
assert len(two) > len(one)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generates_a_valid_pdf_with_a_referent():
|
||||||
|
sheet = PositionDaySheet(
|
||||||
|
position_name="Bar central", position_location="Chapiteau",
|
||||||
|
date=dt.date(2026, 7, 10), shifts=(),
|
||||||
|
referent_name="Sam Nuit", referent_phone="0600000000",
|
||||||
|
)
|
||||||
|
pdf = generate_position_sheets_pdf([sheet])
|
||||||
|
assert pdf.startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_generates_a_valid_pdf_without_a_referent():
|
||||||
|
sheet = PositionDaySheet(
|
||||||
|
position_name="Bar central", position_location="Chapiteau",
|
||||||
|
date=dt.date(2026, 7, 10), shifts=(),
|
||||||
|
)
|
||||||
|
assert sheet.referent_name is None
|
||||||
|
pdf = generate_position_sheets_pdf([sheet])
|
||||||
|
assert pdf.startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_generates_a_valid_pdf_with_volunteer_photos():
|
||||||
|
sheet = _sheet(
|
||||||
|
volunteers=(
|
||||||
|
SheetVolunteer(name="Alex Martin", phone="0600000000", photo=_PNG),
|
||||||
|
SheetVolunteer(name="Sam Nuit", phone=None, photo=None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert sheet.shifts[0].volunteers[0].photo == _PNG
|
||||||
|
assert sheet.shifts[0].volunteers[1].photo is None
|
||||||
|
pdf = generate_position_sheets_pdf([sheet])
|
||||||
|
assert pdf.startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_volunteer_photo_makes_the_pdf_larger():
|
||||||
|
without_photo = generate_position_sheets_pdf(
|
||||||
|
[_sheet(volunteers=(SheetVolunteer(name="Alex Martin", phone=None, photo=None),))]
|
||||||
|
)
|
||||||
|
with_photo = generate_position_sheets_pdf(
|
||||||
|
[_sheet(volunteers=(SheetVolunteer(name="Alex Martin", phone=None, photo=_PNG),))]
|
||||||
|
)
|
||||||
|
assert len(with_photo) > len(without_photo)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generates_a_valid_pdf_with_the_day_lineup():
|
||||||
|
sheet = PositionDaySheet(
|
||||||
|
position_name="Bar central", position_location="Chapiteau",
|
||||||
|
date=dt.date(2026, 7, 10), shifts=(),
|
||||||
|
artists=(
|
||||||
|
SheetArtist(name="Chilla", start_time=dt.time(20, 0), end_time=dt.time(21, 0)),
|
||||||
|
SheetArtist(name="XYZ", start_time=dt.time(21, 30), end_time=dt.time(23, 0)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
pdf = generate_position_sheets_pdf([sheet])
|
||||||
|
assert pdf.startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_generates_a_valid_pdf_without_a_lineup():
|
||||||
|
sheet = PositionDaySheet(
|
||||||
|
position_name="Bar central", position_location="Chapiteau",
|
||||||
|
date=dt.date(2026, 7, 10), shifts=(),
|
||||||
|
)
|
||||||
|
assert sheet.artists == ()
|
||||||
|
pdf = generate_position_sheets_pdf([sheet])
|
||||||
|
assert pdf.startswith(b"%PDF")
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
"""Génération des créneaux d'un poste à partir des créneaux types."""
|
"""Génération des créneaux d'un poste à partir des créneaux types.
|
||||||
|
|
||||||
|
Depuis le passage à la création de créneaux à la main (voir
|
||||||
|
`test_shifts_api.py`), un `Position` ne génère plus rien automatiquement à sa
|
||||||
|
création : `generate-shifts` reste l'outil optionnel pour repartir des
|
||||||
|
créneaux types de l'édition."""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
|
||||||
def _positions_url(edition):
|
def _positions_url(edition):
|
||||||
@@ -11,12 +18,26 @@ def _create_position(client, edition, name="Bar"):
|
|||||||
).json()
|
).json()
|
||||||
|
|
||||||
|
|
||||||
def test_creating_a_position_generates_one_shift_per_day_and_type(client, edition):
|
def _generate(client, edition, position):
|
||||||
|
return client.post(f"{_positions_url(edition)}/{position['id']}/generate-shifts")
|
||||||
|
|
||||||
|
|
||||||
|
def test_creating_a_position_generates_no_shifts(client, edition):
|
||||||
position = _create_position(client, edition)
|
position = _create_position(client, edition)
|
||||||
shifts = client.get(
|
shifts = client.get(
|
||||||
f"/editions/{edition.id}/shifts", params={"position_id": position["id"]}
|
f"/editions/{edition.id}/shifts", params={"position_id": position["id"]}
|
||||||
).json()
|
).json()
|
||||||
|
assert shifts == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_shifts_creates_one_per_day_and_type(client, edition):
|
||||||
|
position = _create_position(client, edition)
|
||||||
|
resp = _generate(client, edition, position)
|
||||||
# 4 jours (2026-07-09..12) x 3 créneaux types.
|
# 4 jours (2026-07-09..12) x 3 créneaux types.
|
||||||
|
assert resp.json()["created"] == 12
|
||||||
|
shifts = client.get(
|
||||||
|
f"/editions/{edition.id}/shifts", params={"position_id": position["id"]}
|
||||||
|
).json()
|
||||||
assert len(shifts) == 12
|
assert len(shifts) == 12
|
||||||
assert all(s["min_staff"] == 0 and s["ideal_staff"] == 0 for s in shifts)
|
assert all(s["min_staff"] == 0 and s["ideal_staff"] == 0 for s in shifts)
|
||||||
assert all(s["day_part_id"] is not None for s in shifts)
|
assert all(s["day_part_id"] is not None for s in shifts)
|
||||||
@@ -24,20 +45,18 @@ def test_creating_a_position_generates_one_shift_per_day_and_type(client, editio
|
|||||||
|
|
||||||
def test_generate_is_idempotent(client, edition):
|
def test_generate_is_idempotent(client, edition):
|
||||||
position = _create_position(client, edition)
|
position = _create_position(client, edition)
|
||||||
resp = client.post(
|
_generate(client, edition, position)
|
||||||
f"{_positions_url(edition)}/{position['id']}/generate-shifts"
|
resp = _generate(client, edition, position)
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["created"] == 0
|
assert resp.json()["created"] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_extending_the_edition_then_generating_adds_the_new_days(client, edition):
|
def test_extending_the_edition_then_generating_adds_the_new_days(client, edition):
|
||||||
position = _create_position(client, edition)
|
position = _create_position(client, edition)
|
||||||
|
_generate(client, edition, position)
|
||||||
client.patch(f"/editions/{edition.id}", json={"end_date": "2026-07-14"})
|
client.patch(f"/editions/{edition.id}", json={"end_date": "2026-07-14"})
|
||||||
|
|
||||||
resp = client.post(
|
resp = _generate(client, edition, position)
|
||||||
f"{_positions_url(edition)}/{position['id']}/generate-shifts"
|
|
||||||
)
|
|
||||||
# 2 jours de plus x 3 créneaux types.
|
# 2 jours de plus x 3 créneaux types.
|
||||||
assert resp.json()["created"] == 6
|
assert resp.json()["created"] == 6
|
||||||
shifts = client.get(
|
shifts = client.get(
|
||||||
@@ -48,18 +67,46 @@ def test_extending_the_edition_then_generating_adds_the_new_days(client, edition
|
|||||||
|
|
||||||
def test_adding_a_day_part_then_generating_adds_it_everywhere(client, edition):
|
def test_adding_a_day_part_then_generating_adds_it_everywhere(client, edition):
|
||||||
position = _create_position(client, edition)
|
position = _create_position(client, edition)
|
||||||
|
_generate(client, edition, position)
|
||||||
client.post(
|
client.post(
|
||||||
f"/editions/{edition.id}/day-parts",
|
f"/editions/{edition.id}/day-parts",
|
||||||
json={"label": "Nuit", "start_time": "02:00", "end_time": "05:00"},
|
json={"label": "Nuit", "start_time": "02:00", "end_time": "05:00"},
|
||||||
)
|
)
|
||||||
resp = client.post(
|
resp = _generate(client, edition, position)
|
||||||
f"{_positions_url(edition)}/{position['id']}/generate-shifts"
|
|
||||||
)
|
|
||||||
assert resp.json()["created"] == 4 # 1 par jour
|
assert resp.json()["created"] == 4 # 1 par jour
|
||||||
|
|
||||||
|
|
||||||
|
def test_day_part_before_the_day_boundary_rolls_to_the_next_calendar_day(client, edition):
|
||||||
|
"""La frontière du jour est le début du créneau type de plus petit `order`
|
||||||
|
(ici MORNING, 06 h — voir CLAUDE.md § 3 et `validation.engine._day_boundary`).
|
||||||
|
Un créneau type qui commence avant cette heure (ex. `Nuit` 02 h–05 h,
|
||||||
|
suite de `Soirée` 19 h–00 h) est donc la fin de la nuit du jour
|
||||||
|
*précédent* : il doit être généré sur le jour suivant, pas sur `day`."""
|
||||||
|
position = _create_position(client, edition)
|
||||||
|
_generate(client, edition, position)
|
||||||
|
client.post(
|
||||||
|
f"/editions/{edition.id}/day-parts",
|
||||||
|
json={"label": "Nuit", "start_time": "02:00", "end_time": "05:00", "order": 3},
|
||||||
|
)
|
||||||
|
_generate(client, edition, position)
|
||||||
|
|
||||||
|
shifts = client.get(
|
||||||
|
f"/editions/{edition.id}/shifts", params={"position_id": position["id"]}
|
||||||
|
).json()
|
||||||
|
nuit_shifts = [
|
||||||
|
s for s in shifts if dt.time.fromisoformat(s["starts_at"][11:19]) == dt.time(2, 0)
|
||||||
|
]
|
||||||
|
assert len(nuit_shifts) == 4
|
||||||
|
|
||||||
|
nuit_dates = sorted(dt.date.fromisoformat(s["starts_at"][:10]) for s in nuit_shifts)
|
||||||
|
# Édition du 9 au 12/07 : la nuit du 9 (Soirée 19 h) se termine le 10 à
|
||||||
|
# 05 h, ..., la nuit du 12 se termine le 13 — jamais le 9 lui-même.
|
||||||
|
assert nuit_dates == [dt.date(2026, 7, d) for d in (10, 11, 12, 13)]
|
||||||
|
|
||||||
|
|
||||||
def test_generation_does_not_touch_staffing_already_set(client, edition):
|
def test_generation_does_not_touch_staffing_already_set(client, edition):
|
||||||
position = _create_position(client, edition)
|
position = _create_position(client, edition)
|
||||||
|
_generate(client, edition, position)
|
||||||
shift = client.get(
|
shift = client.get(
|
||||||
f"/editions/{edition.id}/shifts", params={"position_id": position["id"]}
|
f"/editions/{edition.id}/shifts", params={"position_id": position["id"]}
|
||||||
).json()[0]
|
).json()[0]
|
||||||
@@ -67,6 +114,6 @@ def test_generation_does_not_touch_staffing_already_set(client, edition):
|
|||||||
f"/editions/{edition.id}/shifts/{shift['id']}",
|
f"/editions/{edition.id}/shifts/{shift['id']}",
|
||||||
json={"min_staff": 3, "ideal_staff": 5},
|
json={"min_staff": 3, "ideal_staff": 5},
|
||||||
)
|
)
|
||||||
client.post(f"{_positions_url(edition)}/{position['id']}/generate-shifts")
|
_generate(client, edition, position)
|
||||||
again = client.get(f"/editions/{edition.id}/shifts/{shift['id']}").json()
|
again = client.get(f"/editions/{edition.id}/shifts/{shift['id']}").json()
|
||||||
assert again["min_staff"] == 3 and again["ideal_staff"] == 5
|
assert again["min_staff"] == 3 and again["ideal_staff"] == 5
|
||||||
|
|||||||
Generated
+77
-2
@@ -106,19 +106,21 @@ dependencies = [
|
|||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
{ name = "bcrypt" },
|
{ name = "bcrypt" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
{ name = "httpx" },
|
||||||
{ name = "openpyxl" },
|
{ name = "openpyxl" },
|
||||||
{ name = "ortools" },
|
{ name = "ortools" },
|
||||||
|
{ name = "pillow" },
|
||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
|
{ name = "reportlab" },
|
||||||
{ name = "sqlmodel" },
|
{ name = "sqlmodel" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "httpx" },
|
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-cov" },
|
{ name = "pytest-cov" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
@@ -129,19 +131,21 @@ requires-dist = [
|
|||||||
{ name = "alembic", specifier = ">=1.14" },
|
{ name = "alembic", specifier = ">=1.14" },
|
||||||
{ name = "bcrypt", specifier = ">=4.1" },
|
{ name = "bcrypt", specifier = ">=4.1" },
|
||||||
{ name = "fastapi", specifier = ">=0.115" },
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
{ name = "openpyxl", specifier = ">=3.1" },
|
{ name = "openpyxl", specifier = ">=3.1" },
|
||||||
{ name = "ortools", specifier = ">=9.11" },
|
{ name = "ortools", specifier = ">=9.11" },
|
||||||
|
{ name = "pillow", specifier = ">=11.0" },
|
||||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
|
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.6" },
|
{ name = "pydantic-settings", specifier = ">=2.6" },
|
||||||
{ name = "pyjwt", specifier = ">=2.9" },
|
{ name = "pyjwt", specifier = ">=2.9" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.12" },
|
{ name = "python-multipart", specifier = ">=0.0.12" },
|
||||||
|
{ name = "reportlab", specifier = ">=4.2" },
|
||||||
{ name = "sqlmodel", specifier = ">=0.0.22" },
|
{ name = "sqlmodel", specifier = ">=0.0.22" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "httpx", specifier = ">=0.27" },
|
|
||||||
{ name = "pytest", specifier = ">=8.3" },
|
{ name = "pytest", specifier = ">=8.3" },
|
||||||
{ name = "pytest-cov", specifier = ">=6.0" },
|
{ name = "pytest-cov", specifier = ">=6.0" },
|
||||||
{ name = "ruff", specifier = ">=0.8" },
|
{ name = "ruff", specifier = ">=0.8" },
|
||||||
@@ -156,6 +160,47 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983 },
|
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.5.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.5.0"
|
version = "8.5.0"
|
||||||
@@ -433,6 +478,23 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796 },
|
{ url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pillow"
|
||||||
|
version = "12.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
@@ -652,6 +714,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 },
|
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reportlab"
|
||||||
|
version = "5.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "pillow" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4a/51/dbe28534ae12c852f61be91f039f343305fd1f34f1c66b8de75afae7a525/reportlab-5.0.1.tar.gz", hash = "sha256:ebd13154be1c8515e665de70bd2d303ae9ddc3ef47e44afd5116441ca0283a26", size = 3945711 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/cb/dacbc268cb68d0428ea2cbd85266195a9ab3e677449589ddae59bd7542ac/reportlab-5.0.1-py3-none-any.whl", hash = "sha256:1c36e6bb0e71780c72331eba60da7f602e8d4389a8723825af71342e49d791e8", size = 1957258 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.16.6"
|
version = "0.16.6"
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ Variables (Paramètres → Actions → Variables) :
|
|||||||
| `SSH_USER` | Utilisateur SSH |
|
| `SSH_USER` | Utilisateur SSH |
|
||||||
| `DEPLOY_PATH` | Dossier du projet sur le VPS (ex. `/opt/benevoles`) |
|
| `DEPLOY_PATH` | Dossier du projet sur le VPS (ex. `/opt/benevoles`) |
|
||||||
| `VITE_EDITION_ID` | UUID de l'édition de production — **à ajouter après** l'étape 3 |
|
| `VITE_EDITION_ID` | UUID de l'édition de production — **à ajouter après** l'étape 3 |
|
||||||
|
| `VITE_HCAPTCHA_SITE_KEY` | Clé de site hCaptcha (publique) — voir étape 1, formulaire public d'inscription. Optionnelle : vide ⇒ formulaire sans vérification anti-robot |
|
||||||
|
|
||||||
La clé publique correspondant à `SSH_KEY` doit être dans le
|
La clé publique correspondant à `SSH_KEY` doit être dans le
|
||||||
`~/.ssh/authorized_keys` de `SSH_USER` sur le VPS.
|
`~/.ssh/authorized_keys` de `SSH_USER` sur le VPS.
|
||||||
@@ -100,6 +101,13 @@ SMTP_PASSWORD=<mot de passe SMTP>
|
|||||||
SMTP_STARTTLS=true
|
SMTP_STARTTLS=true
|
||||||
SMTP_SSL=false
|
SMTP_SSL=false
|
||||||
SMTP_FROM=Festival <no-reply@mon-domaine.fr>
|
SMTP_FROM=Festival <no-reply@mon-domaine.fr>
|
||||||
|
|
||||||
|
# Formulaire public d'inscription (/inscription). Vide ⇒ aucune vérification
|
||||||
|
# anti-robot (déconseillé en public) — créer un compte gratuit sur
|
||||||
|
# https://hcaptcha.com, puis renseigner ici le secret du couple site/secret
|
||||||
|
# (la clé de site va, elle, dans la variable Gitea VITE_HCAPTCHA_SITE_KEY —
|
||||||
|
# voir tableau ci-dessus — car figée au build du frontend, pas modifiable ici).
|
||||||
|
HCAPTCHA_SECRET_KEY=
|
||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ name: benevoles
|
|||||||
|
|
||||||
# Déployé sur le VPS. Les variables sont lues depuis un fichier .env situé à
|
# Déployé sur le VPS. Les variables sont lues depuis un fichier .env situé à
|
||||||
# côté de ce fichier (non versionné) : POSTGRES_USER, POSTGRES_PASSWORD,
|
# côté de ce fichier (non versionné) : POSTGRES_USER, POSTGRES_PASSWORD,
|
||||||
# POSTGRES_DB, JWT_SECRET, PUBLIC_BASE_URL et les variables SMTP_* / EMAIL_ENABLED.
|
# POSTGRES_DB, JWT_SECRET, PUBLIC_BASE_URL, HCAPTCHA_SECRET_KEY et les
|
||||||
# BACKEND_IMAGE / FRONTEND_IMAGE sont exportées par le pipeline de déploiement
|
# variables SMTP_* / EMAIL_ENABLED. BACKEND_IMAGE / FRONTEND_IMAGE sont
|
||||||
# (.gitea/workflows/ci-cd.yml).
|
# exportées par le pipeline de déploiement (.gitea/workflows/ci-cd.yml) —
|
||||||
|
# VITE_HCAPTCHA_SITE_KEY, elle, est figée au build de l'image frontend (voir
|
||||||
|
# ci-cd.yml et deploy/README.md), pas modifiable ici.
|
||||||
#
|
#
|
||||||
# Les deux services ne publient que sur `127.0.0.1` : c'est le Caddy du VPS
|
# Les deux services ne publient que sur `127.0.0.1` : c'est le Caddy du VPS
|
||||||
# (hors de ce compose, voir deploy/README.md) qui reçoit le trafic public et
|
# (hors de ce compose, voir deploy/README.md) qui reçoit le trafic public et
|
||||||
@@ -41,6 +43,7 @@ services:
|
|||||||
SMTP_STARTTLS: ${SMTP_STARTTLS:-true}
|
SMTP_STARTTLS: ${SMTP_STARTTLS:-true}
|
||||||
SMTP_SSL: ${SMTP_SSL:-false}
|
SMTP_SSL: ${SMTP_SSL:-false}
|
||||||
SMTP_FROM: ${SMTP_FROM:-}
|
SMTP_FROM: ${SMTP_FROM:-}
|
||||||
|
HCAPTCHA_SECRET_KEY: ${HCAPTCHA_SECRET_KEY:-}
|
||||||
TZ: Europe/Paris
|
TZ: Europe/Paris
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ services:
|
|||||||
SMTP_STARTTLS: ${SMTP_STARTTLS:-true}
|
SMTP_STARTTLS: ${SMTP_STARTTLS:-true}
|
||||||
SMTP_SSL: ${SMTP_SSL:-false}
|
SMTP_SSL: ${SMTP_SSL:-false}
|
||||||
SMTP_FROM: ${SMTP_FROM:-Festival des bénévoles <no-reply@example.test>}
|
SMTP_FROM: ${SMTP_FROM:-Festival des bénévoles <no-reply@example.test>}
|
||||||
|
# Formulaire public d'inscription (app/api/forms.py). Vide en dev ⇒
|
||||||
|
# vérification hCaptcha désactivée (voir app/forms/captcha.py).
|
||||||
|
HCAPTCHA_SECRET_KEY: ${HCAPTCHA_SECRET_KEY:-}
|
||||||
TZ: Europe/Paris
|
TZ: Europe/Paris
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
@@ -54,6 +57,10 @@ services:
|
|||||||
# existante, p. ex. après `docker compose run --rm backend uv run python -m app.seed`).
|
# existante, p. ex. après `docker compose run --rm backend uv run python -m app.seed`).
|
||||||
VITE_API_MODE: ${VITE_API_MODE:-mock}
|
VITE_API_MODE: ${VITE_API_MODE:-mock}
|
||||||
VITE_EDITION_ID: ${VITE_EDITION_ID:-}
|
VITE_EDITION_ID: ${VITE_EDITION_ID:-}
|
||||||
|
# Clé de site hCaptcha (publique) : sans elle, le formulaire public
|
||||||
|
# d'inscription s'affiche sans vérification anti-robot (voir
|
||||||
|
# PublicSignupPage.tsx).
|
||||||
|
VITE_HCAPTCHA_SITE_KEY: ${HCAPTCHA_SITE_KEY:-}
|
||||||
# Cible du proxy `/api` de Vite à l'intérieur du réseau compose.
|
# Cible du proxy `/api` de Vite à l'intérieur du réseau compose.
|
||||||
VITE_API_TARGET: http://backend:8000
|
VITE_API_TARGET: http://backend:8000
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ COPY . .
|
|||||||
|
|
||||||
ARG VITE_API_MODE=http
|
ARG VITE_API_MODE=http
|
||||||
ARG VITE_EDITION_ID
|
ARG VITE_EDITION_ID
|
||||||
|
ARG VITE_HCAPTCHA_SITE_KEY
|
||||||
ENV VITE_API_MODE=$VITE_API_MODE
|
ENV VITE_API_MODE=$VITE_API_MODE
|
||||||
ENV VITE_EDITION_ID=$VITE_EDITION_ID
|
ENV VITE_EDITION_ID=$VITE_EDITION_ID
|
||||||
|
ENV VITE_HCAPTCHA_SITE_KEY=$VITE_HCAPTCHA_SITE_KEY
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import type { AccountRead } from '@/api/schema';
|
|||||||
import { queryKeys } from '@/api/hooks';
|
import { queryKeys } from '@/api/hooks';
|
||||||
import { AuthContext, AuthProvider, type AuthContextValue } from '@/auth/auth-context';
|
import { AuthContext, AuthProvider, type AuthContextValue } from '@/auth/auth-context';
|
||||||
import { routes } from '@/app/router';
|
import { routes } from '@/app/router';
|
||||||
|
import { buildTimeGrid, placeInTimeGrid } from '@/lib/planning';
|
||||||
|
import { BadgesPage } from '@/pages/BadgesPage';
|
||||||
import { ConfigPage } from '@/pages/ConfigPage';
|
import { ConfigPage } from '@/pages/ConfigPage';
|
||||||
|
import { FormBuilderPage } from '@/pages/FormBuilderPage';
|
||||||
import { GrillePage } from '@/pages/GrillePage';
|
import { GrillePage } from '@/pages/GrillePage';
|
||||||
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
||||||
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
|
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
|
||||||
@@ -80,6 +83,249 @@ async function checkAuth() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Exerce le formulaire public de bout en bout (mode mock) : configuration,
|
||||||
|
* lecture publique, soumission, vœux (max 2 postes, un artiste par jour),
|
||||||
|
* et le bénévole créé doit apparaître dans la liste de la coordination. */
|
||||||
|
async function checkPublicForm() {
|
||||||
|
const positions = await api.positions.list(e);
|
||||||
|
const artists = await api.artists.list(e);
|
||||||
|
const beforeCount = (await api.volunteers.list(e)).length;
|
||||||
|
|
||||||
|
await api.forms.setFields(e, {
|
||||||
|
fields: ['EMAIL', 'POSITION_WISHES', 'ARTIST_WISHES', 'COMMENT'],
|
||||||
|
});
|
||||||
|
const config = await api.forms.getPublicConfig(e);
|
||||||
|
if (!config.is_open) throw new Error('formulaire public : devrait être ouvert par défaut');
|
||||||
|
if (config.positions.length !== positions.length) {
|
||||||
|
throw new Error('formulaire public : postes manquants dans la config publique');
|
||||||
|
}
|
||||||
|
if (config.has_logo !== false || config.description !== null || config.yapla_url !== null) {
|
||||||
|
throw new Error('formulaire public : logo/description/lien Yapla devraient être vides par défaut');
|
||||||
|
}
|
||||||
|
// Le recadrage carré (canvas) et l'upload du logo (FileReader) sont des
|
||||||
|
// API navigateur — non testables sous Node (voir scripts/smoke.tsx tel
|
||||||
|
// qu'exécuté ici) ; seul le texte d'introduction et le lien Yapla sont
|
||||||
|
// vérifiables ici.
|
||||||
|
await api.forms.setOpen(e, { description: 'Bienvenue au festival de test !' });
|
||||||
|
const configWithDescription = await api.forms.getPublicConfig(e);
|
||||||
|
if (configWithDescription.description !== 'Bienvenue au festival de test !') {
|
||||||
|
throw new Error("formulaire public : le texte d'introduction ne revient pas correctement");
|
||||||
|
}
|
||||||
|
|
||||||
|
let invalidYaplaUrlRejectedWith = 'non';
|
||||||
|
try {
|
||||||
|
await api.forms.setOpen(e, { yapla_url: 'yapla.com/exemple' });
|
||||||
|
} catch (err) {
|
||||||
|
invalidYaplaUrlRejectedWith = err instanceof ApiError ? err.code : String(err);
|
||||||
|
}
|
||||||
|
await api.forms.setOpen(e, { yapla_url: 'https://www.yapla.com/fr/exemple' });
|
||||||
|
const configWithYapla = await api.forms.getPublicConfig(e);
|
||||||
|
if (configWithYapla.yapla_url !== 'https://www.yapla.com/fr/exemple') {
|
||||||
|
throw new Error('formulaire public : le lien Yapla ne revient pas correctement');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameDay = artists.filter((a) => a.performance_date === artists[0]?.performance_date);
|
||||||
|
let tooManyArtistsRejectedWith = 'non';
|
||||||
|
if (sameDay.length >= 2) {
|
||||||
|
try {
|
||||||
|
await api.forms.submitPublic(e, {
|
||||||
|
first_name: 'Test',
|
||||||
|
last_name: 'Smoke',
|
||||||
|
hcaptcha_token: 'dev',
|
||||||
|
artist_wish_ids: [sameDay[0].id, sameDay[1].id],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
tooManyArtistsRejectedWith = err instanceof ApiError ? err.code : String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tooManyPositionsRejectedWith = 'non';
|
||||||
|
if (positions.length >= 3) {
|
||||||
|
try {
|
||||||
|
await api.forms.submitPublic(e, {
|
||||||
|
first_name: 'Test',
|
||||||
|
last_name: 'Smoke',
|
||||||
|
hcaptcha_token: 'dev',
|
||||||
|
position_wish_ids: positions.slice(0, 3).map((p) => p.id),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
tooManyPositionsRejectedWith = err instanceof ApiError ? err.code : String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.forms.submitPublic(e, {
|
||||||
|
first_name: 'test',
|
||||||
|
last_name: 'smoke',
|
||||||
|
hcaptcha_token: 'dev',
|
||||||
|
email: 'test.smoke@example.org',
|
||||||
|
comment: 'Créé par le smoke test',
|
||||||
|
position_wish_ids: positions.slice(0, 2).map((p) => p.id),
|
||||||
|
artist_wish_ids: artists[0] ? [artists[0].id] : [],
|
||||||
|
});
|
||||||
|
const created = await api.volunteers.get(e, result.volunteer_id);
|
||||||
|
const afterCount = (await api.volunteers.list(e)).length;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
formOpenByDefault: config.is_open,
|
||||||
|
descriptionRoundTrips: configWithDescription.description,
|
||||||
|
invalidYaplaUrlRejectedWith,
|
||||||
|
yaplaUrlRoundTrips: configWithYapla.yapla_url,
|
||||||
|
tooManyPositionsRejectedWith,
|
||||||
|
tooManyArtistsRejectedWith,
|
||||||
|
createdVolunteerName: `${created.first_name} ${created.last_name}`,
|
||||||
|
createdVolunteerAppearsInList: afterCount === beforeCount + 1,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feuilles de poste PDF (mode mock) : la sélection filtre bien, l'absence
|
||||||
|
* de créneau/poste rejette avec le bon code. */
|
||||||
|
async function checkPositionSheets() {
|
||||||
|
const positions = await api.positions.list(e);
|
||||||
|
const first = positions[0];
|
||||||
|
if (!first) throw new Error('feuilles de poste : aucun poste en démo pour tester');
|
||||||
|
|
||||||
|
const blob = await api.positionSheets.generate(e, { position_ids: [first.id] });
|
||||||
|
if (!(blob instanceof Blob) || blob.size === 0) {
|
||||||
|
throw new Error('feuilles de poste : PDF vide');
|
||||||
|
}
|
||||||
|
|
||||||
|
let noPositionsRejectedWith = 'non';
|
||||||
|
try {
|
||||||
|
await api.positionSheets.generate(e, {
|
||||||
|
position_ids: ['00000000-0000-0000-0000-000000000000'],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
noPositionsRejectedWith = err instanceof ApiError ? err.code : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({ pdfBytes: blob.size, noPositionsRejectedWith }, null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Créneaux (mode mock) : un poste créé ne génère plus aucun créneau ; la
|
||||||
|
* création manuelle à horaires libres fonctionne toujours ; « générer les
|
||||||
|
* créneaux » (créneaux types) reste disponible en option ; un créneau à
|
||||||
|
* horaires non alignés (19:15–22:45) se place bien dans la grille demi-heure
|
||||||
|
* (`buildTimeGrid`/`placeInTimeGrid`), arrondi à la tranche englobante. */
|
||||||
|
async function checkShiftCreation() {
|
||||||
|
const position = await api.positions.create(e, { name: 'Smoke poste', category: 'OTHER' });
|
||||||
|
const shiftsAfterCreate = await api.shifts.list(e, { positionId: position.id });
|
||||||
|
|
||||||
|
const edition = await api.editions.get(e);
|
||||||
|
const shift = await api.shifts.create(e, {
|
||||||
|
position_id: position.id,
|
||||||
|
starts_at: `${edition.start_date}T19:15:00`,
|
||||||
|
ends_at: `${edition.start_date}T22:45:00`,
|
||||||
|
min_staff: 1,
|
||||||
|
ideal_staff: 2,
|
||||||
|
});
|
||||||
|
const generated = await api.positions.generateShifts(e, position.id);
|
||||||
|
|
||||||
|
// Deuxième créneau adjacent au premier mais non aligné sur 30 min (fin à
|
||||||
|
// 22:45, comme le premier) : arrondis à la tranche englobante, les deux
|
||||||
|
// créneaux se chevauchent légèrement dans la grille (voir GrillePage.tsx,
|
||||||
|
// `PositionRow`) — ne doit jamais planter le rendu ni décaler les postes
|
||||||
|
// suivants (régression corrigée : la grille positionnait ses cellules par
|
||||||
|
// ordre d'émission plutôt qu'explicitement, un poste dont les créneaux ne
|
||||||
|
// totalisaient pas exactement le nombre de tranches d'un jour décalait
|
||||||
|
// tous les postes suivants).
|
||||||
|
await api.shifts.create(e, {
|
||||||
|
position_id: position.id,
|
||||||
|
starts_at: `${edition.start_date}T22:45:00`,
|
||||||
|
ends_at: `${edition.start_date}T23:15:00`,
|
||||||
|
min_staff: 1,
|
||||||
|
ideal_staff: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const grid = buildTimeGrid([shift]);
|
||||||
|
const day = grid[0];
|
||||||
|
const placement = day ? placeInTimeGrid(grid, [shift]).get(day.date)?.[0] : undefined;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
shiftsAfterPositionCreate: shiftsAfterCreate.length,
|
||||||
|
manualShiftTimes: `${shift.starts_at}–${shift.ends_at}`,
|
||||||
|
generateShiftsStillOptIn: generated.created,
|
||||||
|
gridSlotCount: day?.slotLabels.length,
|
||||||
|
gridFirstSlot: day?.slotLabels[0],
|
||||||
|
placementSpan: placement?.columnSpan,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shiftsAfterCreate.length !== 0) {
|
||||||
|
throw new Error('création de poste : ne devrait générer aucun créneau automatiquement');
|
||||||
|
}
|
||||||
|
if (generated.created <= 0) {
|
||||||
|
throw new Error('generate-shifts : devrait rester disponible en option');
|
||||||
|
}
|
||||||
|
// 19:15–22:45 (3 h 30) arrondi aux tranches de 30 min englobantes
|
||||||
|
// (19:00–23:00) = 8 tranches.
|
||||||
|
if (day?.slotLabels[0] !== '19:00' || placement?.columnSpan !== 8) {
|
||||||
|
throw new Error('grille demi-heure : placement inattendu pour un créneau non aligné');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grille des besoins : chaque poste affiché doit occuper sa propre ligne de
|
||||||
|
* grille CSS (`gridRow` explicite, voir `GrillePage.tsx:PositionRow`).
|
||||||
|
* Régression testée : avant le passage à un positionnement explicite, un
|
||||||
|
* poste dont les créneaux ne totalisaient pas exactement le nombre de
|
||||||
|
* tranches de 30 min d'un jour (deux créneaux adjacents mais non alignés
|
||||||
|
* sur 30 min, par ex. — cas courant depuis la création de créneaux à la
|
||||||
|
* main) décalait, par un pur effet de bord CSS (auto-flow), tous les postes
|
||||||
|
* suivants — leur nom de poste n'apparaissait alors plus sur sa propre
|
||||||
|
* ligne. Purement visuel : un rendu SSR ordinaire ne l'aurait pas détecté,
|
||||||
|
* d'où cette vérification directe des lignes de grille attribuées.
|
||||||
|
*/
|
||||||
|
async function checkGridLayout() {
|
||||||
|
if (!coordinator) throw new Error('grille : pas de session coordination pour ce test');
|
||||||
|
const positions = await api.positions.list(e);
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
await prefetchCore(client);
|
||||||
|
const html = renderToString(
|
||||||
|
createElement(
|
||||||
|
QueryClientProvider,
|
||||||
|
{ client },
|
||||||
|
createElement(
|
||||||
|
AuthContext.Provider,
|
||||||
|
{ value: authValue(coordinator) },
|
||||||
|
createElement(MemoryRouter, null, createElement(GrillePage)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const rowNumbers = [...html.matchAll(/grid-row:\s*(\d+)/g)].map((m) => Number(m[1]));
|
||||||
|
const uniqueRows = [...new Set(rowNumbers)].sort((a, b) => a - b);
|
||||||
|
// 2 lignes d'en-tête (jours, puis tranches) + une par poste affiché,
|
||||||
|
// strictement consécutives (1, 2, 3, … sans trou ni doublon).
|
||||||
|
const expected = Array.from({ length: positions.length + 2 }, (_, i) => i + 1);
|
||||||
|
const sequential = uniqueRows.length === expected.length
|
||||||
|
&& uniqueRows.every((row, i) => row === expected[i]);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{ positionsCount: positions.length, gridRowsUsed: uniqueRows.length, sequential },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!sequential) {
|
||||||
|
throw new Error(
|
||||||
|
`grille des besoins : lignes de grille ${JSON.stringify(uniqueRows)}, attendu ${JSON.stringify(expected)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function prefetchCore(client: QueryClient) {
|
async function prefetchCore(client: QueryClient) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }),
|
client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }),
|
||||||
@@ -147,15 +393,22 @@ function renderPublic(path: string) {
|
|||||||
|
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
await checkAuth().catch((err) => fail('checkAuth', err));
|
await checkAuth().catch((err) => fail('checkAuth', err));
|
||||||
|
await checkPublicForm().catch((err) => fail('checkPublicForm', err));
|
||||||
|
await checkPositionSheets().catch((err) => fail('checkPositionSheets', err));
|
||||||
|
await checkShiftCreation().catch((err) => fail('checkShiftCreation', err));
|
||||||
|
await checkGridLayout().catch((err) => fail('checkGridLayout', err));
|
||||||
|
|
||||||
renderPublic('/connexion');
|
renderPublic('/connexion');
|
||||||
renderPublic('/connexion/coordination');
|
renderPublic('/connexion/coordination');
|
||||||
|
renderPublic('/inscription'); // formulaire public, hors de <RequireAuth/>
|
||||||
|
|
||||||
if (coordinator) {
|
if (coordinator) {
|
||||||
setAuthToken(coordinatorToken);
|
setAuthToken(coordinatorToken);
|
||||||
await renderProtected('/grille', coordinator, createElement(GrillePage));
|
await renderProtected('/grille', coordinator, createElement(GrillePage));
|
||||||
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
|
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
|
||||||
await renderProtected('/configuration', coordinator, createElement(ConfigPage));
|
await renderProtected('/configuration', coordinator, createElement(ConfigPage));
|
||||||
|
await renderProtected('/badges', coordinator, createElement(BadgesPage));
|
||||||
|
await renderProtected('/inscriptions', coordinator, createElement(FormBuilderPage));
|
||||||
}
|
}
|
||||||
if (volunteerAccount) {
|
if (volunteerAccount) {
|
||||||
const vid = volunteerAccount.volunteer_id as string;
|
const vid = volunteerAccount.volunteer_id as string;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
@@ -15,13 +17,20 @@ import type {
|
|||||||
AssignmentUpdate,
|
AssignmentUpdate,
|
||||||
AssignmentValidateResult,
|
AssignmentValidateResult,
|
||||||
AvailabilitySet,
|
AvailabilitySet,
|
||||||
|
BadgeFieldsSet,
|
||||||
|
BadgeGenerateRequest,
|
||||||
|
BadgeSizeUpdate,
|
||||||
DayPartCreate,
|
DayPartCreate,
|
||||||
DayPartUpdate,
|
DayPartUpdate,
|
||||||
EditionUpdate,
|
EditionUpdate,
|
||||||
|
FormFieldsSet,
|
||||||
|
FormTemplateUpdate,
|
||||||
ImportMapping,
|
ImportMapping,
|
||||||
PlanningNotificationRequest,
|
PlanningNotificationRequest,
|
||||||
PositionCreate,
|
PositionCreate,
|
||||||
|
PositionSheetsRequest,
|
||||||
PositionUpdate,
|
PositionUpdate,
|
||||||
|
PublicFormSubmission,
|
||||||
ShiftCreate,
|
ShiftCreate,
|
||||||
ShiftUpdate,
|
ShiftUpdate,
|
||||||
VolunteerCreate,
|
VolunteerCreate,
|
||||||
@@ -92,6 +101,30 @@ export function useVolunteerPlanning(volunteerId: string | null | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** URL objet (aperçu) de la photo d'un bénévole (formulaire public), ou
|
||||||
|
* `null` si absente. */
|
||||||
|
export function useVolunteerPhotoUrl(volunteerId: string | null | undefined, hasPhoto: boolean) {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let objectUrl: string | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
if (!hasPhoto || !volunteerId) {
|
||||||
|
setUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api.volunteers.photoBlob(edition, volunteerId).then((blob) => {
|
||||||
|
if (cancelled || !blob) return;
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
setUrl(objectUrl);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
}, [volunteerId, hasPhoto]);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
export function useShifts(positionId?: string) {
|
export function useShifts(positionId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.shifts(positionId),
|
queryKey: queryKeys.shifts(positionId),
|
||||||
@@ -131,6 +164,47 @@ export function useMatrix() {
|
|||||||
return useQuery({ queryKey: queryKeys.matrix, queryFn: () => api.matrix.get(edition) });
|
return useQuery({ queryKey: queryKeys.matrix, queryFn: () => api.matrix.get(edition) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Configuration du recto/verso des badges (images, champs). */
|
||||||
|
export function useBadgeTemplate() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['badgeTemplate', edition],
|
||||||
|
queryFn: () => api.badges.getTemplate(edition),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL objet (aperçu) de l'image recto/verso, ou `null` si non définie.
|
||||||
|
* `refreshKey` (ex. `dataUpdatedAt` de `useBadgeTemplate`) force un nouveau
|
||||||
|
* fetch après un remplacement d'image — un simple `hasImage` ne changerait
|
||||||
|
* pas de valeur entre deux images successives.
|
||||||
|
*/
|
||||||
|
export function useBadgeImageUrl(
|
||||||
|
kind: 'front' | 'back',
|
||||||
|
hasImage: boolean,
|
||||||
|
refreshKey: number | string,
|
||||||
|
) {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let objectUrl: string | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
if (!hasImage) {
|
||||||
|
setUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fetchBlob = kind === 'front' ? api.badges.frontImageBlob : api.badges.backImageBlob;
|
||||||
|
fetchBlob(edition).then((blob) => {
|
||||||
|
if (cancelled || !blob) return;
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
setUrl(objectUrl);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
}, [kind, hasImage, refreshKey]);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dernières suggestions du solveur (lecture seule du cache posé par
|
* Dernières suggestions du solveur (lecture seule du cache posé par
|
||||||
* `useRunSolver`). Ne déclenche jamais de calcul elle-même.
|
* `useRunSolver`). Ne déclenche jamais de calcul elle-même.
|
||||||
@@ -453,9 +527,149 @@ export function useRunSolver() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Feuilles de poste PDF (une page par poste et par jour, créneaux +
|
||||||
|
* bénévoles affectés + téléphone) ; ne fait que retourner le fichier (le
|
||||||
|
* composant appelant déclenche le téléchargement). */
|
||||||
|
export function useGeneratePositionSheets() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: PositionSheetsRequest) => api.positionSheets.generate(edition, body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useSendPlanningNotification() {
|
export function useSendPlanningNotification() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: PlanningNotificationRequest) =>
|
mutationFn: (body: PlanningNotificationRequest) =>
|
||||||
api.notifications.sendPlanning(edition, body),
|
api.notifications.sendPlanning(edition, body),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useInvalidateBadgeTemplate() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
return () => client.invalidateQueries({ queryKey: ['badgeTemplate', edition] });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadBadgeFrontImage() {
|
||||||
|
const invalidate = useInvalidateBadgeTemplate();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (file: File) => api.badges.uploadFrontImage(edition, file),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadBadgeBackImage() {
|
||||||
|
const invalidate = useInvalidateBadgeTemplate();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (file: File) => api.badges.uploadBackImage(edition, file),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remplace l'intégralité des champs du recto (positions, activation). */
|
||||||
|
export function useSetBadgeFields() {
|
||||||
|
const invalidate = useInvalidateBadgeTemplate();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: BadgeFieldsSet) => api.badges.setFields(edition, body),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redimensionne le badge indépendamment de l'image (agrandissement
|
||||||
|
* proportionnel d'une image importée trop petite). */
|
||||||
|
export function useSetBadgeSize() {
|
||||||
|
const invalidate = useInvalidateBadgeTemplate();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: BadgeSizeUpdate) => api.badges.setSize(edition, body),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Génère la planche PDF ; ne fait que retourner le fichier (le composant
|
||||||
|
* appelant déclenche le téléchargement). */
|
||||||
|
export function useGenerateBadges() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: BadgeGenerateRequest) => api.badges.generate(edition, body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Formulaire public d'inscription //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
/** Configuration du formulaire (COORDINATION). */
|
||||||
|
export function useFormTemplate() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['formTemplate', edition],
|
||||||
|
queryFn: () => api.forms.getTemplate(edition),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInvalidateFormTemplate() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
return () => client.invalidateQueries({ queryKey: ['formTemplate', edition] });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetFormFields() {
|
||||||
|
const invalidate = useInvalidateFormTemplate();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: FormFieldsSet) => api.forms.setFields(edition, body),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `PATCH /form` : `is_open` et/ou `description`, chacun optionnel
|
||||||
|
* (absent = inchangé — voir `FormTemplateUpdate`). */
|
||||||
|
export function useUpdateFormTemplate() {
|
||||||
|
const invalidate = useInvalidateFormTemplate();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: FormTemplateUpdate) => api.forms.setOpen(edition, body),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadFormLogo() {
|
||||||
|
const invalidate = useInvalidateFormTemplate();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (file: File) => api.forms.uploadLogo(edition, file),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL objet (aperçu) du logo du formulaire public, ou `null` si absent.
|
||||||
|
* Utilisable sans compte connecté (`GET .../form/logo` n'est pas protégé —
|
||||||
|
* affiché sur `/inscription`). `refreshKey` (ex. `dataUpdatedAt` de
|
||||||
|
* `useFormTemplate`) force un nouveau fetch après un changement de logo. */
|
||||||
|
export function useFormLogoUrl(hasLogo: boolean, refreshKey: number | string) {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let objectUrl: string | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
if (!hasLogo) {
|
||||||
|
setUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api.forms.logoBlob(edition).then((blob) => {
|
||||||
|
if (cancelled || !blob) return;
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
setUrl(objectUrl);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
}, [hasLogo, refreshKey]);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Configuration publique du formulaire — utilisable sans compte connecté
|
||||||
|
* (écran `/inscription`, hors de `RequireAuth`). */
|
||||||
|
export function usePublicFormConfig() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['publicFormConfig', edition],
|
||||||
|
queryFn: () => api.forms.getPublicConfig(edition),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubmitPublicForm() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: PublicFormSubmission) => api.forms.submitPublic(edition, body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,15 +7,22 @@ import type {
|
|||||||
AssignmentUpdate,
|
AssignmentUpdate,
|
||||||
AssignmentValidateRequest,
|
AssignmentValidateRequest,
|
||||||
AvailabilitySet,
|
AvailabilitySet,
|
||||||
|
BadgeFieldsSet,
|
||||||
|
BadgeGenerateRequest,
|
||||||
|
BadgeSizeUpdate,
|
||||||
DayPartCreate,
|
DayPartCreate,
|
||||||
DayPartUpdate,
|
DayPartUpdate,
|
||||||
EditionUpdate,
|
EditionUpdate,
|
||||||
|
FormFieldsSet,
|
||||||
|
FormTemplateUpdate,
|
||||||
ImportMapping,
|
ImportMapping,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
MagicLinkRequest,
|
MagicLinkRequest,
|
||||||
PlanningNotificationRequest,
|
PlanningNotificationRequest,
|
||||||
PositionCreate,
|
PositionCreate,
|
||||||
|
PositionSheetsRequest,
|
||||||
PositionUpdate,
|
PositionUpdate,
|
||||||
|
PublicFormSubmission,
|
||||||
ShiftCreate,
|
ShiftCreate,
|
||||||
ShiftUpdate,
|
ShiftUpdate,
|
||||||
VerifyRequest,
|
VerifyRequest,
|
||||||
@@ -66,6 +73,35 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
return body as T;
|
return body as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Réponses binaires (images du badge, PDF généré) : pas de JSON, on ne
|
||||||
|
// veut que les octets (ou `null` pour une image absente, en 404).
|
||||||
|
async function requestBlob(path: string, init?: RequestInit): Promise<Blob | null> {
|
||||||
|
const token = getAuthToken();
|
||||||
|
const isForm = init?.body instanceof FormData;
|
||||||
|
const res = await fetch(`${baseUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...(isForm ? {} : { 'Content-Type': 'application/json' }),
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (!res.ok) {
|
||||||
|
let code = 'error.generic';
|
||||||
|
try {
|
||||||
|
const body = (await res.json()) as { detail?: { code?: string } };
|
||||||
|
code = body.detail?.code ?? code;
|
||||||
|
} catch {
|
||||||
|
// Corps non JSON (ex. erreur réseau) : garde le code générique.
|
||||||
|
}
|
||||||
|
if (res.status === 401) notifyUnauthorized();
|
||||||
|
throw new ApiError(res.status, code, []);
|
||||||
|
}
|
||||||
|
return res.blob();
|
||||||
|
}
|
||||||
|
|
||||||
const query = (params: Record<string, string | undefined>) => {
|
const query = (params: Record<string, string | undefined>) => {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
@@ -112,6 +148,7 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }),
|
request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }),
|
||||||
planning: (editionId, id) =>
|
planning: (editionId, id) =>
|
||||||
request(`${base(editionId)}/volunteers/${id}/planning`),
|
request(`${base(editionId)}/volunteers/${id}/planning`),
|
||||||
|
photoBlob: (editionId, id) => requestBlob(`${base(editionId)}/volunteers/${id}/photo`),
|
||||||
},
|
},
|
||||||
accounts: {
|
accounts: {
|
||||||
list: (editionId) => request(`${base(editionId)}/accounts`),
|
list: (editionId) => request(`${base(editionId)}/accounts`),
|
||||||
@@ -191,6 +228,94 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
matrix: {
|
matrix: {
|
||||||
get: (editionId) => request(`${base(editionId)}/matrix`),
|
get: (editionId) => request(`${base(editionId)}/matrix`),
|
||||||
},
|
},
|
||||||
|
badges: {
|
||||||
|
getTemplate: (editionId) => request(`${base(editionId)}/badges/template`),
|
||||||
|
uploadFrontImage: (editionId, file: File) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
return request(`${base(editionId)}/badges/template/front-image`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
uploadBackImage: (editionId, file: File) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
return request(`${base(editionId)}/badges/template/back-image`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
frontImageBlob: (editionId) => requestBlob(`${base(editionId)}/badges/template/front-image`),
|
||||||
|
backImageBlob: (editionId) => requestBlob(`${base(editionId)}/badges/template/back-image`),
|
||||||
|
setFields: (editionId, body: BadgeFieldsSet) =>
|
||||||
|
request(`${base(editionId)}/badges/template/fields`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
setSize: (editionId, body: BadgeSizeUpdate) =>
|
||||||
|
request(`${base(editionId)}/badges/template/size`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
generate: async (editionId, body: BadgeGenerateRequest) => {
|
||||||
|
const blob = await requestBlob(`${base(editionId)}/badges/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!blob) throw new ApiError(500, 'error.generic', []);
|
||||||
|
return blob;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
forms: {
|
||||||
|
getTemplate: (editionId) => request(`${base(editionId)}/form`),
|
||||||
|
setFields: (editionId, body: FormFieldsSet) =>
|
||||||
|
request(`${base(editionId)}/form/fields`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
setOpen: (editionId, body: FormTemplateUpdate) =>
|
||||||
|
request(`${base(editionId)}/form`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||||
|
uploadLogo: (editionId, file: File) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
return request(`${base(editionId)}/form/logo`, { method: 'PUT', body: form });
|
||||||
|
},
|
||||||
|
getPublicConfig: (editionId) => request(`${base(editionId)}/form/public`),
|
||||||
|
logoBlob: (editionId) => requestBlob(`${base(editionId)}/form/logo`),
|
||||||
|
submitPublic: (editionId, body: PublicFormSubmission) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('first_name', body.first_name);
|
||||||
|
form.append('last_name', body.last_name);
|
||||||
|
form.append('hcaptcha_token', body.hcaptcha_token);
|
||||||
|
if (body.email) form.append('email', body.email);
|
||||||
|
if (body.phone) form.append('phone', body.phone);
|
||||||
|
if (body.comment) form.append('comment', body.comment);
|
||||||
|
if (body.companion_wish_note) form.append('companion_wish_note', body.companion_wish_note);
|
||||||
|
if (body.available !== undefined) form.append('available', String(body.available));
|
||||||
|
if (body.willing_to_pay_fee !== undefined) {
|
||||||
|
form.append('willing_to_pay_fee', String(body.willing_to_pay_fee));
|
||||||
|
}
|
||||||
|
if (body.position_wish_ids) {
|
||||||
|
form.append('position_wish_ids', JSON.stringify(body.position_wish_ids));
|
||||||
|
}
|
||||||
|
if (body.artist_wish_ids) {
|
||||||
|
form.append('artist_wish_ids', JSON.stringify(body.artist_wish_ids));
|
||||||
|
}
|
||||||
|
if (body.photo) form.append('photo', body.photo);
|
||||||
|
return request(`${base(editionId)}/form/public/submit`, { method: 'POST', body: form });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
positionSheets: {
|
||||||
|
generate: async (editionId, body: PositionSheetsRequest) => {
|
||||||
|
const blob = await requestBlob(`${base(editionId)}/position-sheets/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!blob) throw new ApiError(500, 'error.generic', []);
|
||||||
|
return blob;
|
||||||
|
},
|
||||||
|
},
|
||||||
solver: {
|
solver: {
|
||||||
run: (editionId) =>
|
run: (editionId) =>
|
||||||
request(`${base(editionId)}/solver/suggestions`, { method: 'POST' }),
|
request(`${base(editionId)}/solver/suggestions`, { method: 'POST' }),
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import type {
|
|||||||
ArtistRead,
|
ArtistRead,
|
||||||
AssignmentRead,
|
AssignmentRead,
|
||||||
AvailabilityRead,
|
AvailabilityRead,
|
||||||
|
BadgeFieldRead,
|
||||||
DayPartRead,
|
DayPartRead,
|
||||||
EditionRead,
|
EditionRead,
|
||||||
|
FormFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
PositionRead,
|
PositionRead,
|
||||||
ShiftRead,
|
ShiftRead,
|
||||||
@@ -39,6 +41,25 @@ export interface MockState {
|
|||||||
accounts: AccountRead[];
|
accounts: AccountRead[];
|
||||||
/** accountId -> mot de passe en clair (mock uniquement). */
|
/** accountId -> mot de passe en clair (mock uniquement). */
|
||||||
passwords: Map<string, string>;
|
passwords: Map<string, string>;
|
||||||
|
badgeTemplate: {
|
||||||
|
frontImage: string | null; // data URL
|
||||||
|
frontImageType: string | null;
|
||||||
|
frontImageWidthMm: number | null;
|
||||||
|
frontImageHeightMm: number | null;
|
||||||
|
backImage: string | null; // data URL
|
||||||
|
backImageType: string | null;
|
||||||
|
fields: BadgeFieldRead[];
|
||||||
|
};
|
||||||
|
formTemplate: {
|
||||||
|
isOpen: boolean;
|
||||||
|
fields: FormFieldType[];
|
||||||
|
logo: string | null; // data URL
|
||||||
|
logoType: string | null;
|
||||||
|
description: string | null;
|
||||||
|
yaplaUrl: string | null;
|
||||||
|
};
|
||||||
|
/** volunteerId -> photo (formulaire public), data URL. */
|
||||||
|
photos: Map<string, string>;
|
||||||
seq: number;
|
seq: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +269,10 @@ export function buildMockState(): MockState {
|
|||||||
artist_ids,
|
artist_ids,
|
||||||
wished_position_ids,
|
wished_position_ids,
|
||||||
wished_companion_ids,
|
wished_companion_ids,
|
||||||
|
has_photo: false,
|
||||||
|
companion_wish_note: null,
|
||||||
|
self_declared_available: false,
|
||||||
|
willing_to_pay_fee: false,
|
||||||
created_at: NOW,
|
created_at: NOW,
|
||||||
updated_at: NOW,
|
updated_at: NOW,
|
||||||
});
|
});
|
||||||
@@ -412,6 +437,19 @@ export function buildMockState(): MockState {
|
|||||||
accounts,
|
accounts,
|
||||||
passwords,
|
passwords,
|
||||||
availability,
|
availability,
|
||||||
|
badgeTemplate: {
|
||||||
|
frontImage: null,
|
||||||
|
frontImageType: null,
|
||||||
|
frontImageWidthMm: null,
|
||||||
|
frontImageHeightMm: null,
|
||||||
|
backImage: null,
|
||||||
|
backImageType: null,
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
|
formTemplate: {
|
||||||
|
isOpen: true, fields: [], logo: null, logoType: null, description: null, yaplaUrl: null,
|
||||||
|
},
|
||||||
|
photos: new Map(),
|
||||||
seq,
|
seq,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,19 @@ import type {
|
|||||||
AssignmentValidateResult,
|
AssignmentValidateResult,
|
||||||
AvailabilityRead,
|
AvailabilityRead,
|
||||||
AvailabilitySet,
|
AvailabilitySet,
|
||||||
|
BadgeFieldRead,
|
||||||
|
BadgeFieldsSet,
|
||||||
|
BadgeGenerateRequest,
|
||||||
|
BadgeSizeUpdate,
|
||||||
|
BadgeTemplateRead,
|
||||||
DayPartCreate,
|
DayPartCreate,
|
||||||
DayPartRead,
|
DayPartRead,
|
||||||
DayPartUpdate,
|
DayPartUpdate,
|
||||||
EditionUpdate,
|
EditionUpdate,
|
||||||
|
FormFieldsSet,
|
||||||
|
FormSubmissionResult,
|
||||||
|
FormTemplateRead,
|
||||||
|
FormTemplateUpdate,
|
||||||
ImportAnalyzeResponse,
|
ImportAnalyzeResponse,
|
||||||
ImportMapping,
|
ImportMapping,
|
||||||
ImportPreviewRow,
|
ImportPreviewRow,
|
||||||
@@ -21,6 +30,9 @@ import type {
|
|||||||
ImportRowReport,
|
ImportRowReport,
|
||||||
MatrixResponse,
|
MatrixResponse,
|
||||||
PlanningNotificationRequest,
|
PlanningNotificationRequest,
|
||||||
|
PositionSheetsRequest,
|
||||||
|
PublicFormConfig,
|
||||||
|
PublicFormSubmission,
|
||||||
ShiftRead,
|
ShiftRead,
|
||||||
SolverRunResult,
|
SolverRunResult,
|
||||||
SolverSuggestion,
|
SolverSuggestion,
|
||||||
@@ -82,6 +94,57 @@ const notFound = (): never => {
|
|||||||
throw new ApiError(404, 'error.not_found');
|
throw new ApiError(404, 'error.not_found');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Format CR80 (85,6 × 54 mm) par défaut tant qu'aucune image de recto n'est
|
||||||
|
// importée — sinon le format du badge est dérivé de ses pixels (voir
|
||||||
|
// `imageSizeMm`), comme côté back-end (`app/badges/pdf.py:image_size_mm`).
|
||||||
|
const DEFAULT_BADGE_WIDTH_MM = 85.6;
|
||||||
|
const DEFAULT_BADGE_HEIGHT_MM = 54;
|
||||||
|
const BADGE_ASSUMED_DPI = 300;
|
||||||
|
|
||||||
|
const imageSizeMm = async (file: File): Promise<{ width_mm: number; height_mm: number }> => {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
const width_mm = (bitmap.width / BADGE_ASSUMED_DPI) * 25.4;
|
||||||
|
const height_mm = (bitmap.height / BADGE_ASSUMED_DPI) * 25.4;
|
||||||
|
bitmap.close();
|
||||||
|
return { width_mm, height_mm };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Approche de `str.title()` (Python) utilisé côté back-end à la soumission
|
||||||
|
// du formulaire public — capitalise chaque mot (« - » compris).
|
||||||
|
const titleCase = (s: string): string =>
|
||||||
|
s.toLowerCase().replace(/\b\p{L}/gu, (c) => c.toUpperCase());
|
||||||
|
|
||||||
|
const fileToDataUrl = (file: File): Promise<string> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
const formTemplateRead = (): FormTemplateRead => ({
|
||||||
|
edition_id: state.edition.id,
|
||||||
|
is_open: state.formTemplate.isOpen,
|
||||||
|
fields: state.formTemplate.fields,
|
||||||
|
has_logo: state.formTemplate.logo !== null,
|
||||||
|
description: state.formTemplate.description,
|
||||||
|
yapla_url: state.formTemplate.yaplaUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// PDF valide minimal (une page blanche) — remplace la vraie génération
|
||||||
|
// (réservée au back-end réel) pour que le téléchargement fonctionne en mode
|
||||||
|
// démonstration.
|
||||||
|
const MOCK_PDF_BLOB = () =>
|
||||||
|
new Blob(
|
||||||
|
[
|
||||||
|
'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n' +
|
||||||
|
'2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n' +
|
||||||
|
'3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]>>endobj\n' +
|
||||||
|
'trailer<</Root 1 0 R>>\n%%EOF',
|
||||||
|
],
|
||||||
|
{ type: 'application/pdf' },
|
||||||
|
);
|
||||||
|
|
||||||
const touch = () => new Date().toISOString().replace(/\.\d+Z$/, '');
|
const touch = () => new Date().toISOString().replace(/\.\d+Z$/, '');
|
||||||
|
|
||||||
const addDays = (day: string, n: number): string => {
|
const addDays = (day: string, n: number): string => {
|
||||||
@@ -789,6 +852,10 @@ export function createMockApi(): PlanningApi {
|
|||||||
'position.unknown',
|
'position.unknown',
|
||||||
),
|
),
|
||||||
wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []),
|
wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []),
|
||||||
|
has_photo: false,
|
||||||
|
companion_wish_note: null,
|
||||||
|
self_declared_available: false,
|
||||||
|
willing_to_pay_fee: false,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -819,6 +886,15 @@ export function createMockApi(): PlanningApi {
|
|||||||
...(body.wished_companion_ids != null
|
...(body.wished_companion_ids != null
|
||||||
? { wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids, v.id) }
|
? { wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids, v.id) }
|
||||||
: {}),
|
: {}),
|
||||||
|
...('companion_wish_note' in body
|
||||||
|
? { companion_wish_note: body.companion_wish_note ?? null }
|
||||||
|
: {}),
|
||||||
|
...('self_declared_available' in body && body.self_declared_available != null
|
||||||
|
? { self_declared_available: body.self_declared_available }
|
||||||
|
: {}),
|
||||||
|
...('willing_to_pay_fee' in body && body.willing_to_pay_fee != null
|
||||||
|
? { willing_to_pay_fee: body.willing_to_pay_fee }
|
||||||
|
: {}),
|
||||||
updated_at: touch(),
|
updated_at: touch(),
|
||||||
});
|
});
|
||||||
return delay(v);
|
return delay(v);
|
||||||
@@ -827,6 +903,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
state.volunteers = state.volunteers.filter((x) => x.id !== id);
|
state.volunteers = state.volunteers.filter((x) => x.id !== id);
|
||||||
state.assignments = state.assignments.filter((a) => a.volunteer_id !== id);
|
state.assignments = state.assignments.filter((a) => a.volunteer_id !== id);
|
||||||
state.availability.delete(id);
|
state.availability.delete(id);
|
||||||
|
state.photos.delete(id);
|
||||||
// RGPD : effacement complet, y compris le compte de connexion éventuel
|
// RGPD : effacement complet, y compris le compte de connexion éventuel
|
||||||
// et les vœux de compagnon dont il était la cible.
|
// et les vœux de compagnon dont il était la cible.
|
||||||
state.accounts = state.accounts.filter((a) => a.volunteer_id !== id);
|
state.accounts = state.accounts.filter((a) => a.volunteer_id !== id);
|
||||||
@@ -835,6 +912,11 @@ export function createMockApi(): PlanningApi {
|
|||||||
});
|
});
|
||||||
return delay(undefined);
|
return delay(undefined);
|
||||||
},
|
},
|
||||||
|
photoBlob: async (_edition, id) => {
|
||||||
|
const dataUrl = state.photos.get(id);
|
||||||
|
if (!dataUrl) return null;
|
||||||
|
return (await fetch(dataUrl)).blob();
|
||||||
|
},
|
||||||
planning: (_edition, id) => {
|
planning: (_edition, id) => {
|
||||||
if (!state.volunteers.some((v) => v.id === id)) return notFound();
|
if (!state.volunteers.some((v) => v.id === id)) return notFound();
|
||||||
const entries = state.assignments
|
const entries = state.assignments
|
||||||
@@ -1105,6 +1187,10 @@ export function createMockApi(): PlanningApi {
|
|||||||
artist_ids: [],
|
artist_ids: [],
|
||||||
wished_position_ids: [],
|
wished_position_ids: [],
|
||||||
wished_companion_ids: [],
|
wished_companion_ids: [],
|
||||||
|
has_photo: false,
|
||||||
|
companion_wish_note: null,
|
||||||
|
self_declared_available: false,
|
||||||
|
willing_to_pay_fee: false,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -1152,7 +1238,8 @@ export function createMockApi(): PlanningApi {
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
state.positions.push(position);
|
state.positions.push(position);
|
||||||
generateMissingShifts(position.id);
|
// Plus de génération automatique : la coordination ajoute les
|
||||||
|
// créneaux à la main, ou via `generateShifts` (opt-in) ci-dessous.
|
||||||
return delay(position);
|
return delay(position);
|
||||||
},
|
},
|
||||||
update: (_edition, id, body) => {
|
update: (_edition, id, body) => {
|
||||||
@@ -1301,6 +1388,205 @@ export function createMockApi(): PlanningApi {
|
|||||||
get: () => delay(buildMatrix()),
|
get: () => delay(buildMatrix()),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
badges: {
|
||||||
|
getTemplate: (): Promise<BadgeTemplateRead> =>
|
||||||
|
delay({
|
||||||
|
edition_id: state.edition.id,
|
||||||
|
has_front_image: state.badgeTemplate.frontImage !== null,
|
||||||
|
has_back_image: state.badgeTemplate.backImage !== null,
|
||||||
|
badge_width_mm: state.badgeTemplate.frontImageWidthMm ?? DEFAULT_BADGE_WIDTH_MM,
|
||||||
|
badge_height_mm: state.badgeTemplate.frontImageHeightMm ?? DEFAULT_BADGE_HEIGHT_MM,
|
||||||
|
fields: state.badgeTemplate.fields,
|
||||||
|
}),
|
||||||
|
uploadFrontImage: async (_edition, file: File) => {
|
||||||
|
const { width_mm, height_mm } = await imageSizeMm(file);
|
||||||
|
state.badgeTemplate.frontImage = await fileToDataUrl(file);
|
||||||
|
state.badgeTemplate.frontImageType = file.type;
|
||||||
|
state.badgeTemplate.frontImageWidthMm = width_mm;
|
||||||
|
state.badgeTemplate.frontImageHeightMm = height_mm;
|
||||||
|
return delay<BadgeTemplateRead>({
|
||||||
|
edition_id: state.edition.id,
|
||||||
|
has_front_image: true,
|
||||||
|
has_back_image: state.badgeTemplate.backImage !== null,
|
||||||
|
badge_width_mm: width_mm,
|
||||||
|
badge_height_mm: height_mm,
|
||||||
|
fields: state.badgeTemplate.fields,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
uploadBackImage: async (_edition, file: File) => {
|
||||||
|
state.badgeTemplate.backImage = await fileToDataUrl(file);
|
||||||
|
state.badgeTemplate.backImageType = file.type;
|
||||||
|
return delay<BadgeTemplateRead>({
|
||||||
|
edition_id: state.edition.id,
|
||||||
|
has_front_image: state.badgeTemplate.frontImage !== null,
|
||||||
|
has_back_image: true,
|
||||||
|
badge_width_mm: state.badgeTemplate.frontImageWidthMm ?? DEFAULT_BADGE_WIDTH_MM,
|
||||||
|
badge_height_mm: state.badgeTemplate.frontImageHeightMm ?? DEFAULT_BADGE_HEIGHT_MM,
|
||||||
|
fields: state.badgeTemplate.fields,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
frontImageBlob: async () => {
|
||||||
|
if (!state.badgeTemplate.frontImage) return null;
|
||||||
|
return (await fetch(state.badgeTemplate.frontImage)).blob();
|
||||||
|
},
|
||||||
|
backImageBlob: async () => {
|
||||||
|
if (!state.badgeTemplate.backImage) return null;
|
||||||
|
return (await fetch(state.badgeTemplate.backImage)).blob();
|
||||||
|
},
|
||||||
|
setFields: (_edition, body: BadgeFieldsSet) => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const f of body.fields) {
|
||||||
|
if (seen.has(f.field_type)) throw new ApiError(422, 'badge.duplicate_field_type');
|
||||||
|
seen.add(f.field_type);
|
||||||
|
}
|
||||||
|
const fields: BadgeFieldRead[] = body.fields.map((f) => ({
|
||||||
|
...f,
|
||||||
|
id: `bf${state.seq++}`,
|
||||||
|
edition_id: state.edition.id,
|
||||||
|
}));
|
||||||
|
state.badgeTemplate.fields = fields;
|
||||||
|
return delay(fields);
|
||||||
|
},
|
||||||
|
setSize: (_edition, body: BadgeSizeUpdate) => {
|
||||||
|
if (body.badge_width_mm <= 0 || body.badge_height_mm <= 0) {
|
||||||
|
throw new ApiError(422, 'badge.invalid_size');
|
||||||
|
}
|
||||||
|
state.badgeTemplate.frontImageWidthMm = body.badge_width_mm;
|
||||||
|
state.badgeTemplate.frontImageHeightMm = body.badge_height_mm;
|
||||||
|
return delay<BadgeTemplateRead>({
|
||||||
|
edition_id: state.edition.id,
|
||||||
|
has_front_image: state.badgeTemplate.frontImage !== null,
|
||||||
|
has_back_image: state.badgeTemplate.backImage !== null,
|
||||||
|
badge_width_mm: body.badge_width_mm,
|
||||||
|
badge_height_mm: body.badge_height_mm,
|
||||||
|
fields: state.badgeTemplate.fields,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
generate: (_edition, body: BadgeGenerateRequest) => {
|
||||||
|
if (state.badgeTemplate.fields.length === 0) {
|
||||||
|
throw new ApiError(422, 'badge.no_fields');
|
||||||
|
}
|
||||||
|
const ids = body.volunteer_ids ?? state.volunteers.map((v) => v.id);
|
||||||
|
const matching = state.volunteers.filter((v) => ids.includes(v.id));
|
||||||
|
if (matching.length === 0) throw new ApiError(422, 'badge.no_volunteers');
|
||||||
|
// La composition réelle du PDF (reportlab) est réservée au back-end :
|
||||||
|
// le mode démonstration renvoie un PDF minimal valide, téléchargeable.
|
||||||
|
return delay(MOCK_PDF_BLOB());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
forms: {
|
||||||
|
getTemplate: (): Promise<FormTemplateRead> => delay(formTemplateRead()),
|
||||||
|
setFields: (_edition, body: FormFieldsSet) => {
|
||||||
|
state.formTemplate.fields = [...new Set(body.fields)];
|
||||||
|
return delay<FormTemplateRead>(formTemplateRead());
|
||||||
|
},
|
||||||
|
setOpen: (_edition, body: FormTemplateUpdate) => {
|
||||||
|
if (body.is_open !== undefined) state.formTemplate.isOpen = body.is_open;
|
||||||
|
if (body.description !== undefined) state.formTemplate.description = body.description;
|
||||||
|
if (body.yapla_url !== undefined) {
|
||||||
|
if (body.yapla_url && !/^https?:\/\//.test(body.yapla_url)) {
|
||||||
|
throw new ApiError(422, 'form.invalid_yapla_url');
|
||||||
|
}
|
||||||
|
state.formTemplate.yaplaUrl = body.yapla_url;
|
||||||
|
}
|
||||||
|
return delay<FormTemplateRead>(formTemplateRead());
|
||||||
|
},
|
||||||
|
uploadLogo: async (_edition, file: File) => {
|
||||||
|
state.formTemplate.logo = await fileToDataUrl(file);
|
||||||
|
state.formTemplate.logoType = file.type;
|
||||||
|
return delay<FormTemplateRead>(formTemplateRead());
|
||||||
|
},
|
||||||
|
logoBlob: async () => {
|
||||||
|
if (!state.formTemplate.logo) return null;
|
||||||
|
return (await fetch(state.formTemplate.logo)).blob();
|
||||||
|
},
|
||||||
|
getPublicConfig: (): Promise<PublicFormConfig> => {
|
||||||
|
const fields = state.formTemplate.fields;
|
||||||
|
return delay({
|
||||||
|
is_open: state.formTemplate.isOpen,
|
||||||
|
fields,
|
||||||
|
positions: fields.includes('POSITION_WISHES')
|
||||||
|
? state.positions.map((p) => ({ id: p.id, name: p.name }))
|
||||||
|
: [],
|
||||||
|
artists: fields.includes('ARTIST_WISHES')
|
||||||
|
? state.artists.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
performance_date: a.performance_date,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
has_logo: state.formTemplate.logo !== null,
|
||||||
|
description: state.formTemplate.description,
|
||||||
|
yapla_url: state.formTemplate.yaplaUrl,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
submitPublic: async (_edition, body: PublicFormSubmission) => {
|
||||||
|
if (!state.formTemplate.isOpen) throw new ApiError(422, 'form.closed');
|
||||||
|
if (!body.hcaptcha_token) throw new ApiError(422, 'form.captcha_failed');
|
||||||
|
if (!body.first_name.trim() || !body.last_name.trim()) {
|
||||||
|
throw new ApiError(422, 'import.row.missing_required_field');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = new Set(state.formTemplate.fields);
|
||||||
|
const positionIds = fields.has('POSITION_WISHES') ? (body.position_wish_ids ?? []) : [];
|
||||||
|
if (positionIds.length > 2) throw new ApiError(422, 'form.too_many_position_wishes');
|
||||||
|
const artistIds = fields.has('ARTIST_WISHES') ? (body.artist_wish_ids ?? []) : [];
|
||||||
|
const artistDates = artistIds.map(
|
||||||
|
(id) => state.artists.find((a) => a.id === id)?.performance_date,
|
||||||
|
);
|
||||||
|
if (new Set(artistDates).size !== artistDates.length) {
|
||||||
|
throw new ApiError(422, 'form.too_many_artists_same_day');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = touch();
|
||||||
|
const volunteer: VolunteerRead = {
|
||||||
|
id: `v${state.seq++}`,
|
||||||
|
edition_id: state.edition.id,
|
||||||
|
first_name: titleCase(body.first_name.trim()),
|
||||||
|
last_name: titleCase(body.last_name.trim()),
|
||||||
|
email: fields.has('EMAIL') ? body.email?.trim() || null : null,
|
||||||
|
phone: fields.has('PHONE') ? body.phone?.trim() || null : null,
|
||||||
|
comment: fields.has('COMMENT') ? body.comment?.trim() || null : null,
|
||||||
|
artist_ids: resolveWishes(state.artists, artistIds, 'artist.unknown'),
|
||||||
|
wished_position_ids: resolveWishes(state.positions, positionIds, 'position.unknown'),
|
||||||
|
wished_companion_ids: [],
|
||||||
|
has_photo: false,
|
||||||
|
companion_wish_note: fields.has('COMPANION_WISH')
|
||||||
|
? body.companion_wish_note?.trim() || null
|
||||||
|
: null,
|
||||||
|
self_declared_available: fields.has('AVAILABLE') ? Boolean(body.available) : false,
|
||||||
|
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE')
|
||||||
|
? Boolean(body.willing_to_pay_fee)
|
||||||
|
: false,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fields.has('PHOTO') && body.photo) {
|
||||||
|
volunteer.has_photo = true;
|
||||||
|
state.photos.set(volunteer.id, await fileToDataUrl(body.photo));
|
||||||
|
}
|
||||||
|
|
||||||
|
state.volunteers.push(volunteer);
|
||||||
|
state.availability.set(volunteer.id, []);
|
||||||
|
return delay<FormSubmissionResult>({ volunteer_id: volunteer.id });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
positionSheets: {
|
||||||
|
generate: (_edition, body: PositionSheetsRequest) => {
|
||||||
|
const ids = body.position_ids ?? state.positions.map((p) => p.id);
|
||||||
|
const matching = state.positions.filter((p) => ids.includes(p.id));
|
||||||
|
if (matching.length === 0) throw new ApiError(422, 'position_sheets.no_positions');
|
||||||
|
const hasShifts = state.shifts.some((s) => matching.some((p) => p.id === s.position_id));
|
||||||
|
if (!hasShifts) throw new ApiError(422, 'position_sheets.no_shifts');
|
||||||
|
// La composition réelle du PDF (reportlab) est réservée au back-end :
|
||||||
|
// le mode démonstration renvoie un PDF minimal valide, téléchargeable.
|
||||||
|
return delay(MOCK_PDF_BLOB());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
solver: {
|
solver: {
|
||||||
run: () => delay(runSolver()),
|
run: () => delay(runSolver()),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ export interface VolunteerRead {
|
|||||||
wished_position_ids: string[];
|
wished_position_ids: string[];
|
||||||
/** Vœux : bénévoles avec qui il voudrait être affecté (0 à N, à sens unique). */
|
/** Vœux : bénévoles avec qui il voudrait être affecté (0 à N, à sens unique). */
|
||||||
wished_companion_ids: string[];
|
wished_companion_ids: string[];
|
||||||
|
/** Renseignés par le formulaire public d'inscription (voir schema forms). */
|
||||||
|
has_photo: boolean;
|
||||||
|
companion_wish_note: string | null;
|
||||||
|
self_declared_available: boolean;
|
||||||
|
willing_to_pay_fee: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -133,6 +138,9 @@ export interface VolunteerUpdate {
|
|||||||
artist_ids?: string[] | null;
|
artist_ids?: string[] | null;
|
||||||
wished_position_ids?: string[] | null;
|
wished_position_ids?: string[] | null;
|
||||||
wished_companion_ids?: string[] | null;
|
wished_companion_ids?: string[] | null;
|
||||||
|
companion_wish_note?: string | null;
|
||||||
|
self_declared_available?: boolean | null;
|
||||||
|
willing_to_pay_fee?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -416,6 +424,62 @@ export interface MatrixResponse {
|
|||||||
positions: MatrixPosition[];
|
positions: MatrixPosition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Badges (planches PDF) //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
export type BadgeFieldType = 'FULL_NAME' | 'POSITIONS' | 'CATEGORY' | 'ROLE';
|
||||||
|
export type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT';
|
||||||
|
|
||||||
|
export interface BadgeFieldIn {
|
||||||
|
field_type: BadgeFieldType;
|
||||||
|
x_mm: number;
|
||||||
|
y_mm: number;
|
||||||
|
width_mm: number;
|
||||||
|
height_mm: number;
|
||||||
|
font_size: number;
|
||||||
|
align: BadgeAlign;
|
||||||
|
bold: boolean;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BadgeFieldsSet {
|
||||||
|
fields: BadgeFieldIn[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BadgeFieldRead extends BadgeFieldIn {
|
||||||
|
id: string;
|
||||||
|
edition_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BadgeTemplateRead {
|
||||||
|
edition_id: string;
|
||||||
|
has_front_image: boolean;
|
||||||
|
has_back_image: boolean;
|
||||||
|
/** Dérivée de l'image du recto (300 dpi supposés) ; format CR80 par défaut
|
||||||
|
* tant qu'aucune image n'est importée. */
|
||||||
|
badge_width_mm: number;
|
||||||
|
badge_height_mm: number;
|
||||||
|
fields: BadgeFieldRead[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BadgeGenerateRequest {
|
||||||
|
volunteer_ids?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redimensionne le badge indépendamment d'un nouvel import d'image — ex.
|
||||||
|
* agrandir proportionnellement une image importée à basse résolution. */
|
||||||
|
export interface BadgeSizeUpdate {
|
||||||
|
badge_width_mm: number;
|
||||||
|
badge_height_mm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feuilles de poste (PDF) : une page par poste et par jour, créneaux +
|
||||||
|
* bénévoles affectés + téléphone. */
|
||||||
|
export interface PositionSheetsRequest {
|
||||||
|
// null/absent = tous les postes de l'édition.
|
||||||
|
position_ids?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
// Authentification //
|
// Authentification //
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
@@ -483,3 +547,82 @@ export interface PlanningNotificationResult {
|
|||||||
skipped_no_email: number;
|
skipped_no_email: number;
|
||||||
recipients: string[];
|
recipients: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Formulaire public d'inscription //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Nom/prénom toujours collectés, pas dans cette liste (voir app/api/forms.py).
|
||||||
|
export type FormFieldType =
|
||||||
|
| 'EMAIL'
|
||||||
|
| 'PHONE'
|
||||||
|
| 'POSITION_WISHES'
|
||||||
|
| 'ARTIST_WISHES'
|
||||||
|
| 'COMPANION_WISH'
|
||||||
|
| 'AVAILABLE'
|
||||||
|
| 'WILLING_TO_PAY_FEE'
|
||||||
|
| 'PHOTO'
|
||||||
|
| 'COMMENT';
|
||||||
|
|
||||||
|
export interface FormFieldsSet {
|
||||||
|
fields: FormFieldType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormTemplateRead {
|
||||||
|
edition_id: string;
|
||||||
|
is_open: boolean;
|
||||||
|
fields: FormFieldType[];
|
||||||
|
has_logo: boolean;
|
||||||
|
description: string | null;
|
||||||
|
yapla_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `undefined`/absent = inchangé pour chaque champ. */
|
||||||
|
export interface FormTemplateUpdate {
|
||||||
|
is_open?: boolean;
|
||||||
|
description?: string | null;
|
||||||
|
yapla_url?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicPositionOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicArtistOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
performance_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tout ce qu'il faut pour afficher le formulaire public — jamais plus. */
|
||||||
|
export interface PublicFormConfig {
|
||||||
|
is_open: boolean;
|
||||||
|
fields: FormFieldType[];
|
||||||
|
positions: PublicPositionOption[];
|
||||||
|
artists: PublicArtistOption[];
|
||||||
|
has_logo: boolean;
|
||||||
|
description: string | null;
|
||||||
|
yapla_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormSubmissionResult {
|
||||||
|
volunteer_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Champs du formulaire public — envoyés en `multipart/form-data` (photo
|
||||||
|
* possible). `position_wish_ids`/`artist_wish_ids` : JSON stringifié
|
||||||
|
* (`'["id1","id2"]'`), pas de tableau natif possible en multipart. */
|
||||||
|
export interface PublicFormSubmission {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
hcaptcha_token: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
comment?: string;
|
||||||
|
companion_wish_note?: string;
|
||||||
|
available?: boolean;
|
||||||
|
willing_to_pay_fee?: boolean;
|
||||||
|
position_wish_ids?: string[];
|
||||||
|
artist_wish_ids?: string[];
|
||||||
|
photo?: File | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,11 +13,20 @@ import type {
|
|||||||
AssignmentWriteResult,
|
AssignmentWriteResult,
|
||||||
AvailabilityRead,
|
AvailabilityRead,
|
||||||
AvailabilitySet,
|
AvailabilitySet,
|
||||||
|
BadgeFieldRead,
|
||||||
|
BadgeFieldsSet,
|
||||||
|
BadgeGenerateRequest,
|
||||||
|
BadgeSizeUpdate,
|
||||||
|
BadgeTemplateRead,
|
||||||
DayPartCreate,
|
DayPartCreate,
|
||||||
DayPartRead,
|
DayPartRead,
|
||||||
DayPartUpdate,
|
DayPartUpdate,
|
||||||
EditionRead,
|
EditionRead,
|
||||||
EditionUpdate,
|
EditionUpdate,
|
||||||
|
FormFieldsSet,
|
||||||
|
FormSubmissionResult,
|
||||||
|
FormTemplateRead,
|
||||||
|
FormTemplateUpdate,
|
||||||
GenerateShiftsResult,
|
GenerateShiftsResult,
|
||||||
ImportAnalyzeResponse,
|
ImportAnalyzeResponse,
|
||||||
ImportMapping,
|
ImportMapping,
|
||||||
@@ -29,7 +38,10 @@ import type {
|
|||||||
PlanningNotificationResult,
|
PlanningNotificationResult,
|
||||||
PositionCreate,
|
PositionCreate,
|
||||||
PositionRead,
|
PositionRead,
|
||||||
|
PositionSheetsRequest,
|
||||||
PositionUpdate,
|
PositionUpdate,
|
||||||
|
PublicFormConfig,
|
||||||
|
PublicFormSubmission,
|
||||||
ShiftCreate,
|
ShiftCreate,
|
||||||
ShiftRead,
|
ShiftRead,
|
||||||
ShiftUpdate,
|
ShiftUpdate,
|
||||||
@@ -72,6 +84,8 @@ export interface PlanningApi {
|
|||||||
remove(editionId: string, id: string): Promise<void>;
|
remove(editionId: string, id: string): Promise<void>;
|
||||||
/** Planning enrichi (poste, lieu, référent) — écran bénévole. */
|
/** Planning enrichi (poste, lieu, référent) — écran bénévole. */
|
||||||
planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>;
|
planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>;
|
||||||
|
/** Photo transmise via le formulaire public, `null` si absente. */
|
||||||
|
photoBlob(editionId: string, id: string): Promise<Blob | null>;
|
||||||
};
|
};
|
||||||
accounts: {
|
accounts: {
|
||||||
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
|
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
|
||||||
@@ -132,6 +146,38 @@ export interface PlanningApi {
|
|||||||
matrix: {
|
matrix: {
|
||||||
get(editionId: string): Promise<MatrixResponse>;
|
get(editionId: string): Promise<MatrixResponse>;
|
||||||
};
|
};
|
||||||
|
badges: {
|
||||||
|
/** Configuration du recto/verso (COORDINATION uniquement). */
|
||||||
|
getTemplate(editionId: string): Promise<BadgeTemplateRead>;
|
||||||
|
uploadFrontImage(editionId: string, file: File): Promise<BadgeTemplateRead>;
|
||||||
|
uploadBackImage(editionId: string, file: File): Promise<BadgeTemplateRead>;
|
||||||
|
/** Image brute (aperçu dans l'éditeur), `null` si non définie. */
|
||||||
|
frontImageBlob(editionId: string): Promise<Blob | null>;
|
||||||
|
backImageBlob(editionId: string): Promise<Blob | null>;
|
||||||
|
/** Remplace l'intégralité des champs du recto. */
|
||||||
|
setFields(editionId: string, body: BadgeFieldsSet): Promise<BadgeFieldRead[]>;
|
||||||
|
/** Redimensionne le badge indépendamment de l'image (agrandissement
|
||||||
|
* proportionnel d'une image importée trop petite). */
|
||||||
|
setSize(editionId: string, body: BadgeSizeUpdate): Promise<BadgeTemplateRead>;
|
||||||
|
/** Génère la planche PDF (recto personnalisé + verso fixe). */
|
||||||
|
generate(editionId: string, body: BadgeGenerateRequest): Promise<Blob>;
|
||||||
|
};
|
||||||
|
forms: {
|
||||||
|
/** Configuration (COORDINATION uniquement). */
|
||||||
|
getTemplate(editionId: string): Promise<FormTemplateRead>;
|
||||||
|
setFields(editionId: string, body: FormFieldsSet): Promise<FormTemplateRead>;
|
||||||
|
setOpen(editionId: string, body: FormTemplateUpdate): Promise<FormTemplateRead>;
|
||||||
|
uploadLogo(editionId: string, file: File): Promise<FormTemplateRead>;
|
||||||
|
/** Publics — aucune authentification, utilisables sans compte connecté
|
||||||
|
* (y compris `logoBlob`, affiché sur `/inscription`). */
|
||||||
|
getPublicConfig(editionId: string): Promise<PublicFormConfig>;
|
||||||
|
submitPublic(editionId: string, body: PublicFormSubmission): Promise<FormSubmissionResult>;
|
||||||
|
logoBlob(editionId: string): Promise<Blob | null>;
|
||||||
|
};
|
||||||
|
positionSheets: {
|
||||||
|
/** Feuilles de poste PDF (une page par poste et par jour). */
|
||||||
|
generate(editionId: string, body: PositionSheetsRequest): Promise<Blob>;
|
||||||
|
};
|
||||||
solver: {
|
solver: {
|
||||||
/** Calcule des suggestions (CP-SAT) ; ne persiste jamais d'affectation. */
|
/** Calcule des suggestions (CP-SAT) ; ne persiste jamais d'affectation. */
|
||||||
run(editionId: string): Promise<SolverRunResult>;
|
run(editionId: string): Promise<SolverRunResult>;
|
||||||
|
|||||||
@@ -4,15 +4,22 @@ import { HomeRedirect, RequireAuth } from '@/auth/RequireAuth';
|
|||||||
import { AppShell } from './AppShell';
|
import { AppShell } from './AppShell';
|
||||||
import { AdminLoginPage } from '@/pages/auth/AdminLoginPage';
|
import { AdminLoginPage } from '@/pages/auth/AdminLoginPage';
|
||||||
import { ConnexionPage } from '@/pages/auth/ConnexionPage';
|
import { ConnexionPage } from '@/pages/auth/ConnexionPage';
|
||||||
|
import { BadgesPage } from '@/pages/BadgesPage';
|
||||||
import { ConfigPage } from '@/pages/ConfigPage';
|
import { ConfigPage } from '@/pages/ConfigPage';
|
||||||
|
import { FormBuilderPage } from '@/pages/FormBuilderPage';
|
||||||
import { GrillePage } from '@/pages/GrillePage';
|
import { GrillePage } from '@/pages/GrillePage';
|
||||||
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
||||||
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
|
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
|
||||||
|
import { PublicSignupPage } from '@/pages/PublicSignupPage';
|
||||||
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
|
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
|
||||||
|
|
||||||
export const routes: RouteObject[] = [
|
export const routes: RouteObject[] = [
|
||||||
{ path: '/connexion', element: <ConnexionPage /> },
|
{ path: '/connexion', element: <ConnexionPage /> },
|
||||||
{ path: '/connexion/coordination', element: <AdminLoginPage /> },
|
{ path: '/connexion/coordination', element: <AdminLoginPage /> },
|
||||||
|
// Formulaire public d'inscription : aucun compte requis, donc hors de
|
||||||
|
// <RequireAuth/> — voir app/api/forms.py (les seuls endpoints publics de
|
||||||
|
// toute l'API).
|
||||||
|
{ path: '/inscription', element: <PublicSignupPage /> },
|
||||||
{
|
{
|
||||||
element: <RequireAuth />,
|
element: <RequireAuth />,
|
||||||
children: [
|
children: [
|
||||||
@@ -26,6 +33,8 @@ export const routes: RouteObject[] = [
|
|||||||
{ path: 'mobile-benevole', element: <MobileVolunteerPage /> },
|
{ path: 'mobile-benevole', element: <MobileVolunteerPage /> },
|
||||||
{ path: 'mobile-referent', element: <MobileLeadPage /> },
|
{ path: 'mobile-referent', element: <MobileLeadPage /> },
|
||||||
{ path: 'configuration', element: <ConfigPage /> },
|
{ path: 'configuration', element: <ConfigPage /> },
|
||||||
|
{ path: 'badges', element: <BadgesPage /> },
|
||||||
|
{ path: 'inscriptions', element: <FormBuilderPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export const ROUTE_ROLES: Record<string, Role[]> = {
|
|||||||
'/grille': ['COORDINATOR'],
|
'/grille': ['COORDINATOR'],
|
||||||
'/benevoles': ['COORDINATOR'],
|
'/benevoles': ['COORDINATOR'],
|
||||||
'/configuration': ['COORDINATOR'],
|
'/configuration': ['COORDINATOR'],
|
||||||
|
'/badges': ['COORDINATOR'],
|
||||||
|
'/inscriptions': ['COORDINATOR'],
|
||||||
'/mobile-benevole': ['VOLUNTEER'],
|
'/mobile-benevole': ['VOLUNTEER'],
|
||||||
'/mobile-referent': ['LEAD'],
|
'/mobile-referent': ['LEAD'],
|
||||||
};
|
};
|
||||||
@@ -19,6 +21,8 @@ export const NAV_TABS: Array<{ to: string; num: string; labelKey: string }> = [
|
|||||||
{ to: '/mobile-benevole', num: '04', labelKey: 'tab.mobileVolunteer' },
|
{ to: '/mobile-benevole', num: '04', labelKey: 'tab.mobileVolunteer' },
|
||||||
{ to: '/mobile-referent', num: '05', labelKey: 'tab.mobileLead' },
|
{ to: '/mobile-referent', num: '05', labelKey: 'tab.mobileLead' },
|
||||||
{ to: '/configuration', num: '06', labelKey: 'tab.config' },
|
{ to: '/configuration', num: '06', labelKey: 'tab.config' },
|
||||||
|
{ to: '/badges', num: '07', labelKey: 'tab.badges' },
|
||||||
|
{ to: '/inscriptions', num: '08', labelKey: 'tab.forms' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function canAccess(pathname: string, role: Role): boolean {
|
export function canAccess(pathname: string, role: Role): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useVolunteerPhotoUrl } from '@/api/hooks';
|
||||||
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Photo du bénévole (formulaire public, voir `app/api/forms.py`) si
|
||||||
|
* disponible, sinon repli sur un carré d'initiales — même gabarit dans les
|
||||||
|
* deux cas pour ne jamais faire sauter la mise en page. `className` fixe la
|
||||||
|
* taille (ex. `h-[38px] w-[38px] text-[13px]`).
|
||||||
|
*/
|
||||||
|
export function VolunteerAvatar({
|
||||||
|
volunteerId,
|
||||||
|
hasPhoto,
|
||||||
|
initials,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
volunteerId: string;
|
||||||
|
hasPhoto: boolean;
|
||||||
|
initials: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const photoUrl = useVolunteerPhotoUrl(volunteerId, hasPhoto);
|
||||||
|
|
||||||
|
if (photoUrl) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={photoUrl}
|
||||||
|
alt=""
|
||||||
|
className={cx('flex-none border-[3px] border-ink object-cover', className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
'flex flex-none items-center justify-center border-[3px] border-ink bg-yellow font-display font-extrabold text-ink',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
"tab.mobileVolunteer": "Bénévole · mobile",
|
"tab.mobileVolunteer": "Bénévole · mobile",
|
||||||
"tab.mobileLead": "Référent · mobile",
|
"tab.mobileLead": "Référent · mobile",
|
||||||
"tab.config": "Configuration",
|
"tab.config": "Configuration",
|
||||||
|
"tab.badges": "Badges",
|
||||||
"position.category.bar": "Bar",
|
"position.category.bar": "Bar",
|
||||||
"position.category.ticketing": "Billetterie",
|
"position.category.ticketing": "Billetterie",
|
||||||
"position.category.welcome": "Accueil",
|
"position.category.welcome": "Accueil",
|
||||||
@@ -156,7 +157,10 @@
|
|||||||
"config.grid.deletePositionConfirm": "Supprimer ce poste et tous ses créneaux ?",
|
"config.grid.deletePositionConfirm": "Supprimer ce poste et tous ses créneaux ?",
|
||||||
"config.grid.generateShifts": "GÉNÉRER LES CRÉNEAUX",
|
"config.grid.generateShifts": "GÉNÉRER LES CRÉNEAUX",
|
||||||
"config.grid.generated": "{count} créneau(x) ajouté(s).",
|
"config.grid.generated": "{count} créneau(x) ajouté(s).",
|
||||||
"config.grid.note": "Effectifs, créneaux, nom et catégorie du poste sont enregistrés immédiatement via l'API. Pour ajouter ou retirer des jours, modifiez les dates de l'édition dans l'onglet « Règles & édition ».",
|
"config.grid.shiftStart": "Début du créneau",
|
||||||
|
"config.grid.shiftEnd": "Fin du créneau",
|
||||||
|
"config.grid.addShift": "+ AJOUTER CE CRÉNEAU",
|
||||||
|
"config.grid.note": "Un poste ne crée aucun créneau à sa création : ajoutez-les à la main (heure de début et de fin) sur chaque jour, ou utilisez « Générer les créneaux » pour repartir des créneaux types de l'édition. Effectifs, créneaux, nom et catégorie du poste sont enregistrés immédiatement via l'API. Pour ajouter ou retirer des jours, modifiez les dates de l'édition dans l'onglet « Règles & édition ».",
|
||||||
"config.vol.importTitle": "IMPORTER DES BÉNÉVOLES",
|
"config.vol.importTitle": "IMPORTER DES BÉNÉVOLES",
|
||||||
"config.vol.importHint": "Trois façons d'ajouter des bénévoles : saisie manuelle, import d'un fichier CSV / Excel, ou (à venir) le formulaire d'inscription en ligne.",
|
"config.vol.importHint": "Trois façons d'ajouter des bénévoles : saisie manuelle, import d'un fichier CSV / Excel, ou (à venir) le formulaire d'inscription en ligne.",
|
||||||
"config.vol.profiles": "PROFILS ENREGISTRÉS",
|
"config.vol.profiles": "PROFILS ENREGISTRÉS",
|
||||||
@@ -168,9 +172,9 @@
|
|||||||
"config.vol.lastName": "NOM",
|
"config.vol.lastName": "NOM",
|
||||||
"config.vol.phone": "TÉLÉPHONE",
|
"config.vol.phone": "TÉLÉPHONE",
|
||||||
"config.vol.email": "EMAIL (LIEN MAGIQUE)",
|
"config.vol.email": "EMAIL (LIEN MAGIQUE)",
|
||||||
"config.vol.delete": "SUPPRIMER (RGPD — EFFACEMENT COMPLET)",
|
"config.vol.delete": "SUPPRIMER",
|
||||||
"config.vol.deleteConfirm": "Supprimer définitivement ce bénévole et toutes ses données (disponibilités, affectations) ?",
|
"config.vol.deleteConfirm": "Supprimer définitivement ce bénévole et toutes ses données (disponibilités, affectations) ?",
|
||||||
"config.vol.note": "Modifications enregistrées immédiatement via l'API. La suppression est un effacement complet en cascade (RGPD).",
|
"config.vol.note": "Modifications enregistrées immédiatement via l'API. La suppression est un effacement complet en cascade.",
|
||||||
"config.mail.smtpBanner": "Envoie un récapitulatif de planning à chaque bénévole. L'email part si le serveur SMTP est configuré (EMAIL_ENABLED) ; sinon il est seulement journalisé.",
|
"config.mail.smtpBanner": "Envoie un récapitulatif de planning à chaque bénévole. L'email part si le serveur SMTP est configuré (EMAIL_ENABLED) ; sinon il est seulement journalisé.",
|
||||||
"config.mail.templateTitle": "MODÈLE DE MESSAGE",
|
"config.mail.templateTitle": "MODÈLE DE MESSAGE",
|
||||||
"config.mail.senderNote": "Expéditeur défini côté serveur (SMTP_FROM).",
|
"config.mail.senderNote": "Expéditeur défini côté serveur (SMTP_FROM).",
|
||||||
@@ -299,6 +303,8 @@
|
|||||||
"config.vol.noCompanions": "Aucun bénévole choisi",
|
"config.vol.noCompanions": "Aucun bénévole choisi",
|
||||||
"config.vol.pickCompanion": "Ajouter un bénévole…",
|
"config.vol.pickCompanion": "Ajouter un bénévole…",
|
||||||
"config.vol.searchCompanion": "Rechercher un bénévole…",
|
"config.vol.searchCompanion": "Rechercher un bénévole…",
|
||||||
|
"config.vol.publicForm.title": "Formulaire public",
|
||||||
|
"config.vol.publicForm.hint": "Réponses saisies par le bénévole lui-même via le formulaire public d'inscription.",
|
||||||
"config.vol.colWishes": "VŒUX",
|
"config.vol.colWishes": "VŒUX",
|
||||||
"config.vol.wishesCount": "{count} vœu(x)",
|
"config.vol.wishesCount": "{count} vœu(x)",
|
||||||
"config.vol.addAvailabilityHint": "Les disponibilités se renseignent sur la fiche du bénévole, une fois celui-ci créé.",
|
"config.vol.addAvailabilityHint": "Les disponibilités se renseignent sur la fiche du bénévole, une fois celui-ci créé.",
|
||||||
@@ -315,10 +321,116 @@
|
|||||||
"grille.solver.run": "SUGGESTIONS (SOLVEUR)",
|
"grille.solver.run": "SUGGESTIONS (SOLVEUR)",
|
||||||
"grille.solver.running": "CALCUL EN COURS…",
|
"grille.solver.running": "CALCUL EN COURS…",
|
||||||
"grille.solver.result": "{count} suggestion(s)",
|
"grille.solver.result": "{count} suggestion(s)",
|
||||||
|
"grille.sheets.generate": "FEUILLES DE POSTE",
|
||||||
|
"grille.sheets.generating": "GÉNÉRATION…",
|
||||||
"grille.panel.suggested": "Suggéré",
|
"grille.panel.suggested": "Suggéré",
|
||||||
"config.artists.startTime": "Heure de début du passage",
|
"config.artists.startTime": "Heure de début du passage",
|
||||||
"config.artists.endTime": "Heure de fin du passage",
|
"config.artists.endTime": "Heure de fin du passage",
|
||||||
"grille.panel.wishesPosition": "Vœu de poste",
|
"grille.panel.wishesPosition": "Vœu de poste",
|
||||||
"grille.panel.artistConflict": "Manquerait un artiste souhaité",
|
"grille.panel.artistConflict": "Manquerait un artiste souhaité",
|
||||||
"grille.panel.withCompanion": "Avec {count} compagnon(s) souhaité(s)"
|
"grille.panel.withCompanion": "Avec {count} compagnon(s) souhaité(s)",
|
||||||
|
|
||||||
|
"badges.admin": "PLANCHES DE BADGES",
|
||||||
|
"badges.tab.customize": "PERSONNALISER",
|
||||||
|
"badges.tab.generate": "GÉNÉRER",
|
||||||
|
"badges.front": "Recto",
|
||||||
|
"badges.front.help": "Fond personnalisable + champs positionnables (nom, postes avec horaires, catégorie, rôle). Le format du badge est celui de cette image (300 dpi supposés). Propre à cette édition.",
|
||||||
|
"badges.back": "Verso",
|
||||||
|
"badges.back.help": "Une seule image, la même pour tous les badges de cette édition — ajustée au format défini par le recto.",
|
||||||
|
"badges.fields": "Champs du recto",
|
||||||
|
"badges.field.fullName": "Prénom + nom",
|
||||||
|
"badges.field.fullName.placeholder": "Prénom Nom",
|
||||||
|
"badges.field.positions": "Poste(s) affecté(s)",
|
||||||
|
"badges.field.positions.placeholder": "Bar central 19:00–22:00\nAccueil 09:00–13:00 (Référent : Sam Nuit)",
|
||||||
|
"badges.field.category": "Catégorie",
|
||||||
|
"badges.field.category.placeholder": "Bar",
|
||||||
|
"badges.field.role": "Rôle (référent / bénévole)",
|
||||||
|
"badges.field.role.placeholder": "Référent",
|
||||||
|
"badges.field.size": "Taille",
|
||||||
|
"badges.editor.hint": "Badge {width} × {height} mm. Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.",
|
||||||
|
"badges.size.width": "Largeur du badge (mm)",
|
||||||
|
"badges.size.height": "× {height} mm (hauteur proportionnelle)",
|
||||||
|
"badges.image.choose": "Choisir une image",
|
||||||
|
"badges.image.replace": "Remplacer l'image",
|
||||||
|
"badges.uploading": "Envoi…",
|
||||||
|
"badges.save": "Enregistrer",
|
||||||
|
"badges.saving": "Enregistrement…",
|
||||||
|
"badges.saved": "Enregistré ✓",
|
||||||
|
"badges.generate.title": "Génération de la planche PDF",
|
||||||
|
"badges.generate.all": "Tous les bénévoles ({count})",
|
||||||
|
"badges.generate.pick": "Choisir ({count} sélectionné(s))",
|
||||||
|
"badges.generate.action": "Générer le PDF",
|
||||||
|
"badges.generating": "Génération…",
|
||||||
|
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
|
||||||
|
"badge.empty_image": "L'image est vide.",
|
||||||
|
"badge.image_too_large": "L'image est trop volumineuse.",
|
||||||
|
"badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois.",
|
||||||
|
"badge.invalid_size": "La taille du badge doit être strictement positive.",
|
||||||
|
"badge.no_fields": "Activez au moins un champ sur le recto avant de générer.",
|
||||||
|
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
|
||||||
|
|
||||||
|
"tab.forms": "Inscriptions",
|
||||||
|
"form.field.email": "Email",
|
||||||
|
"form.field.phone": "Téléphone",
|
||||||
|
"form.field.positionWishes": "Vœux de poste",
|
||||||
|
"form.field.positionWishes.help": "Deux vœux au maximum, pour forcer une vraie priorisation.",
|
||||||
|
"form.field.artistWishes": "Concert(s) à voir",
|
||||||
|
"form.field.artistWishes.help": "Un seul artiste souhaité par soir de festival.",
|
||||||
|
"form.field.companionWish": "Collègue apprécié",
|
||||||
|
"form.field.companionWish.help": "Texte libre : la personne visée n'est pas forcément déjà inscrite. À résoudre ensuite depuis sa fiche.",
|
||||||
|
"form.field.available": "Case « je suis disponible »",
|
||||||
|
"form.field.available.help": "Purement informatif — n'a aucun lien avec les disponibilités détaillées de la fiche bénévole.",
|
||||||
|
"form.field.willingToPayFee": "Case « je veux bien payer une cotisation »",
|
||||||
|
"form.field.photo": "Une magnifique photo de toi :",
|
||||||
|
"form.field.comment": "Commentaire",
|
||||||
|
"form.link.title": "Lien du formulaire",
|
||||||
|
"form.link.hint": "À partager aux bénévoles potentiels — n'importe qui muni du lien peut s'inscrire.",
|
||||||
|
"form.link.copy": "Copier",
|
||||||
|
"form.link.copied": "Copié ✓",
|
||||||
|
"form.status.title": "Inscriptions",
|
||||||
|
"form.status.open": "OUVERTES",
|
||||||
|
"form.status.closed": "FERMÉES",
|
||||||
|
"form.fields.title": "Champs du formulaire",
|
||||||
|
"form.save": "Enregistrer",
|
||||||
|
"form.saving": "Enregistrement…",
|
||||||
|
"form.saved": "Enregistré ✓",
|
||||||
|
"form.customize.title": "Personnalisation",
|
||||||
|
"form.customize.logo": "Logo",
|
||||||
|
"form.customize.logo.choose": "Choisir une image",
|
||||||
|
"form.customize.logo.replace": "Remplacer",
|
||||||
|
"form.customize.logo.uploading": "Envoi…",
|
||||||
|
"form.customize.description": "Texte d'introduction",
|
||||||
|
"form.customize.description.placeholder": "Un mot de bienvenue affiché en haut du formulaire (facultatif)…",
|
||||||
|
"form.customize.yaplaUrl": "Lien de cotisation Yapla",
|
||||||
|
"form.customize.yaplaUrl.help": "Facultatif : lien vers votre page de cotisation sur Yapla. Un encart « Payer ma cotisation » apparaît alors sur le formulaire public, près de la case correspondante.",
|
||||||
|
"form.customize.yaplaUrl.placeholder": "https://www.yapla.com/fr/...",
|
||||||
|
|
||||||
|
"publicForm.title": "Inscription bénévole",
|
||||||
|
"publicForm.positionWishes.count": "{count} / {max}",
|
||||||
|
"publicForm.artistWishes.none": "— Aucun —",
|
||||||
|
"publicForm.field.available": "Je suis bénévole les deux jours (vendredi 30 et samedi 31 oct.)",
|
||||||
|
"publicForm.field.willingToPayFee": "Je participe à la cotisation bénévole de 20€",
|
||||||
|
"publicForm.photo.squareHint": "Recadrée automatiquement en carré.",
|
||||||
|
"publicForm.yapla.hint": "Le paiement se fait en ligne, via notre page Yapla.",
|
||||||
|
"publicForm.yapla.cta": "Payer ma cotisation ↗",
|
||||||
|
"publicForm.captcha.disabled": "(Vérification anti-robot non configurée sur cette instance.)",
|
||||||
|
"publicForm.submitting": "Envoi…",
|
||||||
|
"publicForm.submit": "Envoyer mon inscription",
|
||||||
|
"publicForm.success.title": "Inscription reçue",
|
||||||
|
"publicForm.success.body": "Merci ! Ton inscription a bien été enregistrée. La coordination du festival prendra contact avec toi.",
|
||||||
|
"publicForm.closed.title": "Inscriptions fermées",
|
||||||
|
"publicForm.closed.body": "Les inscriptions bénévoles ne sont pas ouvertes pour le moment. Reviens un peu plus tard !",
|
||||||
|
"form.closed": "Les inscriptions sont actuellement fermées.",
|
||||||
|
"form.captcha_failed": "Vérification anti-robot invalide, réessaie.",
|
||||||
|
"form.invalid_payload": "Données du formulaire invalides.",
|
||||||
|
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
|
||||||
|
"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_yapla_url": "Le lien Yapla doit commencer par http:// ou https://.",
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,7 @@ export const ENV = {
|
|||||||
apiBaseUrl: raw.VITE_API_BASE_URL ?? '/api',
|
apiBaseUrl: raw.VITE_API_BASE_URL ?? '/api',
|
||||||
editionId: raw.VITE_EDITION_ID,
|
editionId: raw.VITE_EDITION_ID,
|
||||||
demoVolunteerId: raw.VITE_DEMO_VOLUNTEER_ID ?? '',
|
demoVolunteerId: raw.VITE_DEMO_VOLUNTEER_ID ?? '',
|
||||||
|
// Clé de site hCaptcha (publique, sans risque à exposer) — le formulaire
|
||||||
|
// public d'inscription (`/inscription`) ne s'affiche pas sans elle.
|
||||||
|
hcaptchaSiteKey: raw.VITE_HCAPTCHA_SITE_KEY ?? '',
|
||||||
};
|
};
|
||||||
|
|||||||
+172
-19
@@ -2,6 +2,7 @@ import type {
|
|||||||
ArtistRead,
|
ArtistRead,
|
||||||
AssignmentRead,
|
AssignmentRead,
|
||||||
AvailabilityRead,
|
AvailabilityRead,
|
||||||
|
DayPartRead,
|
||||||
MatrixResponse,
|
MatrixResponse,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
ShiftRead,
|
ShiftRead,
|
||||||
@@ -10,7 +11,7 @@ import type {
|
|||||||
} from '@/api/schema';
|
} from '@/api/schema';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
import { datePart, hoursBetween, naiveMinutes, timePart } from './time';
|
import { addDays, datePart, hoursBetween, minutesOfDay, naiveMinutes, timePart } from './time';
|
||||||
|
|
||||||
export function categoryLabel(category: PositionCategory): string {
|
export function categoryLabel(category: PositionCategory): string {
|
||||||
return t(`position.category.${category.toLowerCase()}`);
|
return t(`position.category.${category.toLowerCase()}`);
|
||||||
@@ -188,48 +189,200 @@ export interface LatticeDay {
|
|||||||
columns: LatticeColumn[];
|
columns: LatticeColumn[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Construit le treillis de colonnes à partir de créneaux (grille alignée). */
|
/** Frontière du jour (« journée » au sens § 3 du CLAUDE.md) : début du
|
||||||
export function buildLattice(starts: Array<{ starts_at: string; ends_at: string }>): {
|
* créneau type de plus petit `order` — même règle que
|
||||||
|
* `validation.engine._day_boundary` côté backend. Sans créneau type, minuit. */
|
||||||
|
export function dayBoundaryFromDayParts(
|
||||||
|
dayParts: Array<Pick<DayPartRead, 'start_time' | 'order'>>,
|
||||||
|
): string {
|
||||||
|
if (dayParts.length === 0) return '00:00';
|
||||||
|
const first = dayParts.reduce((min, part) => (part.order < min.order ? part : min));
|
||||||
|
return first.start_time.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Construit le treillis de colonnes à partir de créneaux (grille alignée).
|
||||||
|
* `dayBoundary` (`HH:MM`, défaut minuit) regroupe et trie les colonnes par
|
||||||
|
* « journée de festival » plutôt que par date calendaire brute : un créneau
|
||||||
|
* commençant avant cette heure (ex. 02:00, suite d'une soirée qui a passé
|
||||||
|
* minuit) est rattaché au jour précédent et trié après 23:00, pas avant
|
||||||
|
* 19:00 — voir CLAUDE.md § 3 (« journée » pour le cumul des 8 h). */
|
||||||
|
export function buildLattice(
|
||||||
|
starts: Array<{ starts_at: string; ends_at: string }>,
|
||||||
|
dayBoundary = '00:00',
|
||||||
|
): {
|
||||||
days: LatticeDay[];
|
days: LatticeDay[];
|
||||||
columns: LatticeColumn[];
|
columns: LatticeColumn[];
|
||||||
} {
|
} {
|
||||||
const seen = new Map<string, LatticeColumn>();
|
const boundaryMinutes = minutesOfDay(dayBoundary);
|
||||||
|
const seen = new Map<string, LatticeColumn & { festivalDay: string; sortMinutes: number }>();
|
||||||
for (const s of starts) {
|
for (const s of starts) {
|
||||||
const date = datePart(s.starts_at);
|
const date = datePart(s.starts_at);
|
||||||
const time = timePart(s.starts_at);
|
const time = timePart(s.starts_at);
|
||||||
const endTime = timePart(s.ends_at);
|
const endTime = timePart(s.ends_at);
|
||||||
const key = `${date}|${time}|${endTime}`;
|
const key = `${date}|${time}|${endTime}`;
|
||||||
if (!seen.has(key)) seen.set(key, { key, date, time, endTime, label: `${time}–${endTime}` });
|
if (!seen.has(key)) {
|
||||||
|
const minutes = minutesOfDay(time);
|
||||||
|
const festivalDay = minutes < boundaryMinutes ? addDays(date, -1) : date;
|
||||||
|
const sortMinutes = (minutes - boundaryMinutes + 24 * 60) % (24 * 60);
|
||||||
|
seen.set(key, { key, date, time, endTime, label: `${time}–${endTime}`, festivalDay, sortMinutes });
|
||||||
}
|
}
|
||||||
const columns = [...seen.values()].sort((a, b) => a.key.localeCompare(b.key));
|
}
|
||||||
|
const ordered = [...seen.values()].sort(
|
||||||
|
(a, b) => a.festivalDay.localeCompare(b.festivalDay) || a.sortMinutes - b.sortMinutes,
|
||||||
|
);
|
||||||
|
const columns: LatticeColumn[] = ordered.map(({ key, date, time, endTime, label }) => ({
|
||||||
|
key,
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
endTime,
|
||||||
|
label,
|
||||||
|
}));
|
||||||
const days: LatticeDay[] = [];
|
const days: LatticeDay[] = [];
|
||||||
for (const column of columns) {
|
for (const column of ordered) {
|
||||||
let day = days.find((d) => d.date === column.date);
|
let day = days.find((d) => d.date === column.festivalDay);
|
||||||
if (!day) {
|
if (!day) {
|
||||||
day = { date: column.date, columns: [] };
|
day = { date: column.festivalDay, columns: [] };
|
||||||
days.push(day);
|
days.push(day);
|
||||||
}
|
}
|
||||||
day.columns.push(column);
|
day.columns.push({
|
||||||
|
key: column.key,
|
||||||
|
date: column.date,
|
||||||
|
time: column.time,
|
||||||
|
endTime: column.endTime,
|
||||||
|
label: column.label,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return { days, columns };
|
return { days, columns };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function latticeFromMatrix(matrix: MatrixResponse) {
|
export function latticeFromMatrix(matrix: MatrixResponse, dayBoundary?: string) {
|
||||||
return buildLattice(
|
return buildLattice(
|
||||||
matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at, ends_at: s.ends_at }))),
|
matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at, ends_at: s.ends_at }))),
|
||||||
|
dayBoundary,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Largeur (px) des colonnes d'une journée, en fonction du nombre de créneaux
|
* Largeur (px) des tranches de 30 min d'une journée, en fonction de leur
|
||||||
* distincts qu'elle compte : peu de créneaux ⇒ colonnes larges plutôt que
|
* nombre : une journée courte (peu de tranches) reste lisible en large,
|
||||||
* resserrées ; beaucoup de créneaux ⇒ largeur compacte (défilement horizontal).
|
* une journée dense (beaucoup de tranches) se resserre jusqu'à un plancher
|
||||||
|
* (défilement horizontal) plutôt que d'écraser le reste de la grille.
|
||||||
*/
|
*/
|
||||||
export function columnWidthPx(columnsInDay: number): number {
|
export function columnWidthPx(slotsInDay: number): number {
|
||||||
if (columnsInDay <= 2) return 160;
|
if (slotsInDay <= 4) return 110;
|
||||||
if (columnsInDay <= 4) return 120;
|
if (slotsInDay <= 8) return 80;
|
||||||
if (columnsInDay <= 6) return 92;
|
if (slotsInDay <= 16) return 56;
|
||||||
return 66;
|
return 42;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Grille des besoins : une colonne = 30 min (§ 4 CLAUDE.md). Les créneaux, //
|
||||||
|
// eux, gardent des horaires libres (voir `ShiftCreate`) — ils sont replacés //
|
||||||
|
// dans cette grille fixe en fusionnant les cases qu'ils couvrent. //
|
||||||
|
// (Distinct de `buildLattice` ci-dessus, gardé pour la « Vue bénévoles », //
|
||||||
|
// une colonne par créneau distinct plutôt qu'un axe temporel continu.) //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
const TIME_GRID_SLOT_MINUTES = 30;
|
||||||
|
|
||||||
|
export interface TimeGridDay {
|
||||||
|
date: string; // jour de festival
|
||||||
|
/** Étiquette d'horloge (`HH:MM`) du début de chaque tranche de 30 min. */
|
||||||
|
slotLabels: string[];
|
||||||
|
/** Minutes depuis `dayBoundary` du début de la 1re tranche (multiple de 30). */
|
||||||
|
startOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeGridPlacement<T> {
|
||||||
|
item: T;
|
||||||
|
columnStart: number; // index de tranche dans le jour (0-based)
|
||||||
|
columnSpan: number; // nombre de tranches fusionnées (>= 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clockLabel = (totalMinutes: number): string => {
|
||||||
|
const wrapped = ((totalMinutes % (24 * 60)) + 24 * 60) % (24 * 60);
|
||||||
|
const h = Math.floor(wrapped / 60);
|
||||||
|
const m = wrapped % 60;
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Jour de festival (§ 3 CLAUDE.md) d'un datetime naïf, selon `dayBoundary`. */
|
||||||
|
function festivalDayOf(iso: string, boundaryMinutes: number): string {
|
||||||
|
const date = datePart(iso);
|
||||||
|
const clock = minutesOfDay(timePart(iso));
|
||||||
|
return clock < boundaryMinutes ? addDays(date, -1) : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit, pour chaque jour de festival couvert par `shifts`, l'axe des
|
||||||
|
* tranches de 30 min à afficher — bornée à la plage réellement utilisée ce
|
||||||
|
* jour-là (du début du créneau le plus matinal à la fin du plus tardif,
|
||||||
|
* arrondi au multiple de 30 inférieur/supérieur), pas les 24 h complètes.
|
||||||
|
*/
|
||||||
|
export function buildTimeGrid(
|
||||||
|
shifts: Array<{ starts_at: string; ends_at: string }>,
|
||||||
|
dayBoundary = '00:00',
|
||||||
|
): TimeGridDay[] {
|
||||||
|
const boundaryMinutes = minutesOfDay(dayBoundary);
|
||||||
|
const bounds = new Map<string, { min: number; max: number }>();
|
||||||
|
for (const s of shifts) {
|
||||||
|
const festivalDay = festivalDayOf(s.starts_at, boundaryMinutes);
|
||||||
|
const startClock = minutesOfDay(timePart(s.starts_at));
|
||||||
|
const startOffset = (startClock - boundaryMinutes + 24 * 60) % (24 * 60);
|
||||||
|
const duration = naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at);
|
||||||
|
const endOffset = startOffset + duration;
|
||||||
|
const entry = bounds.get(festivalDay);
|
||||||
|
if (entry) {
|
||||||
|
entry.min = Math.min(entry.min, startOffset);
|
||||||
|
entry.max = Math.max(entry.max, endOffset);
|
||||||
|
} else {
|
||||||
|
bounds.set(festivalDay, { min: startOffset, max: endOffset });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...bounds.entries()]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([date, { min, max }]) => {
|
||||||
|
const startOffset = Math.floor(min / TIME_GRID_SLOT_MINUTES) * TIME_GRID_SLOT_MINUTES;
|
||||||
|
const endOffset = Math.ceil(max / TIME_GRID_SLOT_MINUTES) * TIME_GRID_SLOT_MINUTES;
|
||||||
|
const slotCount = Math.max(1, (endOffset - startOffset) / TIME_GRID_SLOT_MINUTES);
|
||||||
|
const slotLabels = Array.from({ length: slotCount }, (_, i) =>
|
||||||
|
clockLabel(boundaryMinutes + startOffset + i * TIME_GRID_SLOT_MINUTES),
|
||||||
|
);
|
||||||
|
return { date, slotLabels, startOffset };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace des éléments datés (`starts_at`/`ends_at`) dans la grille de
|
||||||
|
* `days` (voir `buildTimeGrid`) : tranche de départ + nombre de tranches
|
||||||
|
* fusionnées, groupés et triés par jour. Suppose une seule ligne par
|
||||||
|
* appelant (ex. un poste) : deux éléments qui se chevaucheraient dans le
|
||||||
|
* temps ne sont pas gérés (cas non prévu par la grille des besoins).
|
||||||
|
*/
|
||||||
|
export function placeInTimeGrid<T extends { starts_at: string; ends_at: string }>(
|
||||||
|
days: TimeGridDay[],
|
||||||
|
items: T[],
|
||||||
|
dayBoundary = '00:00',
|
||||||
|
): Map<string, TimeGridPlacement<T>[]> {
|
||||||
|
const boundaryMinutes = minutesOfDay(dayBoundary);
|
||||||
|
const byDay = new Map(days.map((d) => [d.date, d]));
|
||||||
|
const placements = new Map<string, TimeGridPlacement<T>[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const festivalDay = festivalDayOf(item.starts_at, boundaryMinutes);
|
||||||
|
const day = byDay.get(festivalDay);
|
||||||
|
if (!day) continue;
|
||||||
|
const startClock = minutesOfDay(timePart(item.starts_at));
|
||||||
|
const startOffset = (startClock - boundaryMinutes + 24 * 60) % (24 * 60);
|
||||||
|
const duration = naiveMinutes(item.ends_at) - naiveMinutes(item.starts_at);
|
||||||
|
const columnStart = Math.floor((startOffset - day.startOffset) / TIME_GRID_SLOT_MINUTES);
|
||||||
|
const columnEnd = Math.ceil(
|
||||||
|
(startOffset + duration - day.startOffset) / TIME_GRID_SLOT_MINUTES,
|
||||||
|
);
|
||||||
|
const list = placements.get(festivalDay) ?? [];
|
||||||
|
list.push({ item, columnStart, columnSpan: Math.max(1, columnEnd - columnStart) });
|
||||||
|
placements.set(festivalDay, list);
|
||||||
|
}
|
||||||
|
for (const list of placements.values()) list.sort((a, b) => a.columnStart - b.columnStart);
|
||||||
|
return placements;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Heures cumulées par bénévole. */
|
/** Heures cumulées par bénévole. */
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ export function timePart(iso: string): string {
|
|||||||
return iso.slice(11, 16);
|
return iso.slice(11, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Minutes depuis minuit d'une heure `HH:MM` (ou `HH:MM:SS`). */
|
||||||
|
export function minutesOfDay(hhmm: string): number {
|
||||||
|
const [h, m] = hhmm.split(':').map(Number);
|
||||||
|
return h * 60 + m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Décale une date `YYYY-MM-DD` de `delta` jours (peut être négatif). */
|
||||||
|
export function addDays(datePartStr: string, delta: number): string {
|
||||||
|
const [y, m, d] = datePartStr.split('-').map(Number);
|
||||||
|
return new Date(Date.UTC(y, m - 1, d + delta)).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
const WEEKDAYS = ['dim.', 'lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.'];
|
const WEEKDAYS = ['dim.', 'lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.'];
|
||||||
const WEEKDAYS_LONG = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'];
|
const WEEKDAYS_LONG = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,624 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
useBadgeImageUrl,
|
||||||
|
useBadgeTemplate,
|
||||||
|
useGenerateBadges,
|
||||||
|
useSetBadgeFields,
|
||||||
|
useSetBadgeSize,
|
||||||
|
useUploadBadgeBackImage,
|
||||||
|
useUploadBadgeFrontImage,
|
||||||
|
useVolunteers,
|
||||||
|
} from '@/api/hooks';
|
||||||
|
import type { BadgeAlign, BadgeFieldIn, BadgeFieldType } from '@/api/schema';
|
||||||
|
import { ApiError } from '@/api';
|
||||||
|
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
||||||
|
import { Btn, Chip, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
|
// Le format du badge est dérivé de l'image de fond du recto (voir
|
||||||
|
// `backend/app/badges/pdf.py:image_size_mm`) — CR80 (85,6 × 54 mm) tant
|
||||||
|
// qu'aucune image n'est importée. `SCALE` fixe la taille d'édition à
|
||||||
|
// l'écran ; les coordonnées enregistrées restent en mm.
|
||||||
|
const DEFAULT_BADGE_W_MM = 85.6;
|
||||||
|
const DEFAULT_BADGE_H_MM = 54;
|
||||||
|
const SCALE = 4.6; // px par mm à l'écran
|
||||||
|
|
||||||
|
type FieldState = BadgeFieldIn & { enabled: boolean };
|
||||||
|
|
||||||
|
const FIELD_TYPES: BadgeFieldType[] = ['FULL_NAME', 'POSITIONS', 'CATEGORY', 'ROLE'];
|
||||||
|
|
||||||
|
const FIELD_LABEL_KEY: Record<BadgeFieldType, string> = {
|
||||||
|
FULL_NAME: 'badges.field.fullName',
|
||||||
|
POSITIONS: 'badges.field.positions',
|
||||||
|
CATEGORY: 'badges.field.category',
|
||||||
|
ROLE: 'badges.field.role',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
|
||||||
|
FULL_NAME: 'badges.field.fullName.placeholder',
|
||||||
|
POSITIONS: 'badges.field.positions.placeholder',
|
||||||
|
CATEGORY: 'badges.field.category.placeholder',
|
||||||
|
ROLE: 'badges.field.role.placeholder',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Position/taille par défaut à l'activation d'un champ — empilés
|
||||||
|
// verticalement pour ne pas se superposer (repères pour un badge CR80 ;
|
||||||
|
// l'utilisateur ajuste ensuite à la souris pour son propre format). Postes
|
||||||
|
// affectés sur plusieurs lignes (un par créneau, avec horaire) : plus haut
|
||||||
|
// que les autres champs.
|
||||||
|
const FIELD_DEFAULTS: Record<BadgeFieldType, Omit<BadgeFieldIn, 'field_type'>> = {
|
||||||
|
FULL_NAME: {
|
||||||
|
x_mm: 5, y_mm: 4, width_mm: 70, height_mm: 10,
|
||||||
|
font_size: 13, align: 'LEFT', bold: true, color: '#000000',
|
||||||
|
},
|
||||||
|
POSITIONS: {
|
||||||
|
x_mm: 5, y_mm: 15, width_mm: 70, height_mm: 22,
|
||||||
|
font_size: 8, align: 'LEFT', bold: false, color: '#000000',
|
||||||
|
},
|
||||||
|
CATEGORY: {
|
||||||
|
x_mm: 5, y_mm: 39, width_mm: 70, height_mm: 7,
|
||||||
|
font_size: 8, align: 'LEFT', bold: false, color: '#000000',
|
||||||
|
},
|
||||||
|
ROLE: {
|
||||||
|
x_mm: 5, y_mm: 47, width_mm: 70, height_mm: 6,
|
||||||
|
font_size: 8, align: 'LEFT', bold: true, color: '#a1442b',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldsFromServer(saved: BadgeFieldIn[]): Record<BadgeFieldType, FieldState> {
|
||||||
|
const byType = new Map(saved.map((f) => [f.field_type, f]));
|
||||||
|
const out = {} as Record<BadgeFieldType, FieldState>;
|
||||||
|
for (const type of FIELD_TYPES) {
|
||||||
|
const existing = byType.get(type);
|
||||||
|
out[type] = existing
|
||||||
|
? { ...existing, enabled: true }
|
||||||
|
: { field_type: type, enabled: false, ...FIELD_DEFAULTS[type] };
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BadgesPage() {
|
||||||
|
const [tab, setTab] = useState<'customize' | 'generate'>('customize');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-b-2 border-ink bg-sand px-4 py-[9px]">
|
||||||
|
<span className="mr-1 font-mono text-[9px] opacity-60">{t('badges.admin')}</span>
|
||||||
|
<Chip active={tab === 'customize'} onClick={() => setTab('customize')}>
|
||||||
|
{t('badges.tab.customize')}
|
||||||
|
</Chip>
|
||||||
|
<Chip active={tab === 'generate'} onClick={() => setTab('generate')}>
|
||||||
|
{t('badges.tab.generate')}
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'customize' ? <BadgeCustomizeTab /> : <BadgeGenerateTab />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Personnalisation //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
function BadgeCustomizeTab() {
|
||||||
|
const template = useBadgeTemplate();
|
||||||
|
const frontUrl = useBadgeImageUrl(
|
||||||
|
'front', template.data?.has_front_image ?? false, template.dataUpdatedAt,
|
||||||
|
);
|
||||||
|
const backUrl = useBadgeImageUrl(
|
||||||
|
'back', template.data?.has_back_image ?? false, template.dataUpdatedAt,
|
||||||
|
);
|
||||||
|
const uploadFront = useUploadBadgeFrontImage();
|
||||||
|
const uploadBack = useUploadBadgeBackImage();
|
||||||
|
const setFields = useSetBadgeFields();
|
||||||
|
const setSize = useSetBadgeSize();
|
||||||
|
|
||||||
|
const [fields, setFieldsState] = useState<Record<BadgeFieldType, FieldState> | null>(null);
|
||||||
|
const [selected, setSelected] = useState<BadgeFieldType | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (template.data) setFieldsState(fieldsFromServer(template.data.fields));
|
||||||
|
}, [template.data]);
|
||||||
|
|
||||||
|
if (template.isLoading || !fields) return <Loading />;
|
||||||
|
if (template.error) return <ErrorNote error={template.error} />;
|
||||||
|
|
||||||
|
const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
|
||||||
|
const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM;
|
||||||
|
|
||||||
|
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
|
||||||
|
setFieldsState((prev) => (prev ? { ...prev, [type]: { ...prev[type], ...patch } } : prev));
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
const payload: BadgeFieldIn[] = FIELD_TYPES.filter((type) => fields[type].enabled).map(
|
||||||
|
(type) => {
|
||||||
|
const f = fields[type];
|
||||||
|
return {
|
||||||
|
field_type: f.field_type,
|
||||||
|
x_mm: f.x_mm,
|
||||||
|
y_mm: f.y_mm,
|
||||||
|
width_mm: f.width_mm,
|
||||||
|
height_mm: f.height_mm,
|
||||||
|
font_size: f.font_size,
|
||||||
|
align: f.align,
|
||||||
|
bold: f.bold,
|
||||||
|
color: f.color,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setFields.mutate({ fields: payload });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-4 lg:flex-row lg:items-start">
|
||||||
|
<div className="flex flex-none flex-col gap-4">
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader>
|
||||||
|
{t('badges.front')}
|
||||||
|
<HelpTip text={t('badges.front.help')} className="ml-[6px]" />
|
||||||
|
</PanelHeader>
|
||||||
|
<div className="p-3">
|
||||||
|
<ImageUploadRow
|
||||||
|
url={frontUrl}
|
||||||
|
pending={uploadFront.isPending}
|
||||||
|
error={uploadFront.error}
|
||||||
|
onPick={(file) => uploadFront.mutate(file)}
|
||||||
|
/>
|
||||||
|
<BadgeSizeControl
|
||||||
|
badgeW={badgeW}
|
||||||
|
badgeH={badgeH}
|
||||||
|
pending={setSize.isPending}
|
||||||
|
onApply={(width_mm, height_mm) =>
|
||||||
|
setSize.mutate({ badge_width_mm: width_mm, badge_height_mm: height_mm })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
|
||||||
|
style={{
|
||||||
|
width: badgeW * SCALE,
|
||||||
|
height: badgeH * SCALE,
|
||||||
|
backgroundImage: frontUrl ? `url(${frontUrl})` : undefined,
|
||||||
|
}}
|
||||||
|
onPointerDown={() => setSelected(null)}
|
||||||
|
>
|
||||||
|
{FIELD_TYPES.filter((type) => fields[type].enabled).map((type) => (
|
||||||
|
<FieldBox
|
||||||
|
key={type}
|
||||||
|
field={fields[type]}
|
||||||
|
badgeW={badgeW}
|
||||||
|
badgeH={badgeH}
|
||||||
|
selected={selected === type}
|
||||||
|
onSelect={() => setSelected(type)}
|
||||||
|
onChange={(patch) => updateField(type, patch)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 font-mono text-[9px] leading-relaxed opacity-60">
|
||||||
|
{t('badges.editor.hint', {
|
||||||
|
width: Math.round(badgeW * 10) / 10,
|
||||||
|
height: Math.round(badgeH * 10) / 10,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader>
|
||||||
|
{t('badges.back')}
|
||||||
|
<HelpTip text={t('badges.back.help')} className="ml-[6px]" />
|
||||||
|
</PanelHeader>
|
||||||
|
<div className="p-3">
|
||||||
|
<ImageUploadRow
|
||||||
|
url={backUrl}
|
||||||
|
pending={uploadBack.isPending}
|
||||||
|
error={uploadBack.error}
|
||||||
|
onPick={(file) => uploadBack.mutate(file)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center"
|
||||||
|
style={{ width: badgeW * SCALE, height: badgeH * SCALE, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel className="min-w-[320px] flex-1">
|
||||||
|
<PanelHeader>{t('badges.fields')}</PanelHeader>
|
||||||
|
<div className="flex flex-col divide-y-2 divide-ink/10">
|
||||||
|
{FIELD_TYPES.map((type) => (
|
||||||
|
<FieldEditorRow
|
||||||
|
key={type}
|
||||||
|
type={type}
|
||||||
|
field={fields[type]}
|
||||||
|
active={selected === type}
|
||||||
|
onFocus={() => setSelected(type)}
|
||||||
|
onChange={(patch) => updateField(type, patch)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 border-t-2 border-ink p-3">
|
||||||
|
<Btn onClick={save} disabled={setFields.isPending}>
|
||||||
|
{setFields.isPending ? t('badges.saving') : t('badges.save')}
|
||||||
|
</Btn>
|
||||||
|
{setFields.isSuccess ? (
|
||||||
|
<span className="font-mono text-[9.5px] font-bold text-green">{t('badges.saved')}</span>
|
||||||
|
) : null}
|
||||||
|
{setFields.isError ? (
|
||||||
|
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
|
||||||
|
{errorMessage(setFields.error)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageUploadRow({
|
||||||
|
url,
|
||||||
|
pending,
|
||||||
|
error,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
url: string | null;
|
||||||
|
pending: boolean;
|
||||||
|
error: unknown;
|
||||||
|
onPick: (file: File) => void;
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-[10px]">
|
||||||
|
<Btn variant="ghost" onClick={() => inputRef.current?.click()} disabled={pending}>
|
||||||
|
{pending ? t('badges.uploading') : url ? t('badges.image.replace') : t('badges.image.choose')}
|
||||||
|
</Btn>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) onPick(file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{error ? (
|
||||||
|
<span className="font-mono text-[9.5px] font-bold text-orange-dark">{errorMessage(error)}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Largeur du badge en mm — la hauteur suit proportionnellement (ratio de
|
||||||
|
* l'image importée). Sert à agrandir un badge devenu minuscule parce que
|
||||||
|
* l'image de fond n'était pas à une résolution d'impression (300 dpi
|
||||||
|
* supposés à l'import, voir `useBadgeTemplate`). */
|
||||||
|
function BadgeSizeControl({
|
||||||
|
badgeW,
|
||||||
|
badgeH,
|
||||||
|
pending,
|
||||||
|
onApply,
|
||||||
|
}: {
|
||||||
|
badgeW: number;
|
||||||
|
badgeH: number;
|
||||||
|
pending: boolean;
|
||||||
|
onApply: (width_mm: number, height_mm: number) => void;
|
||||||
|
}) {
|
||||||
|
const rounded = Math.round(badgeW * 10) / 10;
|
||||||
|
const [width, setWidth] = useState(String(rounded));
|
||||||
|
|
||||||
|
useEffect(() => setWidth(String(rounded)), [rounded]);
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
const w = Number(width);
|
||||||
|
if (!Number.isFinite(w) || w <= 0) {
|
||||||
|
setWidth(String(rounded));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ratio = badgeH / badgeW;
|
||||||
|
onApply(w, Math.round(w * ratio * 10) / 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex items-center gap-[8px]">
|
||||||
|
<FieldLabel>{t('badges.size.width')}</FieldLabel>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={5}
|
||||||
|
step={1}
|
||||||
|
value={width}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(e) => setWidth(e.target.value)}
|
||||||
|
onBlur={apply}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') apply();
|
||||||
|
}}
|
||||||
|
className="w-[64px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-[9.5px] opacity-60">
|
||||||
|
{t('badges.size.height', { height: Math.round(badgeH * 10) / 10 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldBox({
|
||||||
|
field,
|
||||||
|
badgeW,
|
||||||
|
badgeH,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
field: FieldState;
|
||||||
|
badgeW: number;
|
||||||
|
badgeH: number;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onChange: (patch: Partial<FieldState>) => void;
|
||||||
|
}) {
|
||||||
|
const dragRef = useRef<{ startXMm: number; startYMm: number; pointerXPx: number; pointerYPx: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const resizeRef = useRef<{ startWMm: number; startHMm: number; pointerXPx: number; pointerYPx: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const startDrag = (e: React.PointerEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelect();
|
||||||
|
(e.target as Element).setPointerCapture(e.pointerId);
|
||||||
|
dragRef.current = { startXMm: field.x_mm, startYMm: field.y_mm, pointerXPx: e.clientX, pointerYPx: e.clientY };
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragMove = (e: React.PointerEvent) => {
|
||||||
|
if (!dragRef.current) return;
|
||||||
|
const d = dragRef.current;
|
||||||
|
const dxMm = (e.clientX - d.pointerXPx) / SCALE;
|
||||||
|
const dyMm = (e.clientY - d.pointerYPx) / SCALE;
|
||||||
|
onChange({
|
||||||
|
x_mm: clamp(d.startXMm + dxMm, 0, badgeW - field.width_mm),
|
||||||
|
y_mm: clamp(d.startYMm + dyMm, 0, badgeH - field.height_mm),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startResize = (e: React.PointerEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelect();
|
||||||
|
(e.target as Element).setPointerCapture(e.pointerId);
|
||||||
|
resizeRef.current = { startWMm: field.width_mm, startHMm: field.height_mm, pointerXPx: e.clientX, pointerYPx: e.clientY };
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResizeMove = (e: React.PointerEvent) => {
|
||||||
|
if (!resizeRef.current) return;
|
||||||
|
const d = resizeRef.current;
|
||||||
|
const dwMm = (e.clientX - d.pointerXPx) / SCALE;
|
||||||
|
const dhMm = (e.clientY - d.pointerYPx) / SCALE;
|
||||||
|
onChange({
|
||||||
|
width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm),
|
||||||
|
height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onPointerDown={startDrag}
|
||||||
|
onPointerMove={onDragMove}
|
||||||
|
onPointerUp={() => (dragRef.current = null)}
|
||||||
|
className={cx(
|
||||||
|
'absolute flex cursor-move items-start overflow-hidden border-[1.5px] bg-white/55 px-1 py-[1px]',
|
||||||
|
selected ? 'border-blue-strong' : 'border-ink/40',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: field.x_mm * SCALE,
|
||||||
|
top: field.y_mm * SCALE,
|
||||||
|
width: field.width_mm * SCALE,
|
||||||
|
height: field.height_mm * SCALE,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none whitespace-pre-line font-sans leading-tight"
|
||||||
|
style={{
|
||||||
|
fontSize: field.font_size * (SCALE / 2.83), // pt -> px approx. à l'échelle du badge
|
||||||
|
fontWeight: field.bold ? 800 : 400,
|
||||||
|
color: field.color,
|
||||||
|
width: '100%',
|
||||||
|
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(FIELD_PLACEHOLDER_KEY[field.field_type])}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
onPointerDown={startResize}
|
||||||
|
onPointerMove={onResizeMove}
|
||||||
|
onPointerUp={() => (resizeRef.current = null)}
|
||||||
|
className="absolute bottom-0 right-0 h-[10px] w-[10px] cursor-nwse-resize border-l-[1.5px] border-t-[1.5px] border-ink bg-yellow"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldEditorRow({
|
||||||
|
type,
|
||||||
|
field,
|
||||||
|
active,
|
||||||
|
onFocus,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
type: BadgeFieldType;
|
||||||
|
field: FieldState;
|
||||||
|
active: boolean;
|
||||||
|
onFocus: () => void;
|
||||||
|
onChange: (patch: Partial<FieldState>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cx('flex flex-col gap-2 p-3', active && 'bg-yellow-soft')} onClick={onFocus}>
|
||||||
|
<label className="flex items-center gap-[8px]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.enabled}
|
||||||
|
onChange={(e) => onChange({ enabled: e.target.checked })}
|
||||||
|
className="h-[16px] w-[16px] accent-ink"
|
||||||
|
/>
|
||||||
|
<span className="font-display text-[12px] font-extrabold uppercase tracking-wide">
|
||||||
|
{t(FIELD_LABEL_KEY[type])}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{field.enabled ? (
|
||||||
|
<div className="ml-[24px] flex flex-wrap items-center gap-[10px]">
|
||||||
|
<label className="flex items-center gap-[5px]">
|
||||||
|
<FieldLabel>{t('badges.field.size')}</FieldLabel>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={6}
|
||||||
|
max={36}
|
||||||
|
value={field.font_size}
|
||||||
|
onChange={(e) => onChange({ font_size: Number(e.target.value) })}
|
||||||
|
className="w-[52px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-[4px]">
|
||||||
|
{(['LEFT', 'CENTER', 'RIGHT'] as BadgeAlign[]).map((align) => (
|
||||||
|
<button
|
||||||
|
key={align}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ align })}
|
||||||
|
className={cx(
|
||||||
|
'h-[24px] w-[24px] cursor-pointer border-2 border-ink font-mono text-[10px] font-bold',
|
||||||
|
field.align === align ? 'bg-ink text-cream' : 'bg-cream hover:bg-yellow',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{align === 'LEFT' ? '⟵' : align === 'CENTER' ? '↔' : '⟶'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ bold: !field.bold })}
|
||||||
|
className={cx(
|
||||||
|
'h-[24px] w-[24px] cursor-pointer border-2 border-ink font-display text-[11px] font-black',
|
||||||
|
field.bold ? 'bg-ink text-cream' : 'bg-cream hover:bg-yellow',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
G
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={field.color}
|
||||||
|
onChange={(e) => onChange({ color: e.target.value })}
|
||||||
|
className="h-[24px] w-[30px] cursor-pointer border-2 border-ink p-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Génération //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
function BadgeGenerateTab() {
|
||||||
|
const volunteers = useVolunteers();
|
||||||
|
const generate = useGenerateBadges();
|
||||||
|
const [mode, setMode] = useState<'all' | 'pick'>('all');
|
||||||
|
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const sorted = useMemo(
|
||||||
|
() => [...(volunteers.data ?? [])].sort((a, b) => a.last_name.localeCompare(b.last_name, 'fr')),
|
||||||
|
[volunteers.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (volunteers.isLoading) return <Loading />;
|
||||||
|
if (volunteers.error) return <ErrorNote error={volunteers.error} />;
|
||||||
|
if (sorted.length === 0) return <EmptyNote>{t('volunteers.noData')}</EmptyNote>;
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
setPicked((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const download = async () => {
|
||||||
|
const volunteer_ids = mode === 'pick' ? [...picked] : undefined;
|
||||||
|
try {
|
||||||
|
const blob = await generate.mutateAsync({ volunteer_ids });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'badges.pdf';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
// L'erreur est déjà exposée via `generate.isError` / `generate.error`.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const disabled = generate.isPending || (mode === 'pick' && picked.size === 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-4">
|
||||||
|
<Panel className="max-w-[560px]">
|
||||||
|
<PanelHeader>{t('badges.generate.title')}</PanelHeader>
|
||||||
|
<div className="flex flex-col gap-3 p-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Chip active={mode === 'all'} onClick={() => setMode('all')}>
|
||||||
|
{t('badges.generate.all', { count: sorted.length })}
|
||||||
|
</Chip>
|
||||||
|
<Chip active={mode === 'pick'} onClick={() => setMode('pick')}>
|
||||||
|
{t('badges.generate.pick', { count: picked.size })}
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'pick' ? (
|
||||||
|
<div className="max-h-[320px] overflow-auto border-2 border-ink">
|
||||||
|
{sorted.map((v) => (
|
||||||
|
<label
|
||||||
|
key={v.id}
|
||||||
|
className="flex cursor-pointer items-center gap-[8px] border-b border-ink/15 px-[10px] py-[7px] font-mono text-[11px] hover:bg-yellow-soft"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={picked.has(v.id)}
|
||||||
|
onChange={() => toggle(v.id)}
|
||||||
|
className="h-[14px] w-[14px] accent-ink"
|
||||||
|
/>
|
||||||
|
{v.first_name} {v.last_name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Btn onClick={download} disabled={disabled}>
|
||||||
|
{generate.isPending ? t('badges.generating') : t('badges.generate.action')}
|
||||||
|
</Btn>
|
||||||
|
{generate.isError ? (
|
||||||
|
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
|
||||||
|
{errorMessage(generate.error)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
if (error instanceof ApiError) return t(error.code);
|
||||||
|
return t('error.generic');
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { ApiError } from '@/api';
|
||||||
|
import {
|
||||||
|
useFormLogoUrl,
|
||||||
|
useFormTemplate,
|
||||||
|
useSetFormFields,
|
||||||
|
useUpdateFormTemplate,
|
||||||
|
useUploadFormLogo,
|
||||||
|
} from '@/api/hooks';
|
||||||
|
import type { FormFieldType } from '@/api/schema';
|
||||||
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
|
import { Btn, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
if (error instanceof ApiError) return t(error.code);
|
||||||
|
return t('error.generic');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nom/prénom toujours collectés (indispensables à la création du bénévole) —
|
||||||
|
// pas dans cette liste, voir CLAUDE.md § 2 (Volunteer) et app/api/forms.py.
|
||||||
|
const FIELD_TYPES: FormFieldType[] = [
|
||||||
|
'EMAIL',
|
||||||
|
'PHONE',
|
||||||
|
'POSITION_WISHES',
|
||||||
|
'ARTIST_WISHES',
|
||||||
|
'COMPANION_WISH',
|
||||||
|
'AVAILABLE',
|
||||||
|
'WILLING_TO_PAY_FEE',
|
||||||
|
'PHOTO',
|
||||||
|
'COMMENT',
|
||||||
|
];
|
||||||
|
|
||||||
|
const FIELD_LABEL_KEY: Record<FormFieldType, string> = {
|
||||||
|
EMAIL: 'form.field.email',
|
||||||
|
PHONE: 'form.field.phone',
|
||||||
|
POSITION_WISHES: 'form.field.positionWishes',
|
||||||
|
ARTIST_WISHES: 'form.field.artistWishes',
|
||||||
|
COMPANION_WISH: 'form.field.companionWish',
|
||||||
|
AVAILABLE: 'form.field.available',
|
||||||
|
WILLING_TO_PAY_FEE: 'form.field.willingToPayFee',
|
||||||
|
PHOTO: 'form.field.photo',
|
||||||
|
COMMENT: 'form.field.comment',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIELD_HELP_KEY: Record<FormFieldType, string | null> = {
|
||||||
|
EMAIL: null,
|
||||||
|
PHONE: null,
|
||||||
|
POSITION_WISHES: 'form.field.positionWishes.help',
|
||||||
|
ARTIST_WISHES: 'form.field.artistWishes.help',
|
||||||
|
COMPANION_WISH: 'form.field.companionWish.help',
|
||||||
|
AVAILABLE: 'form.field.available.help',
|
||||||
|
WILLING_TO_PAY_FEE: null,
|
||||||
|
PHOTO: null,
|
||||||
|
COMMENT: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FormBuilderPage() {
|
||||||
|
const template = useFormTemplate();
|
||||||
|
const setFields = useSetFormFields();
|
||||||
|
// Trois instances indépendantes du même mutateur (même endpoint PATCH) :
|
||||||
|
// le statut (pending/success) de l'une ne doit pas déteindre sur l'autre.
|
||||||
|
const toggleOpen = useUpdateFormTemplate();
|
||||||
|
const saveDescription = useUpdateFormTemplate();
|
||||||
|
const saveYaplaUrl = useUpdateFormTemplate();
|
||||||
|
const uploadLogo = useUploadFormLogo();
|
||||||
|
const logoUrl = useFormLogoUrl(template.data?.has_logo ?? false, template.dataUpdatedAt);
|
||||||
|
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [enabled, setEnabled] = useState<Set<FormFieldType> | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [yaplaUrl, setYaplaUrl] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (template.data) setEnabled(new Set(template.data.fields));
|
||||||
|
}, [template.data]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (template.data) setDescription(template.data.description ?? '');
|
||||||
|
}, [template.data]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (template.data) setYaplaUrl(template.data.yapla_url ?? '');
|
||||||
|
}, [template.data]);
|
||||||
|
|
||||||
|
if (template.isLoading || !enabled) return <Loading />;
|
||||||
|
if (template.error) return <ErrorNote error={template.error} />;
|
||||||
|
|
||||||
|
const toggle = (type: FormFieldType) => {
|
||||||
|
setEnabled((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(type)) next.delete(type);
|
||||||
|
else next.add(type);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const link = `${window.location.origin}/inscription`;
|
||||||
|
const copyLink = () => {
|
||||||
|
navigator.clipboard.writeText(link).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col overflow-auto p-4">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start">
|
||||||
|
<Panel className="max-w-[420px] flex-1">
|
||||||
|
<PanelHeader>{t('form.link.title')}</PanelHeader>
|
||||||
|
<div className="flex flex-col gap-3 p-3">
|
||||||
|
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
|
||||||
|
{t('form.link.hint')}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={link}
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
|
className="w-full border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[11px]"
|
||||||
|
/>
|
||||||
|
<Btn variant="ghost" onClick={copyLink}>
|
||||||
|
{copied ? t('form.link.copied') : t('form.link.copy')}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center gap-3 border-t-2 border-ink pt-3">
|
||||||
|
<span className="font-display text-[11px] font-extrabold uppercase tracking-wide">
|
||||||
|
{t('form.status.title')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleOpen.mutate({ is_open: !template.data?.is_open })}
|
||||||
|
disabled={toggleOpen.isPending}
|
||||||
|
className={cx(
|
||||||
|
'cursor-pointer border-2 border-ink px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wide',
|
||||||
|
template.data?.is_open ? 'bg-green text-white' : 'bg-orange text-ink',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{template.data?.is_open ? t('form.status.open') : t('form.status.closed')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel className="min-w-[320px] flex-1">
|
||||||
|
<PanelHeader>{t('form.fields.title')}</PanelHeader>
|
||||||
|
<div className="flex flex-col divide-y-2 divide-ink/10">
|
||||||
|
{FIELD_TYPES.map((type) => (
|
||||||
|
<label
|
||||||
|
key={type}
|
||||||
|
className="flex cursor-pointer items-center gap-[10px] p-3 hover:bg-yellow-soft"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled.has(type)}
|
||||||
|
onChange={() => toggle(type)}
|
||||||
|
className="h-[16px] w-[16px] accent-ink"
|
||||||
|
/>
|
||||||
|
<span className="font-display text-[12px] font-extrabold uppercase tracking-wide">
|
||||||
|
{t(FIELD_LABEL_KEY[type])}
|
||||||
|
</span>
|
||||||
|
{FIELD_HELP_KEY[type] ? <HelpTip text={t(FIELD_HELP_KEY[type]!)} /> : null}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 border-t-2 border-ink p-3">
|
||||||
|
<Btn
|
||||||
|
onClick={() => setFields.mutate({ fields: [...enabled] })}
|
||||||
|
disabled={setFields.isPending}
|
||||||
|
>
|
||||||
|
{setFields.isPending ? t('form.saving') : t('form.save')}
|
||||||
|
</Btn>
|
||||||
|
{setFields.isSuccess ? (
|
||||||
|
<span className="font-mono text-[9.5px] font-bold text-green">{t('form.saved')}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel className="mt-4 max-w-[720px]">
|
||||||
|
<PanelHeader>{t('form.customize.title')}</PanelHeader>
|
||||||
|
<div className="flex flex-col gap-4 p-3 sm:flex-row">
|
||||||
|
<div className="flex flex-none flex-col items-start gap-2">
|
||||||
|
<FieldLabel>{t('form.customize.logo')}</FieldLabel>
|
||||||
|
{logoUrl ? (
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-[96px] w-[96px] border-2 border-ink object-contain bg-cream"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Btn variant="ghost" onClick={() => logoInputRef.current?.click()} disabled={uploadLogo.isPending}>
|
||||||
|
{uploadLogo.isPending
|
||||||
|
? t('form.customize.logo.uploading')
|
||||||
|
: logoUrl
|
||||||
|
? t('form.customize.logo.replace')
|
||||||
|
: t('form.customize.logo.choose')}
|
||||||
|
</Btn>
|
||||||
|
<input
|
||||||
|
ref={logoInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) uploadLogo.mutate(file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{uploadLogo.isError ? (
|
||||||
|
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
|
||||||
|
{errorMessage(uploadLogo.error)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||||
|
<FieldLabel>{t('form.customize.description')}</FieldLabel>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder={t('form.customize.description.placeholder')}
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[9px] font-mono text-[12px]"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Btn
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => saveDescription.mutate({ description: description.trim() || null })}
|
||||||
|
disabled={saveDescription.isPending}
|
||||||
|
>
|
||||||
|
{saveDescription.isPending ? t('form.saving') : t('form.save')}
|
||||||
|
</Btn>
|
||||||
|
{saveDescription.isSuccess ? (
|
||||||
|
<span className="font-mono text-[9.5px] font-bold text-green">{t('form.saved')}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 border-t-2 border-ink p-3">
|
||||||
|
<div className="flex items-center gap-[6px]">
|
||||||
|
<FieldLabel>{t('form.customize.yaplaUrl')}</FieldLabel>
|
||||||
|
<HelpTip text={t('form.customize.yaplaUrl.help')} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={yaplaUrl}
|
||||||
|
onChange={(e) => setYaplaUrl(e.target.value)}
|
||||||
|
placeholder={t('form.customize.yaplaUrl.placeholder')}
|
||||||
|
className="w-full border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[11px]"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Btn
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => saveYaplaUrl.mutate({ yapla_url: yaplaUrl.trim() || null })}
|
||||||
|
disabled={saveYaplaUrl.isPending}
|
||||||
|
>
|
||||||
|
{saveYaplaUrl.isPending ? t('form.saving') : t('form.save')}
|
||||||
|
</Btn>
|
||||||
|
{saveYaplaUrl.isSuccess ? (
|
||||||
|
<span className="font-mono text-[9.5px] font-bold text-green">{t('form.saved')}</span>
|
||||||
|
) : null}
|
||||||
|
{saveYaplaUrl.isError ? (
|
||||||
|
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
|
||||||
|
{errorMessage(saveYaplaUrl.error)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,27 +1,51 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { useAssignments, useMatrix, useRunSolver, useVolunteers } from '@/api/hooks';
|
import {
|
||||||
|
useAssignments,
|
||||||
|
useDayParts,
|
||||||
|
useGeneratePositionSheets,
|
||||||
|
useMatrix,
|
||||||
|
useRunSolver,
|
||||||
|
useVolunteers,
|
||||||
|
} from '@/api/hooks';
|
||||||
|
import { ApiError } from '@/api';
|
||||||
import type { MatrixPosition, MatrixShift, PositionCategory } from '@/api/schema';
|
import type { MatrixPosition, MatrixShift, PositionCategory } from '@/api/schema';
|
||||||
import { Chip, HelpTip } from '@/components/ui';
|
import { Chip, HelpTip } from '@/components/ui';
|
||||||
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
||||||
import { AssignmentPanel } from '@/pages/grille/AssignmentPanel';
|
import { AssignmentPanel } from '@/pages/grille/AssignmentPanel';
|
||||||
import { categoryLabel, columnWidthPx, latticeFromMatrix, type LatticeColumn } from '@/lib/planning';
|
import {
|
||||||
import { datePart, shortDayLabel, timePart } from '@/lib/time';
|
buildTimeGrid,
|
||||||
|
categoryLabel,
|
||||||
|
columnWidthPx,
|
||||||
|
dayBoundaryFromDayParts,
|
||||||
|
placeInTimeGrid,
|
||||||
|
type TimeGridDay,
|
||||||
|
} from '@/lib/planning';
|
||||||
|
import { hourRangeLabel, shortDayLabel } from '@/lib/time';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
import { cx } from '@/lib/cx';
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
export function GrillePage() {
|
export function GrillePage() {
|
||||||
const matrix = useMatrix();
|
const matrix = useMatrix();
|
||||||
|
const dayParts = useDayParts();
|
||||||
const runSolver = useRunSolver();
|
const runSolver = useRunSolver();
|
||||||
|
const generateSheets = useGeneratePositionSheets();
|
||||||
const assignments = useAssignments();
|
const assignments = useAssignments();
|
||||||
const volunteers = useVolunteers();
|
const volunteers = useVolunteers();
|
||||||
const [dayFilter, setDayFilter] = useState<'all' | string>('all');
|
const [dayFilter, setDayFilter] = useState<'all' | string>('all');
|
||||||
const [categoryFilter, setCategoryFilter] = useState<'all' | PositionCategory>('all');
|
const [categoryFilter, setCategoryFilter] = useState<'all' | PositionCategory>('all');
|
||||||
const [selectedShiftId, setSelectedShiftId] = useState<string | null>(null);
|
const [selectedShiftId, setSelectedShiftId] = useState<string | null>(null);
|
||||||
|
|
||||||
const lattice = useMemo(
|
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
|
||||||
() => (matrix.data ? latticeFromMatrix(matrix.data) : { days: [], columns: [] }),
|
const days = useMemo(
|
||||||
[matrix.data],
|
() =>
|
||||||
|
matrix.data
|
||||||
|
? buildTimeGrid(
|
||||||
|
matrix.data.positions.flatMap((p) => p.shifts),
|
||||||
|
dayBoundary,
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
[matrix.data, dayBoundary],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Noms des bénévoles affectés, par créneau — pour l'aperçu au survol d'une
|
// Noms des bénévoles affectés, par créneau — pour l'aperçu au survol d'une
|
||||||
@@ -56,8 +80,7 @@ export function GrillePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const categories = [...new Set(matrix.data.positions.map((p) => p.category))].sort();
|
const categories = [...new Set(matrix.data.positions.map((p) => p.category))].sort();
|
||||||
const shownDays = dayFilter === 'all' ? lattice.days : lattice.days.filter((d) => d.date === dayFilter);
|
const shownDays = dayFilter === 'all' ? days : days.filter((d) => d.date === dayFilter);
|
||||||
const shownColumns = shownDays.flatMap((d) => d.columns);
|
|
||||||
const shownPositions =
|
const shownPositions =
|
||||||
categoryFilter === 'all'
|
categoryFilter === 'all'
|
||||||
? matrix.data.positions
|
? matrix.data.positions
|
||||||
@@ -66,17 +89,48 @@ export function GrillePage() {
|
|||||||
const gridTemplateColumns = [
|
const gridTemplateColumns = [
|
||||||
'230px',
|
'230px',
|
||||||
...shownDays.flatMap((day) => {
|
...shownDays.flatMap((day) => {
|
||||||
const width = columnWidthPx(day.columns.length);
|
const width = columnWidthPx(day.slotLabels.length);
|
||||||
return day.columns.map(() => `${width}px`);
|
return day.slotLabels.map(() => `${width}px`);
|
||||||
}),
|
}),
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
|
// Ligne de grille (1-based) où commence chaque jour, colonne 1 réservée au
|
||||||
|
// nom du poste — chaque cellule reçoit sa position explicitement
|
||||||
|
// (`gridColumn`/`gridRow`) plutôt que de compter sur l'ordre d'émission
|
||||||
|
// des enfants : un poste dont les créneaux ne totalisent pas exactement le
|
||||||
|
// nombre de tranches d'un jour (créneaux adjacents non alignés sur 30 min,
|
||||||
|
// par ex.) ne doit jamais décaler les lignes suivantes.
|
||||||
|
const dayColumnStarts: number[] = [];
|
||||||
|
{
|
||||||
|
let col = 2;
|
||||||
|
for (const day of shownDays) {
|
||||||
|
dayColumnStarts.push(col);
|
||||||
|
col += day.slotLabels.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const selected = selectedShiftId
|
const selected = selectedShiftId
|
||||||
? matrix.data.positions
|
? matrix.data.positions
|
||||||
.flatMap((p) => p.shifts.map((s) => ({ position: p, shift: s })))
|
.flatMap((p) => p.shifts.map((s) => ({ position: p, shift: s })))
|
||||||
.find((entry) => entry.shift.shift_id === selectedShiftId) ?? null
|
.find((entry) => entry.shift.shift_id === selectedShiftId) ?? null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const downloadPositionSheets = async () => {
|
||||||
|
try {
|
||||||
|
const blob = await generateSheets.mutateAsync({
|
||||||
|
position_ids: shownPositions.map((p) => p.id),
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'feuilles-de-poste.pdf';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
// L'erreur est déjà exposée via generateSheets.isError / .error.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="flex flex-wrap items-center gap-[14px] border-b-2 border-ink bg-sand px-4 py-[9px]">
|
<div className="flex flex-wrap items-center gap-[14px] border-b-2 border-ink bg-sand px-4 py-[9px]">
|
||||||
@@ -85,7 +139,7 @@ export function GrillePage() {
|
|||||||
<Chip active={dayFilter === 'all'} onClick={() => setDayFilter('all')}>
|
<Chip active={dayFilter === 'all'} onClick={() => setDayFilter('all')}>
|
||||||
{t('filter.all')}
|
{t('filter.all')}
|
||||||
</Chip>
|
</Chip>
|
||||||
{lattice.days.map((day) => (
|
{days.map((day) => (
|
||||||
<Chip key={day.date} active={dayFilter === day.date} onClick={() => setDayFilter(day.date)}>
|
<Chip key={day.date} active={dayFilter === day.date} onClick={() => setDayFilter(day.date)}>
|
||||||
{shortDayLabel(`${day.date}T12:00`)}
|
{shortDayLabel(`${day.date}T12:00`)}
|
||||||
</Chip>
|
</Chip>
|
||||||
@@ -127,6 +181,21 @@ export function GrillePage() {
|
|||||||
{t('error.generic')}
|
{t('error.generic')}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={downloadPositionSheets}
|
||||||
|
disabled={generateSheets.isPending}
|
||||||
|
className="cursor-pointer border-2 border-ink bg-cream px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wide shadow-brutal-sm hover:bg-yellow disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{generateSheets.isPending ? t('grille.sheets.generating') : t('grille.sheets.generate')}
|
||||||
|
</button>
|
||||||
|
{generateSheets.isError ? (
|
||||||
|
<span className="font-mono text-[9px] font-bold text-orange-dark">
|
||||||
|
{generateSheets.error instanceof ApiError
|
||||||
|
? t(generateSheets.error.code)
|
||||||
|
: t('error.generic')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
|
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
|
||||||
@@ -152,15 +221,21 @@ export function GrillePage() {
|
|||||||
<div className="flex min-h-0 flex-1 items-stretch">
|
<div className="flex min-h-0 flex-1 items-stretch">
|
||||||
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
|
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
|
||||||
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}>
|
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}>
|
||||||
<div className="sticky left-0 top-0 z-[6] flex items-center gap-[6px] border-b-2 border-r-[3px] border-ink border-b-cream bg-ink px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
|
<div
|
||||||
|
style={{ gridColumn: '1 / span 1', gridRow: 1 }}
|
||||||
|
className="sticky left-0 top-0 z-[20] flex items-center gap-[6px] border-b-2 border-r-[3px] border-ink border-b-cream bg-ink px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream"
|
||||||
|
>
|
||||||
{t('grille.positionsCount', { count: matrix.data.positions.length })}
|
{t('grille.positionsCount', { count: matrix.data.positions.length })}
|
||||||
<HelpTip text={t('grille.footnote')} side="bottom" />
|
<HelpTip text={t('grille.footnote')} side="bottom" />
|
||||||
</div>
|
</div>
|
||||||
{shownDays.map((day) => (
|
{shownDays.map((day, di) => (
|
||||||
<div
|
<div
|
||||||
key={day.date}
|
key={day.date}
|
||||||
className="sticky top-0 z-[4] flex items-baseline gap-[7px] border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] py-[6px] text-cream"
|
className="sticky top-0 z-[12] flex items-baseline gap-[7px] border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] py-[6px] text-cream"
|
||||||
style={{ gridColumn: `span ${day.columns.length}` }}
|
style={{
|
||||||
|
gridColumn: `${dayColumnStarts[di]} / span ${day.slotLabels.length}`,
|
||||||
|
gridRow: 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="font-display text-[12px] font-black tracking-tight">
|
<span className="font-display text-[12px] font-black tracking-tight">
|
||||||
{shortDayLabel(`${day.date}T12:00`)}
|
{shortDayLabel(`${day.date}T12:00`)}
|
||||||
@@ -168,23 +243,32 @@ export function GrillePage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="sticky left-0 top-[27px] z-[6] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px] opacity-60">
|
<div
|
||||||
|
style={{ gridColumn: '1 / span 1', gridRow: 2 }}
|
||||||
|
className="sticky left-0 top-[27px] z-[21] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px]"
|
||||||
|
>
|
||||||
{t('grille.assignedRequired')}
|
{t('grille.assignedRequired')}
|
||||||
</div>
|
</div>
|
||||||
{shownColumns.map((column) => (
|
{shownDays.map((day, di) =>
|
||||||
|
day.slotLabels.map((label, i) => (
|
||||||
<div
|
<div
|
||||||
key={column.key}
|
key={`${day.date}|${i}`}
|
||||||
className="sticky top-[27px] z-[3] overflow-hidden text-ellipsis whitespace-nowrap border-b-[3px] border-r border-ink bg-sand px-[2px] py-[5px] text-center font-mono text-[9.5px]"
|
style={{ gridColumn: `${dayColumnStarts[di] + i} / span 1`, gridRow: 2 }}
|
||||||
|
className="sticky top-[27px] z-[11] overflow-hidden text-ellipsis whitespace-nowrap border-b-[3px] border-r border-ink bg-sand px-[2px] py-[5px] text-center font-mono text-[9.5px]"
|
||||||
>
|
>
|
||||||
{column.label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)),
|
||||||
|
)}
|
||||||
|
|
||||||
{shownPositions.map((position) => (
|
{shownPositions.map((position, posIndex) => (
|
||||||
<PositionRow
|
<PositionRow
|
||||||
key={position.id}
|
key={position.id}
|
||||||
position={position}
|
position={position}
|
||||||
columns={shownColumns}
|
days={shownDays}
|
||||||
|
dayColumnStarts={dayColumnStarts}
|
||||||
|
rowIndex={3 + posIndex}
|
||||||
|
dayBoundary={dayBoundary}
|
||||||
selectedShiftId={selectedShiftId}
|
selectedShiftId={selectedShiftId}
|
||||||
onSelect={setSelectedShiftId}
|
onSelect={setSelectedShiftId}
|
||||||
volunteerNamesByShift={volunteerNamesByShift}
|
volunteerNamesByShift={volunteerNamesByShift}
|
||||||
@@ -247,61 +331,128 @@ function LegendItem({
|
|||||||
|
|
||||||
function PositionRow({
|
function PositionRow({
|
||||||
position,
|
position,
|
||||||
columns,
|
days,
|
||||||
|
dayColumnStarts,
|
||||||
|
rowIndex,
|
||||||
|
dayBoundary,
|
||||||
selectedShiftId,
|
selectedShiftId,
|
||||||
onSelect,
|
onSelect,
|
||||||
volunteerNamesByShift,
|
volunteerNamesByShift,
|
||||||
}: {
|
}: {
|
||||||
position: MatrixPosition;
|
position: MatrixPosition;
|
||||||
columns: LatticeColumn[];
|
days: TimeGridDay[];
|
||||||
|
dayColumnStarts: number[];
|
||||||
|
rowIndex: number;
|
||||||
|
dayBoundary: string;
|
||||||
selectedShiftId: string | null;
|
selectedShiftId: string | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
volunteerNamesByShift: Map<string, string[]>;
|
volunteerNamesByShift: Map<string, string[]>;
|
||||||
}) {
|
}) {
|
||||||
const byColumn = new Map<string, MatrixShift>();
|
const placements = placeInTimeGrid(days, position.shifts, dayBoundary);
|
||||||
for (const shift of position.shifts) {
|
|
||||||
byColumn.set(
|
|
||||||
`${datePart(shift.starts_at)}|${timePart(shift.starts_at)}|${timePart(shift.ends_at)}`,
|
|
||||||
shift,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="sticky left-0 z-[2] flex h-9 flex-col justify-center gap-[1px] border-b border-r-[3px] border-ink bg-cream px-2">
|
<div
|
||||||
|
style={{ gridColumn: '1 / span 1', gridRow: rowIndex }}
|
||||||
|
className="sticky left-0 z-[10] flex h-9 flex-col justify-center gap-[1px] border-b border-r-[3px] border-ink bg-cream px-2"
|
||||||
|
>
|
||||||
<span className="truncate font-display text-[12px] font-bold tracking-tight">
|
<span className="truncate font-display text-[12px] font-bold tracking-tight">
|
||||||
{position.name}
|
{position.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate font-mono text-[8.5px] opacity-55">{position.location}</span>
|
<span className="truncate font-mono text-[8.5px] opacity-55">{position.location}</span>
|
||||||
</div>
|
</div>
|
||||||
{columns.map((column) => {
|
{days.map((day, di) => {
|
||||||
const shift = byColumn.get(column.key);
|
const base = dayColumnStarts[di];
|
||||||
if (!shift) {
|
const dayPlacements = placements.get(day.date) ?? [];
|
||||||
return <div key={column.key} className="h-9 border-b border-r border-ink" />;
|
const cells: React.ReactNode[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
for (const { item: shift, columnStart, columnSpan } of dayPlacements) {
|
||||||
|
// `columnStart` peut retomber avant `cursor` : deux créneaux du
|
||||||
|
// même poste adjacents mais non alignés sur 30 min s'arrondissent
|
||||||
|
// chacun à la tranche englobante et peuvent se toucher/chevaucher
|
||||||
|
// légèrement à l'affichage — on raccourcit plutôt que de laisser
|
||||||
|
// deux cellules revendiquer la même colonne (chaque cellule ayant
|
||||||
|
// sa position de grille explicite, ce repli reste local à ce
|
||||||
|
// poste : il ne décale jamais les lignes suivantes).
|
||||||
|
const start = Math.min(Math.max(cursor, columnStart), day.slotLabels.length);
|
||||||
|
for (let i = cursor; i < start; i += 1) {
|
||||||
|
cells.push(
|
||||||
|
<div
|
||||||
|
key={`${day.date}|empty|${i}`}
|
||||||
|
style={{ gridColumn: `${base + i} / span 1`, gridRow: rowIndex }}
|
||||||
|
className="h-9 border-b border-r border-ink"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
const end = Math.min(Math.max(start + 1, columnStart + columnSpan), day.slotLabels.length);
|
||||||
|
if (start < day.slotLabels.length) {
|
||||||
|
cells.push(
|
||||||
|
<ShiftCell
|
||||||
|
key={shift.shift_id}
|
||||||
|
shift={shift}
|
||||||
|
gridColumn={`${base + start} / span ${end - start}`}
|
||||||
|
gridRow={rowIndex}
|
||||||
|
selected={selectedShiftId === shift.shift_id}
|
||||||
|
onSelect={onSelect}
|
||||||
|
names={volunteerNamesByShift.get(shift.shift_id) ?? []}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cursor = Math.max(cursor, end);
|
||||||
|
}
|
||||||
|
for (let i = cursor; i < day.slotLabels.length; i += 1) {
|
||||||
|
cells.push(
|
||||||
|
<div
|
||||||
|
key={`${day.date}|empty|${i}`}
|
||||||
|
style={{ gridColumn: `${base + i} / span 1`, gridRow: rowIndex }}
|
||||||
|
className="h-9 border-b border-r border-ink"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShiftCell({
|
||||||
|
shift,
|
||||||
|
gridColumn,
|
||||||
|
gridRow,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
names,
|
||||||
|
}: {
|
||||||
|
shift: MatrixShift;
|
||||||
|
gridColumn: string;
|
||||||
|
gridRow: number;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
names: string[];
|
||||||
|
}) {
|
||||||
const assigned = shift.assigned;
|
const assigned = shift.assigned;
|
||||||
const undefinedNeed = shift.min_staff === 0 && shift.ideal_staff === 0;
|
const undefinedNeed = shift.min_staff === 0 && shift.ideal_staff === 0;
|
||||||
const isCrit = !undefinedNeed && assigned < shift.min_staff;
|
const isCrit = !undefinedNeed && assigned < shift.min_staff;
|
||||||
const isOver = !undefinedNeed && assigned > shift.ideal_staff;
|
const isOver = !undefinedNeed && assigned > shift.ideal_staff;
|
||||||
const isInc = !undefinedNeed && !isCrit && !isOver && assigned < shift.ideal_staff;
|
const isInc = !undefinedNeed && !isCrit && !isOver && assigned < shift.ideal_staff;
|
||||||
const isOk = !undefinedNeed && !isCrit && !isOver && !isInc;
|
const isOk = !undefinedNeed && !isCrit && !isOver && !isInc;
|
||||||
const glyph = undefinedNeed
|
const glyph = undefinedNeed ? '·' : isCrit ? '▲' : isOver ? '▼' : isInc ? '◧' : '✓';
|
||||||
? '·'
|
|
||||||
: isCrit
|
|
||||||
? '▲'
|
|
||||||
: isOver
|
|
||||||
? '▼'
|
|
||||||
: isInc
|
|
||||||
? '◧'
|
|
||||||
: '✓';
|
|
||||||
const selected = selectedShiftId === shift.shift_id;
|
|
||||||
const names = volunteerNamesByShift.get(shift.shift_id) ?? [];
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={column.key}
|
|
||||||
onClick={() => onSelect(shift.shift_id)}
|
onClick={() => onSelect(shift.shift_id)}
|
||||||
|
style={{ gridColumn, gridRow }}
|
||||||
className={cx(
|
className={cx(
|
||||||
'group relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink',
|
// `relative z-0` (plutôt que `relative` seul) crée un contexte
|
||||||
|
// d'empilement local : l'infobulle interne (`z-20`, voir plus bas)
|
||||||
|
// ne rivalise qu'avec les autres enfants de cette cellule, jamais
|
||||||
|
// avec les en-têtes figés de la grille (z-10 à 21) — sans ça, un
|
||||||
|
// survol déclenché par la molette pendant un défilement (le
|
||||||
|
// curseur reste immobile à l'écran, ce sont les cellules qui
|
||||||
|
// défilent dessous) pouvait faire passer l'infobulle au-dessus de
|
||||||
|
// la ligne d'en-tête « Affectés / idéal » pendant sa transition de
|
||||||
|
// disparition.
|
||||||
|
'group relative z-0 flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink',
|
||||||
undefinedNeed && 'bg-sand-dark hover:bg-sand',
|
undefinedNeed && 'bg-sand-dark hover:bg-sand',
|
||||||
isCrit && 'bg-hatch-orange',
|
isCrit && 'bg-hatch-orange',
|
||||||
isOver && 'bg-hatch-mauve hover:bg-mauve-hover',
|
isOver && 'bg-hatch-mauve hover:bg-mauve-hover',
|
||||||
@@ -311,10 +462,11 @@ function PositionRow({
|
|||||||
>
|
>
|
||||||
<span className="font-mono text-[13px] font-bold">{assigned}</span>
|
<span className="font-mono text-[13px] font-bold">{assigned}</span>
|
||||||
<span className="font-mono text-[9px] opacity-50">/{shift.ideal_staff}</span>
|
<span className="font-mono text-[9px] opacity-50">/{shift.ideal_staff}</span>
|
||||||
<span className={cx('font-mono text-[10px] font-bold', isOk && 'text-green')}>
|
<span className={cx('font-mono text-[10px] font-bold', isOk && 'text-green')}>{glyph}</span>
|
||||||
{glyph}
|
<div className="pointer-events-none absolute left-1/2 top-full z-[100] mt-[4px] w-max max-w-[220px] -translate-x-1/2 border-2 border-ink bg-ink px-[9px] py-[7px] text-left font-mono text-[10px] font-normal normal-case leading-relaxed tracking-normal text-cream opacity-0 shadow-brutal-md transition-opacity duration-100 group-hover:opacity-100">
|
||||||
</span>
|
<div className="mb-[1px] font-mono text-[9px] font-bold text-cream opacity-80">
|
||||||
<div className="pointer-events-none absolute left-1/2 top-full z-20 mt-[4px] w-max max-w-[220px] -translate-x-1/2 border-2 border-ink bg-ink px-[9px] py-[7px] text-left font-mono text-[10px] font-normal normal-case leading-relaxed tracking-normal text-cream opacity-0 shadow-brutal-md transition-opacity duration-100 group-hover:opacity-100">
|
{hourRangeLabel(shift.starts_at, shift.ends_at)}
|
||||||
|
</div>
|
||||||
<div className="mb-[3px] font-display text-[9px] font-extrabold uppercase tracking-wide text-yellow">
|
<div className="mb-[3px] font-display text-[9px] font-extrabold uppercase tracking-wide text-yellow">
|
||||||
{t('grille.cellPreview.staffing', {
|
{t('grille.cellPreview.staffing', {
|
||||||
assigned,
|
assigned,
|
||||||
@@ -333,7 +485,4 @@ function PositionRow({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/ho
|
|||||||
import { useAuth } from '@/auth/auth-context';
|
import { useAuth } from '@/auth/auth-context';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
import { NowLine } from '@/components/NowLine';
|
import { NowLine } from '@/components/NowLine';
|
||||||
|
import { VolunteerAvatar } from '@/components/VolunteerAvatar';
|
||||||
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time';
|
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time';
|
||||||
import { useNowMinutes } from '@/lib/useNowMinutes';
|
import { useNowMinutes } from '@/lib/useNowMinutes';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
@@ -122,12 +123,14 @@ export function MobileLeadPage() {
|
|||||||
const volunteer = volunteersById.get(a.volunteer_id);
|
const volunteer = volunteersById.get(a.volunteer_id);
|
||||||
return {
|
return {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
|
volunteerId: a.volunteer_id,
|
||||||
name: volunteer
|
name: volunteer
|
||||||
? `${volunteer.first_name} ${volunteer.last_name}`
|
? `${volunteer.first_name} ${volunteer.last_name}`
|
||||||
: a.volunteer_id,
|
: a.volunteer_id,
|
||||||
initials: volunteer
|
initials: volunteer
|
||||||
? `${volunteer.first_name[0]}${volunteer.last_name[0]}`
|
? `${volunteer.first_name[0]}${volunteer.last_name[0]}`
|
||||||
: '?',
|
: '?',
|
||||||
|
hasPhoto: volunteer?.has_photo ?? false,
|
||||||
phone: volunteer?.phone ?? '',
|
phone: volunteer?.phone ?? '',
|
||||||
present: presence[a.id] === 'present',
|
present: presence[a.id] === 'present',
|
||||||
absent: presence[a.id] === 'absent',
|
absent: presence[a.id] === 'absent',
|
||||||
@@ -155,9 +158,12 @@ export function MobileLeadPage() {
|
|||||||
className="border-[3px] border-ink bg-cream shadow-brutal-sm"
|
className="border-[3px] border-ink bg-cream shadow-brutal-sm"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-[10px] px-[11px] py-[10px]">
|
<div className="flex items-center gap-[10px] px-[11px] py-[10px]">
|
||||||
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center border-[3px] border-ink bg-yellow font-display text-[13px] font-extrabold">
|
<VolunteerAvatar
|
||||||
{member.initials}
|
volunteerId={member.volunteerId}
|
||||||
</span>
|
hasPhoto={member.hasPhoto}
|
||||||
|
initials={member.initials}
|
||||||
|
className="h-[38px] w-[38px] text-[13px]"
|
||||||
|
/>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block font-display text-[17px] font-extrabold leading-tight">
|
<span className="block font-display text-[17px] font-extrabold leading-tight">
|
||||||
{member.name}
|
{member.name}
|
||||||
|
|||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { ApiError } from '@/api';
|
||||||
|
import { useFormLogoUrl, usePublicFormConfig, useSubmitPublicForm } from '@/api/hooks';
|
||||||
|
import type { PublicArtistOption } from '@/api/schema';
|
||||||
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
|
import { Btn, FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
import { ENV } from '@/lib/env';
|
||||||
|
import { longDayLabel } from '@/lib/time';
|
||||||
|
|
||||||
|
const MAX_POSITION_WISHES = 2;
|
||||||
|
|
||||||
|
// Charge le script hCaptcha une seule fois, quel que soit le nombre de
|
||||||
|
// montages de <HCaptchaWidget> (StrictMode, navigation aller-retour…).
|
||||||
|
let hcaptchaScriptPromise: Promise<void> | null = null;
|
||||||
|
function loadHCaptchaScript(): Promise<void> {
|
||||||
|
if (!hcaptchaScriptPromise) {
|
||||||
|
hcaptchaScriptPromise = new Promise((resolve) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://js.hcaptcha.com/1/api.js';
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.onload = () => resolve();
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return hcaptchaScriptPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Widget hCaptcha (rendu implicite) : le script scanne le DOM et rend les
|
||||||
|
* `.h-captcha` qu'il trouve. `onToken` reçoit le jeton une fois résolu. */
|
||||||
|
function HCaptchaWidget({ onToken }: { onToken: (token: string) => void }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const callbackName = useRef(`hcaptchaCallback${Math.random().toString(36).slice(2)}`);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const win = window as unknown as Record<string, unknown>;
|
||||||
|
const name = callbackName.current;
|
||||||
|
win[name] = onToken;
|
||||||
|
loadHCaptchaScript();
|
||||||
|
return () => {
|
||||||
|
delete win[name];
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="h-captcha"
|
||||||
|
data-sitekey={ENV.hcaptchaSiteKey}
|
||||||
|
data-callback={callbackName.current}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taille max du côté exporté (px) : assez grand pour rester net sur la
|
||||||
|
// vignette imprimée (badges, feuilles de poste), sans gonfler l'upload avec
|
||||||
|
// la pleine résolution d'une photo de smartphone.
|
||||||
|
const PHOTO_EXPORT_SIDE_PX = 1024;
|
||||||
|
|
||||||
|
/** Recadre au carré (centré, tronque le surplus) et réencode en JPEG — le
|
||||||
|
* format brut d'un appareil photo (HEIC, résolution énorme, ratio libre)
|
||||||
|
* n'est ni carré ni forcément accepté par le serveur (`image/png`/`image/jpeg`
|
||||||
|
* seulement, voir `app/api/forms.py`). Le carré obtenu est réutilisé tel
|
||||||
|
* quel par les badges et feuilles de poste, sans recadrage côté serveur. */
|
||||||
|
async function cropToSquareJpeg(file: File): Promise<File> {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
try {
|
||||||
|
const side = Math.min(bitmap.width, bitmap.height);
|
||||||
|
const exportSide = Math.min(side, PHOTO_EXPORT_SIDE_PX);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = exportSide;
|
||||||
|
canvas.height = exportSide;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('canvas 2d indisponible');
|
||||||
|
ctx.drawImage(
|
||||||
|
bitmap,
|
||||||
|
(bitmap.width - side) / 2,
|
||||||
|
(bitmap.height - side) / 2,
|
||||||
|
side,
|
||||||
|
side,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
exportSide,
|
||||||
|
exportSide,
|
||||||
|
);
|
||||||
|
const blob: Blob = await new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(b) => (b ? resolve(b) : reject(new Error('recadrage impossible'))),
|
||||||
|
'image/jpeg',
|
||||||
|
0.9,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return new File([blob], 'photo.jpg', { type: 'image/jpeg' });
|
||||||
|
} finally {
|
||||||
|
bitmap.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
if (error instanceof ApiError) return t(error.code);
|
||||||
|
return t('error.generic');
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupArtistsByDay(artists: PublicArtistOption[]): Array<[string, PublicArtistOption[]]> {
|
||||||
|
const byDay = new Map<string, PublicArtistOption[]>();
|
||||||
|
for (const artist of artists) {
|
||||||
|
const list = byDay.get(artist.performance_date) ?? [];
|
||||||
|
list.push(artist);
|
||||||
|
byDay.set(artist.performance_date, list);
|
||||||
|
}
|
||||||
|
return [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormLogo({ url }: { url: string | null }) {
|
||||||
|
if (!url) return null;
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
className="mx-auto h-[120px] border-2 border-ink bg-cream object-contain shadow-brutal-sm"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublicSignupPage() {
|
||||||
|
const config = usePublicFormConfig();
|
||||||
|
const submit = useSubmitPublicForm();
|
||||||
|
const logoUrl = useFormLogoUrl(config.data?.has_logo ?? false, config.dataUpdatedAt);
|
||||||
|
|
||||||
|
const [firstName, setFirstName] = useState('');
|
||||||
|
const [lastName, setLastName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
const [companionNote, setCompanionNote] = useState('');
|
||||||
|
const [available, setAvailable] = useState(false);
|
||||||
|
const [willingToPayFee, setWillingToPayFee] = useState(false);
|
||||||
|
const [positionIds, setPositionIds] = useState<string[]>([]);
|
||||||
|
const [artistByDay, setArtistByDay] = useState<Record<string, string>>({});
|
||||||
|
const [photo, setPhoto] = useState<File | null>(null);
|
||||||
|
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [captchaToken, setCaptchaToken] = useState('');
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [volunteerId, setVolunteerId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const artistsByDay = useMemo(
|
||||||
|
() => groupArtistsByDay(config.data?.artists ?? []),
|
||||||
|
[config.data?.artists],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Révoque l'URL de l'aperçu précédent à chaque changement, et à la
|
||||||
|
// destruction du composant.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (photoPreviewUrl) URL.revokeObjectURL(photoPreviewUrl);
|
||||||
|
};
|
||||||
|
}, [photoPreviewUrl]);
|
||||||
|
|
||||||
|
const handlePhotoChange = async (file: File | null) => {
|
||||||
|
if (!file) {
|
||||||
|
setPhoto(null);
|
||||||
|
setPhotoPreviewUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const squared = await cropToSquareJpeg(file).catch(() => file);
|
||||||
|
setPhoto(squared);
|
||||||
|
setPhotoPreviewUrl(URL.createObjectURL(squared));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.isLoading) return <Loading />;
|
||||||
|
if (config.error) return <ErrorNote error={config.error} />;
|
||||||
|
if (!config.data) return null;
|
||||||
|
|
||||||
|
if (volunteerId) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||||
|
<FormLogo url={logoUrl} />
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader>{t('publicForm.success.title')}</PanelHeader>
|
||||||
|
<p className="p-4 font-mono text-[12px] leading-relaxed">{t('publicForm.success.body')}</p>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.data.is_open) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||||
|
<FormLogo url={logoUrl} />
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader>{t('publicForm.closed.title')}</PanelHeader>
|
||||||
|
<p className="p-4 font-mono text-[12px] leading-relaxed">{t('publicForm.closed.body')}</p>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = new Set(config.data.fields);
|
||||||
|
const toggleEnabled = ENV.hcaptchaSiteKey.length > 0;
|
||||||
|
|
||||||
|
const togglePosition = (id: string) => {
|
||||||
|
setPositionIds((prev) => {
|
||||||
|
if (prev.includes(id)) return prev.filter((x) => x !== id);
|
||||||
|
if (prev.length >= MAX_POSITION_WISHES) return prev;
|
||||||
|
return [...prev, id];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickArtistForDay = (day: string, artistId: string) => {
|
||||||
|
setArtistByDay((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (!artistId) delete next[day];
|
||||||
|
else next[day] = artistId;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
firstName.trim().length > 0 &&
|
||||||
|
lastName.trim().length > 0 &&
|
||||||
|
(toggleEnabled ? captchaToken.length > 0 : true) &&
|
||||||
|
!submit.isPending;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitError(null);
|
||||||
|
try {
|
||||||
|
const result = await submit.mutateAsync({
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
hcaptcha_token: captchaToken || 'dev',
|
||||||
|
email: fields.has('EMAIL') ? email : undefined,
|
||||||
|
phone: fields.has('PHONE') ? phone : undefined,
|
||||||
|
comment: fields.has('COMMENT') ? comment : undefined,
|
||||||
|
companion_wish_note: fields.has('COMPANION_WISH') ? companionNote : undefined,
|
||||||
|
available: fields.has('AVAILABLE') ? available : undefined,
|
||||||
|
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE') ? willingToPayFee : undefined,
|
||||||
|
position_wish_ids: fields.has('POSITION_WISHES') ? positionIds : undefined,
|
||||||
|
artist_wish_ids: fields.has('ARTIST_WISHES') ? Object.values(artistByDay) : undefined,
|
||||||
|
photo: fields.has('PHOTO') ? photo : undefined,
|
||||||
|
});
|
||||||
|
setVolunteerId(result.volunteer_id);
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitError(errorMessage(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-[560px] flex-col gap-4 p-4">
|
||||||
|
<FormLogo url={logoUrl} />
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader>{t('publicForm.title')}</PanelHeader>
|
||||||
|
{config.data.description ? (
|
||||||
|
<p className="whitespace-pre-line border-b-2 border-ink bg-cream p-4 font-mono text-[12px] leading-relaxed">
|
||||||
|
{config.data.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 p-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('config.vol.field.firstName')}</FieldLabel>
|
||||||
|
<TextInput value={firstName} onChange={setFirstName} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('config.vol.field.lastName')}</FieldLabel>
|
||||||
|
<TextInput value={lastName} onChange={setLastName} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fields.has('EMAIL') ? (
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('form.field.email')}</FieldLabel>
|
||||||
|
<TextInput mono value={email} onChange={setEmail} />
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fields.has('PHONE') ? (
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('form.field.phone')}</FieldLabel>
|
||||||
|
<TextInput mono value={phone} onChange={setPhone} />
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fields.has('POSITION_WISHES') && config.data.positions.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<FieldLabel>
|
||||||
|
{t('form.field.positionWishes')} — {t('publicForm.positionWishes.count', {
|
||||||
|
count: positionIds.length,
|
||||||
|
max: MAX_POSITION_WISHES,
|
||||||
|
})}
|
||||||
|
</FieldLabel>
|
||||||
|
<div className="flex flex-col gap-[6px] border-2 border-ink bg-cream p-[9px]">
|
||||||
|
{config.data.positions.map((p) => {
|
||||||
|
const checked = positionIds.includes(p.id);
|
||||||
|
const disabled = !checked && positionIds.length >= MAX_POSITION_WISHES;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={p.id}
|
||||||
|
className={`flex items-center gap-[8px] font-mono text-[12px] ${disabled ? 'opacity-40' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={() => togglePosition(p.id)}
|
||||||
|
className="h-[15px] w-[15px] accent-ink"
|
||||||
|
/>
|
||||||
|
{p.name}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fields.has('ARTIST_WISHES') && artistsByDay.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<FieldLabel>{t('form.field.artistWishes')}</FieldLabel>
|
||||||
|
<div className="flex flex-col gap-[8px]">
|
||||||
|
{artistsByDay.map(([day, artists]) => (
|
||||||
|
<label key={day} className="block">
|
||||||
|
<span className="mb-[3px] block font-mono text-[10px] opacity-60">
|
||||||
|
{longDayLabel(`${day}T12:00`)}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={artistByDay[day] ?? ''}
|
||||||
|
onChange={(e) => pickArtistForDay(day, e.target.value)}
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[8px] font-mono text-[12px]"
|
||||||
|
>
|
||||||
|
<option value="">{t('publicForm.artistWishes.none')}</option>
|
||||||
|
{artists.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fields.has('COMPANION_WISH') ? (
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('form.field.companionWish')}</FieldLabel>
|
||||||
|
<TextInput value={companionNote} onChange={setCompanionNote} />
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fields.has('COMMENT') ? (
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('form.field.comment')}</FieldLabel>
|
||||||
|
<textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[9px] font-mono text-[12px]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fields.has('AVAILABLE') ? (
|
||||||
|
<label className="flex cursor-pointer items-center gap-[8px]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={available}
|
||||||
|
onChange={(e) => setAvailable(e.target.checked)}
|
||||||
|
className="h-[16px] w-[16px] accent-ink"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-[12px]">{t('publicForm.field.available')}</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fields.has('WILLING_TO_PAY_FEE') ? (
|
||||||
|
<label className="flex cursor-pointer items-center gap-[8px]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={willingToPayFee}
|
||||||
|
onChange={(e) => setWillingToPayFee(e.target.checked)}
|
||||||
|
className="h-[16px] w-[16px] accent-ink"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-[12px]">{t('publicForm.field.willingToPayFee')}</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fields.has('WILLING_TO_PAY_FEE') && config.data.yapla_url ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-[10px] border-2 border-ink bg-yellow-soft px-[12px] py-[10px]">
|
||||||
|
<span className="min-w-0 flex-1 font-mono text-[11px] leading-relaxed">
|
||||||
|
{t('publicForm.yapla.hint')}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={config.data.yapla_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex flex-none cursor-pointer items-center justify-center gap-2 border-2 border-ink bg-yellow px-3 py-2 font-display text-[11px] font-extrabold uppercase tracking-wide shadow-brutal-sm hover:bg-green hover:text-white"
|
||||||
|
>
|
||||||
|
{t('publicForm.yapla.cta')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fields.has('PHOTO') ? (
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('form.field.photo')}</FieldLabel>
|
||||||
|
{photoPreviewUrl ? (
|
||||||
|
<img
|
||||||
|
src={photoPreviewUrl}
|
||||||
|
alt=""
|
||||||
|
className="mb-[8px] h-[180px] w-[180px] border-2 border-ink object-cover shadow-brutal-sm"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
onChange={(e) => handlePhotoChange(e.target.files?.[0] ?? null)}
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[8px] font-mono text-[11px]"
|
||||||
|
/>
|
||||||
|
<p className="mt-[4px] font-mono text-[9.5px] opacity-50">
|
||||||
|
{t('publicForm.photo.squareHint')}
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{toggleEnabled ? (
|
||||||
|
<HCaptchaWidget onToken={setCaptchaToken} />
|
||||||
|
) : (
|
||||||
|
<p className="font-mono text-[9.5px] opacity-50">{t('publicForm.captcha.disabled')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submitError ? (
|
||||||
|
<p className="font-mono text-[11px] font-bold text-orange-dark">{submitError}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Btn type="submit" disabled={!canSubmit}>
|
||||||
|
{submit.isPending ? t('publicForm.submitting') : t('publicForm.submit')}
|
||||||
|
</Btn>
|
||||||
|
</form>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Fragment, useMemo } from 'react';
|
|||||||
import { useVolunteer, useVolunteerPlanning } from '@/api/hooks';
|
import { useVolunteer, useVolunteerPlanning } from '@/api/hooks';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
import { NowLine } from '@/components/NowLine';
|
import { NowLine } from '@/components/NowLine';
|
||||||
|
import { VolunteerAvatar } from '@/components/VolunteerAvatar';
|
||||||
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time';
|
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time';
|
||||||
import { useNowMinutes } from '@/lib/useNowMinutes';
|
import { useNowMinutes } from '@/lib/useNowMinutes';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
@@ -39,6 +40,9 @@ export function VolunteerHomeView({
|
|||||||
if (planning.error) return <ErrorNote error={planning.error} />;
|
if (planning.error) return <ErrorNote error={planning.error} />;
|
||||||
|
|
||||||
const firstName = meVolunteer.data?.first_name ?? fallbackName ?? '';
|
const firstName = meVolunteer.data?.first_name ?? fallbackName ?? '';
|
||||||
|
const initials = meVolunteer.data
|
||||||
|
? `${meVolunteer.data.first_name[0]}${meVolunteer.data.last_name[0]}`
|
||||||
|
: (firstName[0] ?? '?');
|
||||||
|
|
||||||
const totalHours =
|
const totalHours =
|
||||||
Math.round(
|
Math.round(
|
||||||
@@ -55,10 +59,20 @@ export function VolunteerHomeView({
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
|
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
|
||||||
<div className="bg-ink px-4 py-5 text-cream sm:px-6 sm:py-7">
|
<div className="bg-ink px-4 py-5 text-cream sm:px-6 sm:py-7">
|
||||||
|
<div className="flex items-center gap-[12px]">
|
||||||
|
<VolunteerAvatar
|
||||||
|
volunteerId={volunteerId}
|
||||||
|
hasPhoto={meVolunteer.data?.has_photo ?? false}
|
||||||
|
initials={initials}
|
||||||
|
className="h-[52px] w-[52px] text-[18px]"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
|
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
|
||||||
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]">
|
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]">
|
||||||
{t('mobileVol.greeting', { name: firstName.toUpperCase() })}
|
{t('mobileVol.greeting', { name: firstName.toUpperCase() })}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
|
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
|
||||||
{t('mobileVol.slots', { count: slots.length })}
|
{t('mobileVol.slots', { count: slots.length })}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
|
import { useAssignments, useDayParts, usePositions, useShifts, useVolunteers } from '@/api/hooks';
|
||||||
import type { AssignmentRead, ShiftRead, VolunteerRead } from '@/api/schema';
|
import type { AssignmentRead, ShiftRead, VolunteerRead } from '@/api/schema';
|
||||||
import { Chip, HelpTip } from '@/components/ui';
|
import { Chip, HelpTip } from '@/components/ui';
|
||||||
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
||||||
import { buildLattice, hoursByVolunteer, isOverloaded } from '@/lib/planning';
|
import { buildLattice, dayBoundaryFromDayParts, hoursByVolunteer, isOverloaded } from '@/lib/planning';
|
||||||
import { datePart, shortDayLabel, timePart } from '@/lib/time';
|
import { datePart, shortDayLabel, timePart } from '@/lib/time';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
import { cx } from '@/lib/cx';
|
import { cx } from '@/lib/cx';
|
||||||
@@ -16,6 +16,7 @@ export function VolunteerViewPage() {
|
|||||||
const assignments = useAssignments();
|
const assignments = useAssignments();
|
||||||
const shifts = useShifts();
|
const shifts = useShifts();
|
||||||
const positions = usePositions();
|
const positions = usePositions();
|
||||||
|
const dayParts = useDayParts();
|
||||||
const [filter, setFilter] = useState<Filter>('all');
|
const [filter, setFilter] = useState<Filter>('all');
|
||||||
|
|
||||||
const shiftsById = useMemo(
|
const shiftsById = useMemo(
|
||||||
@@ -26,9 +27,10 @@ export function VolunteerViewPage() {
|
|||||||
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
|
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
|
||||||
[positions.data],
|
[positions.data],
|
||||||
);
|
);
|
||||||
|
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
|
||||||
const lattice = useMemo(
|
const lattice = useMemo(
|
||||||
() => buildLattice(shifts.data ?? []),
|
() => buildLattice(shifts.data ?? [], dayBoundary),
|
||||||
[shifts.data],
|
[shifts.data, dayBoundary],
|
||||||
);
|
);
|
||||||
const hours = useMemo(
|
const hours = useMemo(
|
||||||
() => hoursByVolunteer(assignments.data ?? [], shiftsById),
|
() => hoursByVolunteer(assignments.data ?? [], shiftsById),
|
||||||
@@ -137,7 +139,7 @@ export function VolunteerViewPage() {
|
|||||||
{t('volunteers.totalHours')}
|
{t('volunteers.totalHours')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sticky left-0 top-[27px] z-[7] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px] opacity-60">
|
<div className="sticky left-0 top-[27px] z-[7] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px]">
|
||||||
{t('volunteers.name')}
|
{t('volunteers.name')}
|
||||||
</div>
|
</div>
|
||||||
{lattice.columns.map((column) => (
|
{lattice.columns.map((column) => (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
useCreatePosition,
|
useCreatePosition,
|
||||||
|
useCreateShift,
|
||||||
useDeletePosition,
|
useDeletePosition,
|
||||||
useDeleteShift,
|
useDeleteShift,
|
||||||
useEdition,
|
useEdition,
|
||||||
@@ -15,7 +16,7 @@ import type { PositionCategory, PositionRead, ShiftRead } from '@/api/schema';
|
|||||||
import { ErrorNote, Loading } from '@/components/state';
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
import { Btn, HelpTip } from '@/components/ui';
|
import { Btn, HelpTip } from '@/components/ui';
|
||||||
import { categoryLabel } from '@/lib/planning';
|
import { categoryLabel } from '@/lib/planning';
|
||||||
import { hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time';
|
import { addDays, hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
import { cx } from '@/lib/cx';
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
@@ -143,60 +144,16 @@ export function ConfigGrid() {
|
|||||||
{updateShift.isError ? <ErrorNote error={updateShift.error} /> : null}
|
{updateShift.isError ? <ErrorNote error={updateShift.error} /> : null}
|
||||||
|
|
||||||
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(300px,1fr))]">
|
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(300px,1fr))]">
|
||||||
{days.map((day) => {
|
{days.map((day) => (
|
||||||
const shifts = shiftsByDay.get(day) ?? [];
|
<DayCard
|
||||||
return (
|
key={day}
|
||||||
<div key={day} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
day={day}
|
||||||
<div className="bg-ink px-[10px] py-[7px] font-display text-[13px] font-black text-cream">
|
positionId={position.id}
|
||||||
{longDayLabel(`${day}T12:00`)}
|
shifts={shiftsByDay.get(day) ?? []}
|
||||||
</div>
|
updateShift={updateShift}
|
||||||
{shifts.map((shift) => (
|
deleteShift={deleteShift}
|
||||||
<div
|
|
||||||
key={shift.id}
|
|
||||||
className="flex flex-wrap items-center gap-2 border-b border-sand-line px-[9px] py-[7px]"
|
|
||||||
>
|
|
||||||
<span className="w-[92px] flex-none font-mono text-[11px]">
|
|
||||||
{hourRangeLabel(shift.starts_at, shift.ends_at)}
|
|
||||||
</span>
|
|
||||||
<Stepper
|
|
||||||
label={t('config.grid.min')}
|
|
||||||
value={shift.min_staff}
|
|
||||||
onChange={(delta) =>
|
|
||||||
updateShift.mutate({
|
|
||||||
id: shift.id,
|
|
||||||
body: { min_staff: Math.max(0, shift.min_staff + delta) },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Stepper
|
|
||||||
label={t('config.grid.ideal')}
|
|
||||||
accent
|
|
||||||
value={shift.ideal_staff}
|
|
||||||
onChange={(delta) =>
|
|
||||||
updateShift.mutate({
|
|
||||||
id: shift.id,
|
|
||||||
body: { ideal_staff: Math.max(0, shift.ideal_staff + delta) },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => deleteShift.mutate(shift.id)}
|
|
||||||
className="ml-auto h-[22px] w-[22px] cursor-pointer border-2 border-ink bg-paper font-display text-[10px] font-bold hover:bg-orange"
|
|
||||||
aria-label={t('action.remove')}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
{shifts.length === 0 ? (
|
|
||||||
<div className="px-[9px] py-[10px] font-mono text-[10px] opacity-55">
|
|
||||||
{t('config.grid.emptyDay')}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -285,6 +242,111 @@ function PositionHeader({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DayCard({
|
||||||
|
day,
|
||||||
|
positionId,
|
||||||
|
shifts,
|
||||||
|
updateShift,
|
||||||
|
deleteShift,
|
||||||
|
}: {
|
||||||
|
day: string;
|
||||||
|
positionId: string;
|
||||||
|
shifts: ShiftRead[];
|
||||||
|
updateShift: ReturnType<typeof useUpdateShift>;
|
||||||
|
deleteShift: ReturnType<typeof useDeleteShift>;
|
||||||
|
}) {
|
||||||
|
const createShift = useCreateShift();
|
||||||
|
const [start, setStart] = useState('');
|
||||||
|
const [end, setEnd] = useState('');
|
||||||
|
|
||||||
|
const addShift = () => {
|
||||||
|
if (!start || !end) return;
|
||||||
|
const endDay = end <= start ? addDays(day, 1) : day;
|
||||||
|
createShift.mutate(
|
||||||
|
{ position_id: positionId, starts_at: `${day}T${start}:00`, ends_at: `${endDay}T${end}:00` },
|
||||||
|
{ onSuccess: () => { setStart(''); setEnd(''); } },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||||
|
<div className="bg-ink px-[10px] py-[7px] font-display text-[13px] font-black text-cream">
|
||||||
|
{longDayLabel(`${day}T12:00`)}
|
||||||
|
</div>
|
||||||
|
{shifts.map((shift) => (
|
||||||
|
<div
|
||||||
|
key={shift.id}
|
||||||
|
className="flex flex-wrap items-center gap-2 border-b border-sand-line px-[9px] py-[7px]"
|
||||||
|
>
|
||||||
|
<span className="w-[92px] flex-none font-mono text-[11px]">
|
||||||
|
{hourRangeLabel(shift.starts_at, shift.ends_at)}
|
||||||
|
</span>
|
||||||
|
<Stepper
|
||||||
|
label={t('config.grid.min')}
|
||||||
|
value={shift.min_staff}
|
||||||
|
onChange={(delta) =>
|
||||||
|
updateShift.mutate({
|
||||||
|
id: shift.id,
|
||||||
|
body: { min_staff: Math.max(0, shift.min_staff + delta) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stepper
|
||||||
|
label={t('config.grid.ideal')}
|
||||||
|
accent
|
||||||
|
value={shift.ideal_staff}
|
||||||
|
onChange={(delta) =>
|
||||||
|
updateShift.mutate({
|
||||||
|
id: shift.id,
|
||||||
|
body: { ideal_staff: Math.max(0, shift.ideal_staff + delta) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteShift.mutate(shift.id)}
|
||||||
|
className="ml-auto h-[22px] w-[22px] cursor-pointer border-2 border-ink bg-paper font-display text-[10px] font-bold hover:bg-orange"
|
||||||
|
aria-label={t('action.remove')}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{shifts.length === 0 ? (
|
||||||
|
<div className="px-[9px] py-[10px] font-mono text-[10px] opacity-55">
|
||||||
|
{t('config.grid.emptyDay')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap items-center gap-[6px] border-t-2 border-ink bg-sand px-[9px] py-[7px]">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={start}
|
||||||
|
onChange={(e) => setStart(e.target.value)}
|
||||||
|
aria-label={t('config.grid.shiftStart')}
|
||||||
|
className="border-2 border-ink bg-paper px-[6px] py-[3px] font-mono text-[11px]"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-[11px] opacity-60">→</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={end}
|
||||||
|
onChange={(e) => setEnd(e.target.value)}
|
||||||
|
aria-label={t('config.grid.shiftEnd')}
|
||||||
|
className="border-2 border-ink bg-paper px-[6px] py-[3px] font-mono text-[11px]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!start || !end || createShift.isPending}
|
||||||
|
onClick={addShift}
|
||||||
|
className="ml-auto cursor-pointer border-2 border-ink bg-cream px-[8px] py-[4px] font-display text-[9.5px] font-extrabold uppercase hover:bg-yellow disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('config.grid.addShift')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{createShift.isError ? <ErrorNote error={createShift.error} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function groupByDay(shifts: ShiftRead[]): Map<string, ShiftRead[]> {
|
function groupByDay(shifts: ShiftRead[]): Map<string, ShiftRead[]> {
|
||||||
const map = new Map<string, ShiftRead[]>();
|
const map = new Map<string, ShiftRead[]>();
|
||||||
for (const s of [...shifts].sort((a, b) => a.starts_at.localeCompare(b.starts_at))) {
|
for (const s of [...shifts].sort((a, b) => a.starts_at.localeCompare(b.starts_at))) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
useUpdatePosition,
|
useUpdatePosition,
|
||||||
useUpdateVolunteer,
|
useUpdateVolunteer,
|
||||||
useVolunteerAvailability,
|
useVolunteerAvailability,
|
||||||
|
useVolunteerPhotoUrl,
|
||||||
useVolunteers,
|
useVolunteers,
|
||||||
} from '@/api/hooks';
|
} from '@/api/hooks';
|
||||||
import type {
|
import type {
|
||||||
@@ -30,6 +31,7 @@ import type {
|
|||||||
PositionRead,
|
PositionRead,
|
||||||
VolunteerCreate,
|
VolunteerCreate,
|
||||||
VolunteerRead,
|
VolunteerRead,
|
||||||
|
VolunteerUpdate,
|
||||||
} from '@/api/schema';
|
} from '@/api/schema';
|
||||||
import { Chip, FieldLabel, HelpTip, TextInput } from '@/components/ui';
|
import { Chip, FieldLabel, HelpTip, TextInput } from '@/components/ui';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
@@ -158,6 +160,79 @@ function VolunteerAccessSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponses du formulaire public d'inscription (voir `app/api/forms.py`) —
|
||||||
|
* note de compagnon en texte libre (à résoudre manuellement en vrai vœu via
|
||||||
|
* le sélecteur ci-dessus une fois la personne visée elle-même inscrite),
|
||||||
|
* cases informatives, photo. N'apparaît que si le bénévole vient
|
||||||
|
* effectivement de ce formulaire (sinon rien à afficher).
|
||||||
|
*/
|
||||||
|
function PublicFormAnswers({
|
||||||
|
volunteer,
|
||||||
|
patch,
|
||||||
|
}: {
|
||||||
|
volunteer: VolunteerRead;
|
||||||
|
patch: (body: VolunteerUpdate) => void;
|
||||||
|
}) {
|
||||||
|
const photoUrl = useVolunteerPhotoUrl(volunteer.id, volunteer.has_photo);
|
||||||
|
|
||||||
|
const hasAnything =
|
||||||
|
volunteer.has_photo ||
|
||||||
|
Boolean(volunteer.companion_wish_note) ||
|
||||||
|
volunteer.self_declared_available ||
|
||||||
|
volunteer.willing_to_pay_fee;
|
||||||
|
if (!hasAnything) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 border-2 border-ink bg-sand p-[9px]">
|
||||||
|
<FieldLabel>
|
||||||
|
{t('config.vol.publicForm.title')} <HelpTip text={t('config.vol.publicForm.hint')} />
|
||||||
|
</FieldLabel>
|
||||||
|
|
||||||
|
{photoUrl ? (
|
||||||
|
<img
|
||||||
|
src={photoUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-[90px] w-[90px] flex-none border-2 border-ink object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{volunteer.companion_wish_note ? (
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('form.field.companionWish')}</FieldLabel>
|
||||||
|
<textarea
|
||||||
|
value={volunteer.companion_wish_note}
|
||||||
|
onChange={(e) => patch({ companion_wish_note: e.target.value || null })}
|
||||||
|
rows={2}
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-[14px]">
|
||||||
|
<label className="flex cursor-pointer items-center gap-[7px]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={volunteer.self_declared_available}
|
||||||
|
onChange={(e) => patch({ self_declared_available: e.target.checked })}
|
||||||
|
className="h-[15px] w-[15px] accent-ink"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-[11px]">{t('form.field.available')}</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex cursor-pointer items-center gap-[7px]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={volunteer.willing_to_pay_fee}
|
||||||
|
onChange={(e) => patch({ willing_to_pay_fee: e.target.checked })}
|
||||||
|
className="h-[15px] w-[15px] accent-ink"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-[11px]">{t('form.field.willingToPayFee')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Postes dont ce bénévole est référent (`Position.lead_account_id`). Cocher
|
* Postes dont ce bénévole est référent (`Position.lead_account_id`). Cocher
|
||||||
* un poste crée ou met à niveau son accès de connexion en RÉFÉRENT (LEAD) —
|
* un poste crée ou met à niveau son accès de connexion en RÉFÉRENT (LEAD) —
|
||||||
@@ -638,6 +713,8 @@ export function ConfigVolunteers() {
|
|||||||
searchPlaceholder={t('config.vol.searchCompanion')}
|
searchPlaceholder={t('config.vol.searchCompanion')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PublicFormAnswers volunteer={selected} patch={patch} />
|
||||||
|
|
||||||
<AvailabilityEditor volunteerId={selected.id} />
|
<AvailabilityEditor volunteerId={selected.id} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user