Compare commits
28
Commits
ff57bbd863
...
main
+362
@@ -0,0 +1,362 @@
|
||||
# BLUEPRINT — Socle réutilisable
|
||||
|
||||
Ce fichier capture la **philosophie, la stack, l'architecture, le style et
|
||||
les conventions** de l'application « Gestion des bénévoles », débarrassés de
|
||||
son domaine métier (festival, créneaux, cotisations…). Il sert de point de
|
||||
départ pour une nouvelle application construite sur les mêmes bases.
|
||||
|
||||
**Mode d'emploi pour une nouvelle appli :**
|
||||
|
||||
1. Copier ce fichier dans le nouveau dépôt.
|
||||
2. Le donner à Claude avec le brief métier : « Crée l'appli X sur les bases
|
||||
de `BLUEPRINT.md` ».
|
||||
3. La première étape produit un `CLAUDE.md` propre au nouveau projet (même
|
||||
plan que celui de `benevoles` : glossaire, modèle, règles, périmètre,
|
||||
arborescence, commandes, conventions, déploiement, journal des étapes),
|
||||
qui devient ensuite le contrat de travail.
|
||||
|
||||
Les passages marqués **⟨à adapter⟩** dépendent du métier ; tout le reste est
|
||||
le socle à reprendre tel quel.
|
||||
|
||||
---
|
||||
|
||||
## 1. Philosophie
|
||||
|
||||
- **Un outil pour une petite organisation réelle** (association, 50–200
|
||||
personnes), pas une plateforme générique. On résout le besoin concret,
|
||||
sans sur-ingénierie, mais avec un vrai niveau de qualité (tests, sécurité,
|
||||
RGPD, déploiement automatisé).
|
||||
- **Le métier parle français, le code parle anglais.** Un glossaire
|
||||
FR ↔ EN fige les noms une fois pour toutes ; aucun nom métier français
|
||||
dans le code, aucun texte d'interface en dur nulle part.
|
||||
- **`CLAUDE.md` est le contrat de travail.** Il décrit le modèle, les règles,
|
||||
les choix et leurs *raisons*. Il est mis à jour **à chaque étape**, en même
|
||||
temps que le code. Quand un bug est corrigé, la cause et la règle qui
|
||||
l'empêche de revenir y sont notées (« bug corrigé une fois — faire Y
|
||||
recréerait la même classe de bug »).
|
||||
- **Étapes courtes et validées.** Une étape = une fonctionnalité cohérente,
|
||||
montrée à l'utilisateur, puis validée avant de continuer. Un commit atomique
|
||||
par étape, message en français. Le journal des étapes (§ 10 du `CLAUDE.md`)
|
||||
garde l'historique des décisions.
|
||||
- **Le cœur métier est pur et testé d'abord.** Les règles critiques vivent
|
||||
dans un module isolé, sans base de données ni HTTP (fonction pure :
|
||||
entrée typée → résultat typé), écrit en TDD : un cas passant + un cas
|
||||
déclenchant par règle. Un `loader.py` est le seul point de contact avec la
|
||||
base.
|
||||
- **L'outil assiste, l'humain décide.** Les calculs « intelligents »
|
||||
(solveur, suggestions) proposent ; ils ne persistent jamais rien
|
||||
d'eux-mêmes. La validation reste seule juge au moment de l'écriture.
|
||||
- **Erreurs = codes, pas phrases.** Le backend renvoie
|
||||
`{"detail": {"code": "domaine.raison", ...params}}` ; la traduction se
|
||||
fait côté locales. Les violations de règles sont typées (`BLOCKING` /
|
||||
`WARNING`) avec des paramètres structurés.
|
||||
- **Simplicité assumée et documentée.** Quand on simplifie (ex. « vérifié
|
||||
seulement contre l'existant, pas entre deux suggestions »), on l'écrit
|
||||
explicitement comme simplification assumée.
|
||||
- **Retirer est une fonctionnalité.** On n'hésite pas à supprimer un concept
|
||||
devenu inutile (table, règle, écran) — migration, API, front et doc
|
||||
nettoyés ensemble, plutôt que de laisser du code mort.
|
||||
- **Aucune dépendance locale hors Docker.** Tout se lance, se teste et se
|
||||
migre via `docker compose`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Stack technique
|
||||
|
||||
### Backend
|
||||
|
||||
| Rôle | Choix |
|
||||
|------|-------|
|
||||
| Langage | Python 3.12 |
|
||||
| Gestion des dépendances | `uv` (`pyproject.toml`, groupe `dev`) |
|
||||
| API | FastAPI + Uvicorn |
|
||||
| ORM / schémas | SQLModel (SQLAlchemy + Pydantic) |
|
||||
| Base | PostgreSQL 16 (driver `psycopg[binary]` v3) |
|
||||
| Migrations | Alembic (autogenerate + `alembic check` pour vérifier modèle ↔ migrations) |
|
||||
| Configuration | `pydantic-settings` (`app/config.py`, valeurs réelles dans `.env` non versionné) |
|
||||
| Auth | `pyjwt` (HS256) + `bcrypt` |
|
||||
| Tests | `pytest` + `pytest-cov`, **vraie base PostgreSQL de test** |
|
||||
| Lint / format | `ruff` (line-length 100, règles `E F I UP B`) |
|
||||
| Outils ponctuels | `openpyxl` (import/export Excel), `reportlab` + `pypdf` (PDF), `pillow` (validation d'images), `httpx` (API tierces), `ortools` (optimisation) — **⟨à adapter⟩** selon le besoin |
|
||||
|
||||
### Frontend
|
||||
|
||||
| Rôle | Choix |
|
||||
|------|-------|
|
||||
| Framework | React 18 + TypeScript (strict) |
|
||||
| Build | Vite 5 |
|
||||
| Style | Tailwind CSS 3 (thème étendu, aucune lib de composants) |
|
||||
| Données serveur | TanStack React Query 5 (hooks centralisés dans `src/api/hooks.ts`) |
|
||||
| Routing | React Router 6 |
|
||||
| Graphiques | SVG maison (`components/charts.tsx`), aucune bibliothèque |
|
||||
| Qualité | ESLint 9 + `tsc --noEmit` + script `smoke` (rendu headless via `tsx`) |
|
||||
|
||||
Dépendances runtime volontairement minimales : `react`, `react-dom`,
|
||||
`react-router-dom`, `@tanstack/react-query`. Rien d'autre sans bonne raison.
|
||||
|
||||
### Infra
|
||||
|
||||
- Docker Compose en dev (db + backend en hot reload + frontend Vite).
|
||||
- Production : images `backend` (uvicorn) et `frontend` (build statique
|
||||
servi par nginx), ports exposés uniquement sur `127.0.0.1`, Caddy du VPS en
|
||||
reverse proxy HTTPS (`/api/*` → backend avec préfixe retiré, le reste →
|
||||
frontend).
|
||||
- Dépôt Gitea auto-hébergé + Gitea Actions (`act_runner`) : tests → build des
|
||||
images (registre Gitea) → SSH sur le VPS → `pull` → `alembic upgrade head`
|
||||
→ `up -d`.
|
||||
- Fuseau horaire : `TZ=Europe/Paris` + `tzdata` dans le conteneur.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### Arborescence type
|
||||
|
||||
```
|
||||
.
|
||||
├── CLAUDE.md # contrat de travail (tenu à jour à chaque étape)
|
||||
├── README.md
|
||||
├── Makefile # make demo / make blank / make down / make logs
|
||||
├── docker-compose.yml # dev
|
||||
├── .env.example # toutes les variables, sans vraies valeurs
|
||||
├── .gitea/workflows/ci-cd.yml
|
||||
├── deploy/ # docker-compose.prod.yml, bloc Caddyfile, README de mise en service
|
||||
├── docker/postgres-init/ # crée la base de test au premier démarrage
|
||||
├── backend/
|
||||
│ ├── pyproject.toml · Dockerfile · alembic.ini
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # instance FastAPI, montage des routers
|
||||
│ │ ├── config.py # Settings (pydantic-settings), get_settings() en cache
|
||||
│ │ ├── db.py # engine + get_session
|
||||
│ │ ├── logging_config.py # logs sans PII + identifiant de requête
|
||||
│ │ ├── request_logging.py # middleware : une ligne par requête, X-Request-ID, 500 tracées
|
||||
│ │ ├── api/ # un router par ressource + schemas.py + deps.py
|
||||
│ │ ├── auth/ # passwords, magic_link, tokens (JWT), deps (rôles), rate_limit
|
||||
│ │ ├── email/ # sender (SMTP), templates (texte), html (rendu thémé), check (diagnostic)
|
||||
│ │ ├── models/ # enums.py, base.py (TimestampMixin, local_now), domain.py
|
||||
│ │ ├── validation/ # cœur métier pur : models.py, engine.py, loader.py
|
||||
│ │ ├── <module_métier>/ # même découpage pour chaque calcul isolé ⟨à adapter⟩
|
||||
│ │ ├── i18n/ # t(key, **params) + locales/fr.json
|
||||
│ │ ├── bootstrap.py # instance vierge : 1re entité racine + 1er compte admin
|
||||
│ │ ├── seed.py # jeu de démonstration réaliste et déterministe
|
||||
│ │ └── migrations/
|
||||
│ └── tests/ # conftest.py (vraie base), factories.py, test_<module>.py
|
||||
└── frontend/
|
||||
├── Dockerfile · Dockerfile.prod · nginx.conf
|
||||
├── tailwind.config.js # palette et primitives du design
|
||||
├── scripts/smoke.tsx # rendu headless + vérifications structurelles
|
||||
└── src/
|
||||
├── api/ # types.ts (interface Api), schema.ts (miroir des schémas backend),
|
||||
│ # http-client.ts, mock/ (même surface, en mémoire), hooks.ts, session.ts
|
||||
├── app/ # AppShell.tsx, router.tsx
|
||||
├── auth/ # auth-context, RequireAuth, roles (homePath par rôle)
|
||||
├── components/ # ui.tsx (primitives), state.tsx (chargement/erreur), charts.tsx
|
||||
├── i18n/ # t(key, params) + locales/fr.json
|
||||
├── lib/ # logique pure testable (calculs, dates, env, cx)
|
||||
└── pages/ # un écran par fichier, sous-dossiers si gros écran
|
||||
```
|
||||
|
||||
### Principes d'architecture backend
|
||||
|
||||
- **Multi-tenant par une entité racine** (ici `Edition`) : toute entité
|
||||
métier porte son `<racine>_id`, toutes les routes sont préfixées
|
||||
`/<racines>/{id}/...` et la dépendance `get_<racine>` renvoie 404
|
||||
`error.not_found` si elle n'existe pas. **⟨à adapter⟩** (organisation,
|
||||
projet, saison…).
|
||||
- **Clés primaires UUID**, champs d'audit `created_at`/`updated_at` sur
|
||||
toutes les tables sauf associations (`TimestampMixin`).
|
||||
- **Énumérations en `VARCHAR` + `CHECK`** (`native_enum=False`), jamais
|
||||
d'ENUM natif PostgreSQL (migrations plus simples).
|
||||
- **`ON DELETE CASCADE` au niveau base** sur les liens vers la racine et
|
||||
entre entités : la suppression RGPD d'une personne est un vrai *hard
|
||||
delete* qui emporte tout ce qui la concerne.
|
||||
- **Dates naïves en heure locale** (`local_now()`), interprétées
|
||||
Europe/Paris. Pas de mélange UTC/local.
|
||||
- **Modules isolés pour la logique non triviale** : `models.py` (types
|
||||
purs, dataclasses), `engine.py` (fonction pure), `loader.py` (seul accès
|
||||
base). Testés sans HTTP.
|
||||
- **Un router par ressource**, schémas Pydantic `XCreate` / `XRead` /
|
||||
`XUpdate` dans `api/schemas.py`. En `PATCH` : `None`/absent = inchangé,
|
||||
`[]` = vider une liste.
|
||||
- **Secrets jamais renvoyés** par l'API (`xxx_set: bool` à la place ;
|
||||
chaîne vide en écriture = effacer).
|
||||
- **Tâches de fond** (`BackgroundTasks`) pour les emails, en *best effort* :
|
||||
un échec SMTP est journalisé mais ne fait jamais échouer l'appel HTTP.
|
||||
- **Nettoyage paresseux** plutôt que tâches planifiées (purge des données
|
||||
périmées au passage d'une requête), tant que le volume le permet.
|
||||
- **Instance vierge et démo** : `bootstrap.py` (CLI, crée la racine + le
|
||||
1er compte) et `seed.py` (jeu réaliste avec des cas intéressants :
|
||||
trous, conflits, données de chaque fonctionnalité).
|
||||
|
||||
### Principes d'architecture frontend
|
||||
|
||||
- **Une interface `Api` unique, deux implémentations** : client HTTP réel
|
||||
et **mock en mémoire de même surface** (`VITE_API_MODE=mock|http`). Le
|
||||
front se développe et se démontre sans backend ; passer de l'un à l'autre
|
||||
ne change aucun écran. Le mock calcule les mêmes résultats que le backend.
|
||||
- **`schema.ts` = miroir de `backend/app/api/schemas.py`**, gardé
|
||||
synchrone à la main.
|
||||
- **Toutes les données serveur passent par React Query** (`hooks.ts`), jamais
|
||||
de `fetch` dans les pages.
|
||||
- **Logique pure dans `src/lib/`**, souvent miroir d'une fonction backend
|
||||
(ex. `festivalDay` ↔ `day_boundary`) : la règle est écrite à l'identique
|
||||
des deux côtés et le `CLAUDE.md` le signale.
|
||||
- **Auth** : JWT en `localStorage`, `Authorization: Bearer`, une 401
|
||||
déconnecte ; `<RequireAuth>` sur toutes les pages métier ; page d'accueil
|
||||
et onglets visibles selon le rôle.
|
||||
- **Variables Vite figées au build** (`VITE_*`) : l'image de prod est
|
||||
construite pour un environnement donné.
|
||||
- **Téléchargements** (PDF, Excel) : blob → `<a download>` généré côté
|
||||
client.
|
||||
- **Chaque erreur affiche sa référence** (`X-Request-ID`) sous le message,
|
||||
pour retrouver la ligne de log correspondante.
|
||||
|
||||
---
|
||||
|
||||
## 4. Sécurité et RGPD (non négociables)
|
||||
|
||||
- **Deux systèmes d'authentification, une même session JWT** :
|
||||
- mot de passe (bcrypt, ≥ 10 caractères) pour les administrateurs ;
|
||||
- **lien magique** (jeton aléatoire haché SHA-256 en base, usage unique,
|
||||
TTL court) pour les autres comptes ; réponse `202` constante, sans jamais
|
||||
révéler si un compte existe.
|
||||
- **Rôles** appliqués côté backend par endpoint, documentés dans une
|
||||
**matrice ressource × rôle** dans le `CLAUDE.md`. **⟨à adapter⟩**
|
||||
(ici `COORDINATOR` / `LEAD` / `VOLUNTEER`).
|
||||
- **Limiteurs de tentatives** en mémoire (connexion, lien magique,
|
||||
formulaires publics, sondages vers API tierces) — `429
|
||||
error.too_many_requests`.
|
||||
- **Démarrage refusé en production** avec un `JWT_SECRET` par défaut, vide
|
||||
ou trop court.
|
||||
- **Pages publiques protégées par jeton** dans l'URL (`secrets.token_urlsafe`,
|
||||
comparaison `secrets.compare_digest`, 404 générique), régénérable pour
|
||||
révoquer un lien qui a fuité ; hCaptcha optionnel (désactivé si la clé est
|
||||
absente).
|
||||
- **Fichiers importés revalidés** (décodage réel d'image avec PIL, pas
|
||||
seulement le `Content-Type`) ; exports Excel protégés contre l'injection de
|
||||
formule (CWE-1236).
|
||||
- **Aucune donnée nominative dans les logs** (ni nom, ni email, ni
|
||||
téléphone, ni sujet d'email personnalisable) : on logue des identifiants.
|
||||
Journal d'accès d'uvicorn **désactivé** (il écrivait les query strings,
|
||||
donc les jetons).
|
||||
- **Logs de diagnostic** : une ligne par requête (méthode, route *modèle*
|
||||
+ identifiants, statut, durée, code d'erreur métier, compte) ; WARNING en
|
||||
4xx, ERROR + trace complète en 5xx ; `X-Request-ID` propagé jusque dans les
|
||||
tâches de fond. Erreur 422 : emplacement + type, jamais la valeur saisie.
|
||||
- **Rétention paramétrable** des données nominatives sur l'entité racine ;
|
||||
endpoint de suppression complète d'une personne.
|
||||
- **En-têtes de sécurité** dans nginx et Caddy ; backend et frontend
|
||||
n'écoutent que sur `127.0.0.1`.
|
||||
- **Emails** : SMTP auto-hébergé, `EMAIL_ENABLED=false` en dev/tests (simple
|
||||
journalisation), `multipart/alternative` (texte brut = source de vérité
|
||||
des tests + HTML au thème de l'appli, tableaux et styles en ligne, logo en
|
||||
pièce jointe inline par Content-ID plutôt qu'une URL qui fuiterait un
|
||||
jeton). Commande de diagnostic SMTP dédiée.
|
||||
|
||||
---
|
||||
|
||||
## 5. Style visuel (design « néo-brutaliste »)
|
||||
|
||||
Identité forte, lisible, sans bibliothèque de composants : **bordures
|
||||
franches noires, ombres portées dures sans flou, libellés en capitales, fond
|
||||
crème, accents saturés**.
|
||||
|
||||
### Palette (`tailwind.config.js`)
|
||||
|
||||
| Jeton | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `cream` | `#F5EFE1` | fond général |
|
||||
| `ink` | `#14110E` | encre, bordures, ombres |
|
||||
| `paper` | `#FFFDF7` | panneaux, cartes |
|
||||
| `sand` / `sand-line` / `sand-dark` | `#EFE8D6` / `#E0D8C4` / `#E7E0CE` | barres secondaires, séparateurs |
|
||||
| `yellow` / `yellow-soft` / `yellow-hover` | `#FFD23F` / `#FFE68A` / `#FFDF77` | action principale, état incomplet |
|
||||
| `orange` / `orange-dark` | `#FF5C39` / `#E0421F` | urgence, danger |
|
||||
| `green` | `#0E9F6E` | succès, survol de l'action principale |
|
||||
| `blue` / `blue-strong` | `#A8C4FF` / `#2A6BFF` | information |
|
||||
| `mauve` / `mauve-hover` | `#CBA6E3` / `#BE93D9` | dépassement / surplus |
|
||||
|
||||
### Typographie
|
||||
|
||||
- `font-display` : **Archivo** (400 → 900), titres et boutons en
|
||||
`font-black`/`font-extrabold`, `uppercase`, `tracking-wide`, petites
|
||||
tailles (11–12 px).
|
||||
- `font-mono` : **DM Mono**, pour les heures, chiffres, codes.
|
||||
- Chargées depuis Google Fonts dans `index.html`.
|
||||
|
||||
### Primitives (`components/ui.tsx`)
|
||||
|
||||
- **Ombres** : `shadow-brutal-sm` (2 px), `brutal` (3 px), `brutal-md`
|
||||
(4 px), `brutal-lg` (6 px) — toutes `Npx Npx 0 #14110E`.
|
||||
- **`Panel`** : `border-[3px] border-ink bg-paper` + ombre portée.
|
||||
- **`PanelHeader`** : bandeau `bg-ink text-cream` en capitales (variante
|
||||
hachurée `bg-hatch-ink text-yellow`).
|
||||
- **`Btn`** : `border-2 border-ink`, capitales, variantes `solid` (jaune →
|
||||
vert au survol), `ghost` (crème → jaune), `danger` (crème → orange), `dark`
|
||||
(encre → jaune).
|
||||
- **Trames d'état** en `backgroundImage` : hachures orange (critique), mauve
|
||||
(surplus), encre (brouillon), sable (fermé), pointillés.
|
||||
- Infobulles (`HelpTip`) en `position: fixed` positionnées par JS
|
||||
(`useTooltipPosition`), bornées à l'écran — jamais en `absolute` dans un
|
||||
conteneur qui défile.
|
||||
- Graphiques SVG maison, palette vérifiée daltonisme/contraste sur `paper`,
|
||||
**chaque graphique doublé d'une vue tableau**.
|
||||
- Écrans mobiles dédiés aux rôles « terrain » (consultation simple), écrans
|
||||
denses pour l'administration.
|
||||
|
||||
Aucun texte dans les primitives : les libellés viennent toujours de l'i18n.
|
||||
|
||||
---
|
||||
|
||||
## 6. Conventions de code
|
||||
|
||||
- **Code, base, fichiers en anglais** ; français uniquement dans les
|
||||
locales, les commentaires de contexte métier et les commits.
|
||||
- **i18n partout, même en monolingue** : backend `app.i18n.t(key,
|
||||
**params)` (`str.format`), frontend `t(key, params)` (`{nom}`).
|
||||
- **Commentaires qui expliquent le *pourquoi*** (contraintes, bug évité,
|
||||
choix non évident), pas le *quoi*.
|
||||
- **Tests backend** : vraie base PostgreSQL (créée par
|
||||
`docker/postgres-init`), chaque test dans une transaction annulée
|
||||
(savepoints, `join_transaction_mode="create_savepoint"`), fixtures
|
||||
`autouse` qui coupent le SMTP et réinitialisent les limiteurs,
|
||||
`factories.py` pour les données. Un fichier `test_<module>.py` par module
|
||||
ou router ; tests d'autorisation dédiés (`test_authz.py`).
|
||||
- **Tests frontend** : `typecheck` + `lint` + `smoke` (rendu SSR des écrans
|
||||
avec le mock, et vérifications structurelles quand un bug visuel a déjà eu
|
||||
lieu — ex. positions de grille rendues).
|
||||
- **Migrations** numérotées (`0001`, `0002`…), une par étape qui touche le
|
||||
modèle, vérifiées par `alembic check`.
|
||||
- **Pas de nouvelle dépendance sans raison écrite** (commentaire dans
|
||||
`pyproject.toml` si contrainte de version).
|
||||
|
||||
---
|
||||
|
||||
## 7. Commandes types
|
||||
|
||||
| Action | Commande |
|
||||
|--------|----------|
|
||||
| Tout lancer avec la démo | `make demo` |
|
||||
| Instance vierge | `make blank EMAIL=… PASSWORD=… NAME=…` |
|
||||
| Dev | `docker compose up --build` (API : http://localhost:8000/docs, front : http://localhost:5173) |
|
||||
| Tests backend | `docker compose run --rm backend uv run pytest` |
|
||||
| Lint / format | `docker compose run --rm backend uv run ruff check .` / `ruff format .` |
|
||||
| Migration | `docker compose run --rm backend uv run alembic revision --autogenerate -m "…"` |
|
||||
| Appliquer / vérifier | `… alembic upgrade head` puis `… alembic check` |
|
||||
| Front | `npm run typecheck && npm run lint && npm run smoke` |
|
||||
|
||||
---
|
||||
|
||||
## 8. Méthode de travail avec Claude
|
||||
|
||||
1. **Étape 1 — squelette** : monorepo, Docker Compose, backend qui démarre
|
||||
(`/health`), frontend vide, CI/CD, `CLAUDE.md` initial avec glossaire.
|
||||
2. **Étape 2 — modèle de données complet** + migration initiale.
|
||||
3. **Ensuite** : import des données existantes (analyse puis exécution,
|
||||
mode simulation, rapport ligne par ligne, détection de doublons), CRUD,
|
||||
cœur métier en TDD, seed de démo, authentification, puis fonctionnalités
|
||||
une par une.
|
||||
4. **À chaque étape** : code + tests + migration + mise à jour du
|
||||
`CLAUDE.md` (modèle, règles, journal) + front (mock compris) → montrer le
|
||||
résultat → attendre la validation → commit atomique en français.
|
||||
5. **Revues de sécurité régulières** avant toute ouverture au public, notées
|
||||
comme étapes à part entière.
|
||||
@@ -20,7 +20,6 @@ colonne, route et variable utilise la colonne « Code / base ».
|
||||
| Poste | `Position` |
|
||||
| Référent d'un poste | `Position.lead_account_id` → `Account` |
|
||||
| Créneau | `Shift` |
|
||||
| Disponibilité (fenêtre horaire libre) | `Availability` (`date`, `start_time`, `end_time`) |
|
||||
| Affectation | `Assignment` |
|
||||
| Artiste programmé | `Artist` (`name`, `performance_date`, `start_time`, `end_time`) |
|
||||
| Vœu « je veux voir cet artiste » | `VolunteerArtist` (N-N `Volunteer` ↔ `Artist`) |
|
||||
@@ -39,14 +38,14 @@ colonne, route et variable utilise la colonne « Code / base ».
|
||||
| Rôle | `COORDINATOR` / `LEAD` / `VOLUNTEER` |
|
||||
| Catégorie de poste | `BAR` / `TICKETING` / `WELCOME` / `PARKING` / `CLEANING` / `CATERING` / `SETUP` / `STAGE` / `OTHER` |
|
||||
|
||||
### Distinction critique (ne jamais confondre, ni en base ni dans l'UI)
|
||||
### Pas de disponibilités déclarées
|
||||
|
||||
- **`Availability`** = une *déclaration* du bénévole : « le samedi, je peux
|
||||
venir de 14 h à 23 h » — fenêtre horaire libre, plusieurs par jour.
|
||||
- **`Assignment`** = une *décision* de l'organisation : « tu tiens le bar
|
||||
samedi 18 h – 22 h ».
|
||||
|
||||
Deux tables distinctes, deux écrans distincts, deux vocabulaires distincts.
|
||||
Les fenêtres de disponibilité (`Availability`) ont été retirées à l'étape 42
|
||||
(table supprimée, migration `0022`) : l'appli ne connaît que les
|
||||
**`Assignment`**, *décisions* de l'organisation (« tu tiens le bar samedi
|
||||
18 h – 22 h »). Seule subsiste la case informative « je suis disponible » du
|
||||
formulaire public (`Volunteer.self_declared_available`), sans aucun effet sur
|
||||
les affectations.
|
||||
|
||||
---
|
||||
|
||||
@@ -58,33 +57,53 @@ les tables sauf les tables d'association.
|
||||
|
||||
- **`Edition`** : `year`, `name` (opt.), `start_date`, `end_date`,
|
||||
`retention_days` (durée de rétention des données nominatives en jours,
|
||||
`NULL` = pas de purge).
|
||||
`NULL` = pas de purge), `volunteer_message` (**message de
|
||||
l'organisation**, le même pour tous les bénévoles, affiché en bas de
|
||||
l'écran bénévole — saisi par la coordination, onglet « Message aux
|
||||
bénévoles » de la configuration, `ConfigMessage.tsx`, via `PATCH
|
||||
/editions/{id}`) et `volunteer_message_updated_at` (date affichée sous le
|
||||
message). Même règle que `Position.message` : `app/api/_common.py:
|
||||
set_message` — bords retirés, vide ⇒ `NULL`, date posée seulement si le
|
||||
texte change, 2 000 caractères au plus.
|
||||
- **`DayPart`** (« créneau type ») : découpage d'une journée propre à l'édition
|
||||
(nombre et bornes libres). `code`, `label`, `start_time`, `end_time`
|
||||
(`end_time < start_time` ⇒ passe minuit), `order`. Unique (`edition_id`,
|
||||
`code`) ; `code` figé après création. Sert à générer les créneaux 0/0 des
|
||||
postes (pas les disponibilités, qui sont des fenêtres libres).
|
||||
postes.
|
||||
CRUD `GET/POST/PATCH/DELETE /editions/{id}/day-parts` (COORDINATION pour
|
||||
l'écriture).
|
||||
- **`Volunteer`** : `email` (nullable, **pas unique** — plusieurs bénévoles
|
||||
peuvent partager une même adresse, ex. un couple ou une famille ; c'est le
|
||||
couple (email, nom + prénom) qui identifie un bénévole, pas l'email seul),
|
||||
`first_name`, `last_name`, `phone`, `comment`. Pas de date de naissance,
|
||||
`first_name`, `last_name`, `phone`, `comment`. **Aucun champ obligatoire
|
||||
à l'ajout manuel** par la coordination (`POST .../volunteers`, prénom et
|
||||
nom vides par défaut — fiche complétée plus tard) ; le formulaire public
|
||||
et l'import exigent toujours prénom et nom. Un bénévole sans nom
|
||||
s'affiche « Sans nom », initiales « ? » (`lib/volunteer.ts`). Pas de date de naissance,
|
||||
taille de t-shirt, régime, ni compétences. Porte trois listes de vœux,
|
||||
exposées par la fiche (`artist_ids`, `wished_position_ids`,
|
||||
`wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
|
||||
Renseignables aussi via le formulaire public (§ 9) : `photo`/
|
||||
`photo_content_type` (image, servie séparément par
|
||||
`GET .../volunteers/{id}/photo`, COORD/LEAD), `companion_wish_note` (« je
|
||||
`GET .../volunteers/{id}/photo`, COORD/LEAD — la coordination peut aussi
|
||||
l'ajouter, la remplacer ou la retirer depuis la fiche, section « Photo » :
|
||||
`PUT`/`DELETE .../volunteers/{id}/photo`, COORDINATION, même validation
|
||||
que le formulaire, `app/api/_common.py:read_photo`), `companion_wish_note` (« je
|
||||
voudrais être avec » en texte libre — la personne visée n'existe pas
|
||||
forcément encore en base au moment de l'inscription, donc pas de lien
|
||||
direct vers un `VolunteerCompanionWish` ; la coordination la résout à la
|
||||
main depuis la fiche une fois les deux bénévoles inscrits),
|
||||
`self_declared_available` (case à cocher **purement informative**, sans
|
||||
lien avec `Availability`) et `willing_to_pay_fee` (« je participe à la
|
||||
effet sur les affectations) et `willing_to_pay_fee` (« je participe à la
|
||||
cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/
|
||||
`fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le
|
||||
paiement SumUp plus bas).
|
||||
paiement SumUp plus bas). `badge_text` : texte du badge propre à ce bénévole
|
||||
(champ `FREE_TEXT`, voir `BadgeField`), saisi par la coordination sur la
|
||||
fiche (« Texte du badge ») — jamais par le formulaire public — et qui
|
||||
remplace pour lui le texte commun de l'éditeur. `badge_count` : nombre
|
||||
d'exemplaires de son badge imprimés sur les planches (1 par défaut, 1 à
|
||||
100 — `CHECK >= 1` en base, borne haute à la saisie contre une faute de
|
||||
frappe), « Nombre de badges » sur la fiche.
|
||||
- **`Artist`** : programmation de l'édition (`name`, `performance_date`,
|
||||
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
|
||||
lendemain). Unique (`edition_id`, `name`, `performance_date`). La
|
||||
@@ -117,7 +136,21 @@ les tables sauf les tables d'association.
|
||||
compte `LEAD` qui ne référence plus aucun poste (tous décochés) reste donc
|
||||
`LEAD`, mais `/mobile-referent` détecte ce cas et affiche alors, si le
|
||||
compte est rattaché à une fiche bénévole, sa vue de bénévole classique
|
||||
(`VolunteerHomeView`) plutôt qu'un écran référent vide.
|
||||
(`VolunteerHomeView`) plutôt qu'un écran référent vide. Un référent peut
|
||||
aussi être affecté comme bénévole (même soirée comprise) : si son compte
|
||||
est rattaché à une fiche bénévole, `/mobile-referent` a deux onglets,
|
||||
« Mon équipe » et « Mon planning » (sa vue bénévole), ouvert sur « Mon
|
||||
planning » quand il tient en ce moment un créneau de bénévole hors de ses
|
||||
postes sans qu'aucun de ses postes ait un créneau en cours ; dans « Mon
|
||||
équipe », il est marqué « TOI » s'il fait partie de l'équipe pointée.
|
||||
- **`Position.message`** / `message_updated_at` : **message du poste**
|
||||
(consignes : tenue, matériel, rendez-vous…), saisi par le référent depuis
|
||||
son écran (`/mobile-referent`, « Message à ton équipe ») ou par la
|
||||
coordination (`ConfigMessage.tsx`), via `PATCH .../positions/{id}` (même
|
||||
contrôle d'écriture que le reste du poste : COORDINATION ou son
|
||||
référent). Montré à chaque bénévole affecté au poste : repris dans
|
||||
`GET .../volunteers/{vid}/planning` (`position_message`,
|
||||
`position_message_updated_at`).
|
||||
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont
|
||||
il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at`
|
||||
(peut finir le lendemain), `min_staff`, `ideal_staff`. **Aucun créneau
|
||||
@@ -131,14 +164,6 @@ les tables sauf les tables d'association.
|
||||
0/0 par jour de l'édition et par `DayPart` manquant (bouton « Générer les
|
||||
créneaux », idempotent — n'écrase jamais un créneau déjà généré, même
|
||||
modifié depuis).
|
||||
- **`Availability`** : `volunteer_id`, `date`, `start_time`, `end_time`
|
||||
(`end_time <= start_time` ⇒ la fenêtre se termine le lendemain). Fenêtre
|
||||
horaire **libre** ; plusieurs par (`volunteer`, `date`). Unique
|
||||
(`volunteer_id`, `date`, `start_time`, `end_time`). Éditée en bloc :
|
||||
`GET`/`PUT /editions/{id}/volunteers/{vid}/availability` (lecture : COORD /
|
||||
LEAD ou le bénévole lui-même ; `PUT` : COORDINATION, remplace tout).
|
||||
`GET /editions/{id}/availability` liste celles de toute l'édition (COORD /
|
||||
LEAD) — pour la grille des besoins.
|
||||
- **`Assignment`** : `volunteer_id`, `shift_id`, `source` (`AssignmentSource`,
|
||||
défaut `MANUAL`), `locked` (défaut `false`). Unique (`volunteer_id`,
|
||||
`shift_id`). **Pas de statut** : l'affectation est valide dès sa création
|
||||
@@ -149,6 +174,17 @@ les tables sauf les tables d'association.
|
||||
jamais donner à un compte VOLUNTEER l'accès en lecture à `positions`/
|
||||
`shifts` de toute l'édition — c'est l'écran bénévole (`/mobile-benevole`)
|
||||
qui en a besoin.
|
||||
**Pointage** : `attendance` (`AttendanceStatus` `PRESENT` / `ABSENT`,
|
||||
`NULL` = pas pointé) et `attendance_marked_at` (heure du dernier
|
||||
pointage), posés par `PUT .../assignments/{id}/attendance`
|
||||
(`{attendance}`, `null` annule — même contrôle d'écriture que
|
||||
l'affectation : COORDINATION ou référent du poste). Écran référent : un
|
||||
second appui sur le même bouton annule, mise à jour immédiate à l'écran
|
||||
puis enregistrée (`hooks.ts:useSetAttendance`, retour arrière et message
|
||||
si l'enregistrement échoue). Ce n'est pas le statut d'une affectation
|
||||
(toujours valide dès sa création), juste la présence constatée sur
|
||||
place. Avant l'étape 58, ce pointage n'était gardé que dans le
|
||||
navigateur et se perdait au rechargement.
|
||||
- **`Account`** : `email` (unique par édition), `role` (`Role`), `edition_id`,
|
||||
`volunteer_id` (nullable, unique).
|
||||
- **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`,
|
||||
@@ -165,25 +201,88 @@ les tables sauf les tables d'association.
|
||||
et la génération retombant alors sur le format CR80 par défaut (85,6 ×
|
||||
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 »).
|
||||
- **`BadgeField`** : un champ positionné sur le **recto ou le verso**
|
||||
(`face` ∈ `FRONT` / `BACK` ; `field_type` ∈ `FULL_NAME` / `POSITIONS` /
|
||||
`CATEGORY` / `ROLE` / `PHOTO` / `FREE_TEXT`, au plus un par face et par
|
||||
édition — unique (`edition_id`, `face`, `field_type`), 422
|
||||
`badge.duplicate_field_type` sinon ; `PUT .../badges/template/fields`
|
||||
remplace tout, les deux faces dans la même liste), 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`, `bg_color` (fond plein de la
|
||||
zone, `NULL` = transparent, `#rrggbb` sinon 422 `badge.invalid_color`),
|
||||
`auto_fit` (taille réduite par pas de 0,5 pt, plancher 4 pt, jusqu'à ce que
|
||||
tout tienne — `app/badges/pdf.py:fit_text` ; sans, les lignes en trop ne
|
||||
sont pas imprimées), `photo_shape` (`RECT` / `OVAL`, champ `PHOTO`),
|
||||
`z_order` (ordre d'empilement sur la face : les champs sont dessinés du
|
||||
plus petit au plus grand, à égalité la photo dessous — avant, l'ordre
|
||||
alphabétique des types faisait passer la photo par-dessus le nom et le
|
||||
texte libre ; l'éditeur renvoie toujours des rangs distincts 0…n-1,
|
||||
`badges/model.ts:stackOrder`/`restack`, boutons « Superposition » de
|
||||
l'inspecteur, un champ activé arrive au-dessus, la photo dessous ; l'aperçu
|
||||
suit le même ordre, le cadre du champ sélectionné restant par-dessus pour
|
||||
qu'un champ recouvert se saisisse encore).
|
||||
`FREE_TEXT` et `ROLE` sont centrés verticalement dans leur cadre (règle
|
||||
dérivée du type), les autres en haut. `FULL_NAME` = prénom, puis nom à la
|
||||
ligne. Édité visuellement depuis l'écran « Badges »
|
||||
(`frontend/src/pages/badges/` — refonte d'après `design/Créateur de
|
||||
badges.dc.html` : liste des champs recto/verso, aperçu des deux faces avec
|
||||
les données d'un vrai bénévole, inspecteur, formats prédéfinis via
|
||||
`PUT .../template/size`, annuler, flèches, zoom, repères de découpe
|
||||
1,5 mm, contrôle de tous les badges avant génération — `issues.ts`).
|
||||
L'aperçu reprend la mise en page du PDF à l'identique
|
||||
(`badges/layout.ts`, avec les métriques Helvetica de ReportLab recopiées
|
||||
dans `helveticaMetrics.ts`, pas une mesure du navigateur) : toute
|
||||
règle de mise en page changée dans `app/badges/pdf.py` doit l'être aussi
|
||||
dans `layout.ts`, sinon le contrôle avant génération ment.
|
||||
`POSITIONS` est un **tableau** (`app/api/badges.py:_positions_table`,
|
||||
dessiné par `app/badges/pdf.py:_draw_table`) des créneaux affectés,
|
||||
**regroupés par jour de festival** (§ 3 — une soirée qui passe minuit
|
||||
reste sur le jour où elle a commencé) : le jour en en-tête sur toute la
|
||||
largeur (« Samedi 11 »), puis une ligne par créneau, colonne horaire
|
||||
(`HH:MM–HH:MM`, à sa largeur naturelle) et colonne poste (le reste,
|
||||
repliée si besoin), dans l'ordre chronologique. Pas de référent sur le
|
||||
badge (choix de la coordination). Une ligne qui ne tient plus dans la
|
||||
hauteur du cadre n'est pas imprimée ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ;
|
||||
`ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent ») ;
|
||||
`FREE_TEXT` imprime `Volunteer.badge_text` s'il est renseigné, sinon le
|
||||
texte commun saisi dans l'éditeur (`BadgeField.text`, ignoré pour les
|
||||
autres types), sinon rien ; `PHOTO` imprime
|
||||
`Volunteer.photo` dans le cadre du champ (style de texte ignoré), recadrée
|
||||
au ratio du cadre sans déformation, orientation EXIF appliquée et réduite
|
||||
à 300 dpi (`app/badges/pdf.py:_fit_photo` — une photo de téléphone de
|
||||
plusieurs Mo répétée sur 200 badges alourdirait le PDF) ; sans photo ou
|
||||
photo illisible, **rien n'est imprimé** (cadre vide, choix de la
|
||||
coordination — avant l'étape 61, les initiales sur fond beige), sans
|
||||
faire échouer la planche ; l'éditeur n'y montre qu'un repère pointillé
|
||||
« sans photo », non imprimé ; `OVAL` découpe la photo en ellipse. Le texte d'un champ passe à la ligne pour tenir dans la largeur
|
||||
de son cadre (`app/badges/pdf.py:wrap_text` — entre deux mots, ou au
|
||||
milieu d'un mot trop long seul ; les `\n` restent des retours à la
|
||||
ligne), et ce qui dépasse la hauteur du cadre n'est pas imprimé — comme
|
||||
l'aperçu de l'éditeur, qui fait de même côté navigateur. Avant, chaque
|
||||
ligne s'imprimait d'un bloc et débordait du cadre. L'éditeur ramène tout champ à l'intérieur du badge
|
||||
(`badges/model.ts:fitToBadge`) et met le champ sélectionné au premier
|
||||
plan : les positions par défaut visent un CR80 paysage, et sur un badge
|
||||
plus étroit un champ pouvait tomber hors du badge, avec des bornes de
|
||||
glisser-déposer négatives qui le bloquaient — bug corrigé une fois (photo
|
||||
impossible à déplacer ni redimensionner).
|
||||
`POST /editions/{id}/badges/generate` (COORDINATION, `{volunteer_ids?}` —
|
||||
absent = tous) génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est
|
||||
absent = tous ; chaque bénévole y figure `badge_count` fois, à la suite)
|
||||
génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est
|
||||
pour rien : juste ReportLab) : pages recto personnalisées suivies, planche
|
||||
par planche, de la page de verso correspondante (même image répétée à
|
||||
chaque emplacement) — impression recto-verso classique.
|
||||
par planche, de la page de verso correspondante (image de verso puis
|
||||
champs `BACK` de chaque bénévole) — impression recto-verso classique
|
||||
(retournement bord long) : sur la page de verso, chaque badge est placé
|
||||
**en miroir horizontal** (colonne `cols-1-col`, `_cell_origin(mirrored=
|
||||
True)`) pour tomber derrière son recto. La grille est **centrée et symétrique** sur la
|
||||
page (`app/badges/pdf.py:_spacing`) : l'espace libre est réparti à parts
|
||||
égales — même écart à gauche, entre les badges et à droite, et de même en
|
||||
haut, entre les rangées et en bas (CR80 sur A4 : 12,9 mm et 16,2 mm),
|
||||
marge d'impression de 10 mm au moins ; une fois la feuille retournée,
|
||||
chaque verso tombe exactement derrière son recto. Avant l'étape 71, la
|
||||
grille partait du coin haut-gauche et l'espace restant tombait à droite
|
||||
et en bas, d'où un verso décalé (bug corrigé une fois). Indispensable depuis que le verso
|
||||
est personnalisé ; avant, un verso identique pour tous masquait le
|
||||
décalage.
|
||||
- **Feuilles de poste** (PDF, `app/sheets/pdf.py` + `app/api/position_sheets.py`) :
|
||||
aucune nouvelle entité — dérivées de `Position`/`Shift`/`Assignment`/
|
||||
`Volunteer` existants. Complément des badges (un badge identifie un
|
||||
@@ -192,13 +291,52 @@ les tables sauf les tables d'association.
|
||||
téléphone — « à désigner » si aucun) en en-tête, puis les créneaux du jour
|
||||
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).
|
||||
`{position_ids?}` — absent = tous). Mise en page compacte, pour tenir
|
||||
sur une page par poste et par jour autant que possible : bénévoles **deux
|
||||
par ligne** (vignette de 10 mm, nom en gras et téléphone dessous),
|
||||
en-têtes et marges resserrés ; un créneau à besoin 0/0 sans aucune
|
||||
affectation n'apparaît pas (rien à imprimer). Le **message de
|
||||
l'organisation** (`Edition.volunteer_message`, s'il y en a un) est
|
||||
imprimé en pied de **chaque** page, suite d'une feuille comprise
|
||||
(`SheetFooter`, marge du bas agrandie de sa hauteur pour que le contenu
|
||||
ne passe jamais dessous). Photo de chaque bénévole en
|
||||
vignette (10 mm) : réduite à 300 dpi (`app/sheets/pdf.py:_thumbnail`,
|
||||
décodage JPEG à taille réduite via `draft`) une seule fois par PDF, une
|
||||
photo illisible laissant la case vide. Avant l'étape 62, la photo
|
||||
d'origine (plusieurs Mo, prise au téléphone) était intégrée telle quelle
|
||||
pour chaque bénévole de chaque créneau : génération de plusieurs minutes,
|
||||
serveur à 300 % de CPU, PDF énorme et « erreur inattendue » (bug corrigé
|
||||
une fois). Même principe pour les badges (`_fit_photo` + `PhotoCache` :
|
||||
une photo recadrée une fois par génération, malgré recto, verso et
|
||||
exemplaires multiples).
|
||||
- **Statistiques** (`GET /editions/{id}/stats`, COORDINATION,
|
||||
`app/api/stats.py` ; écran `/statistiques`, `StatsPage.tsx`) : aucune
|
||||
nouvelle entité, tout est recalculé à chaque appel. Inscriptions (total,
|
||||
7 derniers jours, évolution cumulée par jour d'ajout `created_at`,
|
||||
jours sans inscription inclus), réponses (part des bénévoles ayant
|
||||
renseigné chaque information, artistes demandés), cotisations (mode de
|
||||
paiement — même règle que `lib/fee.ts:feeMode` —, montants par type,
|
||||
mécénat, `PendingFormSubmission` jamais payées), besoins face aux
|
||||
bénévoles : par **journée de festival** (§ 3), heures × personnes
|
||||
demandées (min/idéal) vs heures affectées ; par poste, places min/idéales, places **pourvues plafonnées
|
||||
créneau par créneau à l'effectif idéal** (`filled` — un sureffectif ne
|
||||
compense jamais un trou ailleurs ; `assigned` garde le total brut) et
|
||||
nombre de vœux. Graphiques en SVG maison (`components/charts.tsx`,
|
||||
aucune bibliothèque), palette validée (daltonisme/contraste) sur le fond
|
||||
`paper`, chaque graphique doublé d'une vue tableau. Le mock calcule les
|
||||
mêmes chiffres (`buildStats`) et étale les dates d'inscription du jeu
|
||||
de démo (`registeredAt`). **Présence** (`attendance`,
|
||||
`_attendance` / mock `buildAttendance`) : présents, absents et non
|
||||
pointés (`Assignment.attendance`) par poste, sur les créneaux terminés
|
||||
et sur ceux en cours à `as_of` (créneaux à venir ignorés), avec le taux
|
||||
de présence parmi les bénévoles pointés ; la section passe en tête de
|
||||
l'écran tant qu'un créneau est en cours, et l'écran se rafraîchit
|
||||
toutes les 30 s (`useStats`, `refetchInterval`).
|
||||
- **Export bénévoles** (Excel, `app/exporting/xlsx.py` + `GET
|
||||
/editions/{id}/volunteers/export`, COORDINATION) : une ligne par
|
||||
bénévole, mêmes champs que la fiche (coordonnées, vœux, cotisation,
|
||||
mécénat) — les vœux (postes/artistes/compagnons) sont résolus en noms
|
||||
lisibles, jamais en UUID. `openpyxl`, déjà utilisé en lecture par l'import
|
||||
mécénat, transaction SumUp — code et identifiant) — les vœux (postes/
|
||||
artistes/compagnons) sont résolus en noms lisibles, jamais en UUID. `openpyxl`, déjà utilisé en lecture par l'import
|
||||
(`app/importing/`), cette fois en écriture. Bouton « Exporter en Excel »
|
||||
sur l'écran de configuration des bénévoles (`ConfigVolunteers.tsx`), même
|
||||
mécanique de téléchargement que les badges/feuilles de poste (blob →
|
||||
@@ -350,7 +488,17 @@ les tables sauf les tables d'association.
|
||||
ou supprimée ensuite. `Volunteer.fee_checkout_id` (identifiant SumUp,
|
||||
jamais exposé) et `fee_payment_status` (`PENDING`/`PAID`/`FAILED`/
|
||||
`EXPIRED`, `NULL` tant qu'aucun checkout n'a été créé) suivent l'état ;
|
||||
`fee_paid_at` est posé à la première confirmation `PAID`. Une fois promue,
|
||||
`fee_paid_at` est posé à la première confirmation `PAID`, avec la
|
||||
transaction SumUp qui a réglé le checkout : `fee_transaction_code` (code
|
||||
court du tableau de bord SumUp et de ses reçus, ex. `TEENSK4W2K` — lu sur
|
||||
le checkout, ou à défaut sur son essai `SUCCESSFUL` dans `transactions`)
|
||||
et `fee_transaction_id` (identifiant technique) — exposés sur la fiche
|
||||
(section « Cotisation ») et dans l'export Excel, pour le rapprochement
|
||||
comptable. Dans l'autre sens, l'API de création de checkout n'ayant
|
||||
**aucun champ de métadonnées libre**, c'est la `description` du checkout
|
||||
(« affichée dans les outils SumUp ») qui porte le nom du bénévole :
|
||||
`Prénom Nom — libellé — édition AAAA` (`_checkout_description` ; donnée
|
||||
transmise au prestataire de paiement, pas un log). Une fois promue,
|
||||
la ligne `PendingFormSubmission` correspondante voit ses champs nominatifs
|
||||
vidés (RGPD : l'information ne vit plus que sur `Volunteer`) ; une ligne
|
||||
jamais payée est purgée après 48 h sans être relancée
|
||||
@@ -368,7 +516,7 @@ Conventions techniques :
|
||||
- Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les
|
||||
`edition_id` et sur les liens vers `volunteer`, `shift`, `position`,
|
||||
`artist`, `account`. La suppression complète d'un bénévole efface
|
||||
donc ses `Availability`, `Assignment`, vœux (artistes, postes, compagnons —
|
||||
donc ses `Assignment`, vœux (artistes, postes, compagnons —
|
||||
dans les deux sens : ceux qu'il a émis et ceux qui le visaient) et son
|
||||
`Account`.
|
||||
- Dates/heures **naïves**, heure locale Europe/Paris (`TZ` du conteneur +
|
||||
@@ -393,11 +541,11 @@ Matrice appliquée par les endpoints :
|
||||
| `assignments` écriture | ✔ | ses postes | — |
|
||||
| `assignments` lecture | ✔ | ✔ | les siennes |
|
||||
| `artists` lecture / écriture | ✔ / ✔ | ✔ / — | ✔ / — |
|
||||
| `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — |
|
||||
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
|
||||
| `badges` (config + génération) | ✔ | — | — |
|
||||
| `position-sheets` (génération) | ✔ | — | — |
|
||||
| `form` (config) | ✔ | — | — |
|
||||
| `stats` | ✔ | — | — |
|
||||
| `form/public`, `form/public/submit` | *(aucune authentification — voir § 2, `FormField`)* |||
|
||||
|
||||
**Authentification — deux systèmes selon le compte**, une même session JWT
|
||||
@@ -438,7 +586,26 @@ compte** : c'est un champ de la fiche, pas une inscription. Tant qu'aucun
|
||||
personne à qui écrire pour cet email et ne fait rien (202 quoi qu'il arrive,
|
||||
pour ne jamais révéler si un compte existe). Front : bouton « Donner un accès
|
||||
de connexion » sur la fiche du bénévole (`ConfigVolunteers.tsx`), qui appelle
|
||||
cet endpoint avec `role=VOLUNTEER`.
|
||||
cet endpoint avec `role=VOLUNTEER`. Seule exception : envoyer un
|
||||
récapitulatif de planning contenant `{lien_connexion}` vaut aussi « donner
|
||||
un accès » (voir plus bas).
|
||||
|
||||
*Lien de connexion permanent* — celui du récapitulatif de planning
|
||||
(`{lien_connexion}`, `app/auth/permanent_link.py`) : connexion directe en
|
||||
un clic, **réutilisable et sans expiration** (contrairement au lien magique
|
||||
de la page de connexion, 30 min et usage unique), pour que le bénévole
|
||||
retrouve son planning depuis n'importe quel email reçu. Rien n'est stocké :
|
||||
jeton `pl.<id du compte>.<HMAC(JWT_SECRET, id + login_link_version)>`,
|
||||
vérifié par le même `POST .../auth/verify` (même page `/connexion?token=`
|
||||
côté front). Bénévoles et référents seulement — jamais un compte de la
|
||||
coordination ni à mot de passe. Révocation : bouton « Renouveler le lien
|
||||
de connexion » sur la fiche (`POST .../accounts/{id}/reset-login-link`,
|
||||
COORDINATION, incrémente `Account.login_link_version`, migration `0031`) —
|
||||
tous les liens déjà envoyés à ce compte cessent de fonctionner ; changer
|
||||
`JWT_SECRET` les révoque tous. Un bénévole sans compte en reçoit un
|
||||
(`VOLUNTEER`, à son email) à l'envoi d'un récapitulatif avec
|
||||
`{lien_connexion}` ; si son email est déjà celui d'un autre compte (couple,
|
||||
famille), pas de compte possible : lien vers la page de connexion seule.
|
||||
|
||||
`PATCH /editions/{id}/accounts/{account_id}` change uniquement le `role`, et
|
||||
seulement entre `LEAD` et `VOLUNTEER` (lien magique dans les deux cas) — 422
|
||||
@@ -464,6 +631,22 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
|
||||
alias validé.
|
||||
- Diagnostic : `docker compose run --rm backend uv run python -m app.email.check
|
||||
<destinataire>` — affiche la config effective et la cause exacte d'un échec.
|
||||
- **Email de test depuis l'appli** : bloc « Tester l'envoi d'emails » de
|
||||
l'onglet « Messages » de la configuration (`ConfigMail.tsx`) — une
|
||||
adresse et un bouton. `POST /editions/{id}/notifications/test` (`{email}`,
|
||||
COORDINATION) envoie **tout de suite** (pas en tâche de fond) le
|
||||
**récapitulatif de planning tel que les bénévoles le recevront** : le
|
||||
modèle en cours d'édition (`subject`/`body`, case « avec le lien de
|
||||
connexion » = même corps que le bouton du même nom), variables remplies
|
||||
avec le planning d'un vrai bénévole (`volunteer_id`, à défaut le premier
|
||||
par nom qui a un créneau, à défaut un exemple fictif), sujet préfixé
|
||||
« [Test] », même mise en forme et même logo — code partagé avec l'envoi
|
||||
réel (`notifications.py:_planning_email`). `{lien_connexion}` y mène à la
|
||||
page de connexion **sans jeton** (jamais de vrai lien magique vers la
|
||||
boîte de test). Renvoie le résultat réel (`app/email/sender.py:deliver_test`) : `sent`, `disabled`
|
||||
(`EMAIL_ENABLED=false`), `connection_failed`, `rejected`, avec la réponse
|
||||
du serveur SMTP (jamais l'adresse du destinataire), le serveur et
|
||||
l'expéditeur configurés — sans accès au serveur ni à ses logs.
|
||||
- **Mise en forme** : chaque email part en `multipart/alternative` — texte
|
||||
brut (source de vérité, utilisé par les tests) **et** une version HTML au
|
||||
thème de l'appli (`app/email/html.py:render_html` — couleurs/typographie
|
||||
@@ -485,11 +668,20 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
|
||||
- **Récapitulatif de planning** : `POST /editions/{id}/notifications/planning`
|
||||
(COORDINATION), corps `{ subject?, body?, volunteer_ids? }`. `subject`/`body`
|
||||
sont des **modèles** avec `{prenom}`, `{nom}`, `{nb_creneaux}`, `{nb_heures}`,
|
||||
`{creneaux}`, `{lien_connexion}` (si présent ⇒ un lien magique personnel est
|
||||
émis pour le compte du bénévole). `volunteer_ids` absent ⇒ tous les bénévoles
|
||||
`{creneaux}`, `{lien_connexion}` (lien de connexion **permanent** du
|
||||
bénévole, voir « Lien de connexion permanent » plus haut — compte créé
|
||||
s'il n'en a pas). `volunteer_ids` absent ⇒ tous les bénévoles
|
||||
de l'édition qui ont un email. Envoi en tâche de fond (`BackgroundTasks`,
|
||||
une connexion SMTP réutilisée). Réponse : `{ queued, skipped_no_email,
|
||||
recipients }`.
|
||||
recipients }`. `{creneaux}` est un **tableau**, comme sur les badges : le jour de
|
||||
festival en en-tête (une nuit reste sur le jour où elle a commencé),
|
||||
puis une ligne par créneau — horaire, poste, référent du poste avec son
|
||||
téléphone (« à désigner » sinon). Vrai tableau HTML dans la version HTML
|
||||
(`app/email/html.py:render_planning_table`, inséré à la place d'un repère
|
||||
via `build_message(html_body=…, html_blocks=…)`), et version texte une
|
||||
ligne par créneau sous chaque jour (`email.planning.table.text_line`).
|
||||
Composition partagée par l'envoi réel et l'email de test
|
||||
(`notifications.py:_planning_email`, `_load_context`).
|
||||
- **Confirmation d'inscription** (`app/api/forms.py`) : dès qu'un `Volunteer`
|
||||
est effectivement créé et a un email — que ce soit tout de suite (pas de
|
||||
paiement en ligne) ou à la promotion d'une `PendingFormSubmission`
|
||||
@@ -517,27 +709,43 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
|
||||
Module isolé `backend/app/validation/`. **Tests écrits avant l'implémentation**,
|
||||
un cas passant + un cas déclenchant par règle.
|
||||
|
||||
Retourne une liste de violations typées `BLOCKING` ou `WARNING`. Chaque
|
||||
Retourne une liste de violations typées `BLOCKING` ou `WARNING` (aucune règle
|
||||
`BLOCKING` aujourd'hui depuis l'étape 44 : le type reste prévu par le contrat
|
||||
de l'API). Chaque
|
||||
violation est un **code** + des paramètres structurés, jamais une phrase (la
|
||||
traduction se fait via `locales/fr.json`).
|
||||
|
||||
| Code | Sévérité | Règle |
|
||||
|---------------------------|------------|-------------------------------------------------------------|
|
||||
| `SHIFT_OVERLAP` | `BLOCKING` | Deux créneaux d'un même bénévole se chevauchent. |
|
||||
| `OUTSIDE_AVAILABILITY` | `WARNING` | Le créneau n'est pas entièrement couvert par les fenêtres de disponibilité déclarées ce jour-là (params : `date`, `uncovered_minutes`). |
|
||||
| `DAILY_HOURS_EXCEEDED` | `WARNING` | > 8 h 00 d'affectations cumulées sur une même journée. |
|
||||
| `INSUFFICIENT_REST` | `WARNING` | < 8 h 00 entre la fin d'un créneau et le début du suivant. |
|
||||
| `SHIFT_OVERLAP` | `WARNING` | Deux créneaux d'un même bénévole se chevauchent. |
|
||||
|
||||
**Deux postes en même temps sont permis** (étape 44) : le chevauchement ne
|
||||
bloque plus l'affectation, il remonte en avertissement dans le panneau
|
||||
d'affectation (« Déjà affecté sur … au même moment »). C'est le solveur qui
|
||||
évite de le proposer (malus, § 4).
|
||||
|
||||
**Aucune limite de temps dans la journée** (étape 42) : plus de cumul
|
||||
journalier maximal (`DAILY_HOURS_EXCEEDED`, ex-8 h), plus de repos minimal
|
||||
entre deux créneaux (`INSUFFICIENT_REST`, ex-8 h), plus de contrôle de
|
||||
disponibilité (`OUTSIDE_AVAILABILITY`). Ni la validation ni le solveur n'en
|
||||
tiennent compte.
|
||||
|
||||
Sémantique figée :
|
||||
|
||||
- Toutes les affectations du bénévole comptent pour chevauchement / cumul /
|
||||
repos (une affectation = une décision, toujours active).
|
||||
- `OUTSIDE_AVAILABILITY` : on prend l'**union** des fenêtres `Availability` du
|
||||
bénévole (converties en instants, minuit géré) ; les minutes du créneau hors
|
||||
de cette union déclenchent l'avertissement. Aucune fenêtre déclarée ⇒
|
||||
créneau entièrement à découvert.
|
||||
- « Journée » (cumul 8 h) : du début du premier `DayPart` du jour au début du
|
||||
premier `DayPart` du lendemain (le `DayPart` ne sert plus qu'à ça).
|
||||
- Toutes les affectations du bénévole comptent pour le chevauchement (une
|
||||
affectation = une décision, toujours active).
|
||||
- « Journée de festival » (plus utilisée par la validation elle-même, mais par
|
||||
les grilles, les feuilles de poste, les statistiques et le chaînage du
|
||||
solveur) : commence au **début du plus long creux** que
|
||||
laissent les `DayPart` sur le cadran de 24 h, c.-à-d. à l'heure où le
|
||||
dernier créneau type de la journée se termine (Matin 08–13, Après-midi
|
||||
13–19, Soirée 19–02 ⇒ journée de 02:00 à 02:00). **Indépendant de
|
||||
`DayPart.order`**, qui n'est qu'un ordre d'affichage figé à la création
|
||||
et ne suit pas les heures modifiées ensuite. Avant, c'était le début du
|
||||
`DayPart` de plus petit `order`, ce qui pouvait donner une journée de
|
||||
21:00 à 03:00 le lendemain dans les grilles (bug corrigé une fois). Sans
|
||||
creux (24 h couvertes) : début du premier par `order` ; sans `DayPart` :
|
||||
minuit.
|
||||
**Même « journée de festival » partout** (`app/validation/engine.py:
|
||||
day_boundary`/`festival_day`, miroir front `lib/planning.ts:festivalDay`) :
|
||||
une soirée qui passe minuit (22h–23h, 23h–00h, 00h–01h…) reste d'un seul
|
||||
@@ -547,8 +755,6 @@ Sémantique figée :
|
||||
`shiftDatesForFestivalDay`), une heure de début avant cette borne désigne
|
||||
la nuit qui suit (00:00 sur le samedi ⇒ dimanche 00:00), pas le petit
|
||||
matin du même jour calendaire.
|
||||
- Cumul : `> 8 h 00` strict (8 h 00 pile passe).
|
||||
- Repos : `< 8 h 00` strict.
|
||||
- Créneaux jointifs (`end_at == start_at`) : **pas** un chevauchement.
|
||||
- Une violation `BLOCKING` empêche l'enregistrement, sans dérogation.
|
||||
- Une violation `WARNING` est retournée à l'appelant mais n'empêche rien
|
||||
@@ -571,22 +777,23 @@ affectation n'est jamais persistée par cet endpoint.
|
||||
recalcule à la demande (rien n'est mis en cache côté serveur) et renvoie
|
||||
`{ suggestions: [{ shift_id, volunteer_ids }], shifts_considered,
|
||||
volunteers_considered }`, `volunteer_ids` du meilleur au moins bon.
|
||||
- **Seule contrainte dure** : aucun chevauchement de créneaux pour un même
|
||||
bénévole (règle `SHIFT_OVERLAP`) — à la fois contre les affectations déjà
|
||||
décidées et entre deux créneaux nouvellement suggérés. L'effectif idéal est
|
||||
un plafond dur (le solveur ne sur-staffe jamais).
|
||||
- **Contraintes dures** : jamais deux créneaux qui se chevauchent suggérés au
|
||||
même bénévole dans un même calcul ; jamais un bénévole suggéré sur un
|
||||
créneau où il est déjà affecté. L'effectif idéal est un plafond dur (le
|
||||
solveur ne sur-staffe jamais). Un bénévole **déjà affecté à un autre poste
|
||||
au même moment** reste proposable (la validation ne fait qu'avertir, § 3)
|
||||
mais avec un fort malus (`_ALREADY_BUSY_PENALTY`, 300 — plus que tous les
|
||||
bonus cumulés et que la base d'un créneau au-dessus de son minimum) : en
|
||||
pratique, il n'est proposé que pour un créneau sous son effectif minimal,
|
||||
et seulement faute de mieux.
|
||||
- Tout le reste est un terme de l'objectif (maximisation), pas une
|
||||
contrainte — cohérent avec le moteur de validation, qui reste seul juge au
|
||||
moment où la coordination confirme une affectation :
|
||||
- un créneau encore sous son effectif **minimal** prime largement sur tout
|
||||
le reste (le solveur doit presque toujours proposer quelqu'un pour un
|
||||
trou urgent, quitte à ce que ce soit un pis-aller signalé ensuite par un
|
||||
avertissement) ;
|
||||
trou urgent, quitte à ce que ce soit un pis-aller) ;
|
||||
- bonus si le poste fait partie des vœux du bénévole
|
||||
(`VolunteerPositionWish`) ;
|
||||
- malus proportionnel aux minutes hors des fenêtres de disponibilité
|
||||
déclarées (réutilise `validation.engine.uncovered_minutes`), et si le
|
||||
repos avec une affectation déjà décidée serait `< 8 h 00` ;
|
||||
- malus si le bénévole a déjà (affectation décidée) le créneau
|
||||
immédiatement précédent, le même jour — **n'importe quel poste**
|
||||
(`SolverShift.predecessor_shift_ids`) : limite les enchaînements de
|
||||
@@ -599,11 +806,9 @@ affectation n'est jamais persistée par cet endpoint.
|
||||
au créneau — plusieurs compagnons présents cumulent le bonus ;
|
||||
- léger malus pour les bénévoles déjà bien chargés (répartition de la
|
||||
charge).
|
||||
- **Pas de plafond de cumul journalier côté solveur** : contrairement à la
|
||||
validation (`DAILY_HOURS_EXCEEDED`, § 3), le solveur ne pénalise pas un
|
||||
bénévole qui dépasserait 8 h sur la journée — seule la validation le
|
||||
signale, à la confirmation.
|
||||
- Simplification assumée : repos, chaînage et compagnons ne sont vérifiés
|
||||
- **Aucune limite de temps** : ni cumul journalier, ni repos minimal, ni
|
||||
disponibilité (§ 3).
|
||||
- Simplification assumée : chaînage et compagnons ne sont vérifiés
|
||||
que contre les affectations **déjà décidées**, pas entre deux suggestions
|
||||
du même calcul (deux compagnons suggérés ensemble dans le même calcul ne
|
||||
se renforcent pas mutuellement) — la validation finale à la confirmation
|
||||
@@ -611,9 +816,9 @@ affectation n'est jamais persistée par cet endpoint.
|
||||
|
||||
**Classement par défaut, sans lancer le solveur** — `frontend/src/lib/planning.ts`
|
||||
(`candidateChanceScore`) porte une version simplifiée du même scoring (vœu de
|
||||
poste, disponibilité y compris conflit d'artiste, compagnons souhaités déjà
|
||||
présents sur le créneau, charge déjà affectée ; pas de cumul 8 h / repos /
|
||||
chaînage, déjà couverts par les badges de validation). C'est ce
|
||||
poste, conflit d'artiste, compagnons souhaités déjà présents sur le créneau,
|
||||
déjà affecté ailleurs au même moment, charge déjà affectée ; pas de
|
||||
chaînage). C'est ce
|
||||
qui classe la colonne des candidats dans le panneau d'affectation dès
|
||||
l'ouverture du détail d'un poste, sans requête réseau. Si le solveur a
|
||||
effectivement tourné (`POST .../solver/suggestions`), ses bénévoles retenus
|
||||
@@ -653,10 +858,18 @@ bug. 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.
|
||||
La « Vue par bénévole » (`VolunteerViewPage.tsx`) utilise le même axe des
|
||||
temps (`buildTimeGrid` / `placeInTimeGrid`, même largeur de tranche par
|
||||
jour) : chaque affectation est une barre étirée sur toute la durée de son
|
||||
créneau, nom du poste dedans. Deux postes tenus en même temps s'empilent
|
||||
sur deux lignes dans la rangée du bénévole (qui s'agrandit) et passent en
|
||||
jaune. Ligne verticale orange à l'heure actuelle, avec l'heure dans
|
||||
l'en-tête des tranches. Barre de recherche (nom du bénévole ou d'un de
|
||||
ses postes, sans accents ni casse) : ne garde que les bénévoles qui
|
||||
correspondent, et cerne d'orange les postes trouvés. Avant l'étape 60, une colonne par créneau distinct
|
||||
(`buildLattice`, supprimé) : une affectation n'apparaissait que dans la
|
||||
colonne de son propre créneau, jamais sur les autres colonnes qu'elle
|
||||
couvrait, et deux postes simultanés se masquaient.
|
||||
|
||||
---
|
||||
|
||||
@@ -666,7 +879,7 @@ adapté à son tableau bénévole × créneau.
|
||||
|
||||
1. Modèle de données complet + migrations Alembic.
|
||||
2. Import CSV/Excel (identité des bénévoles : nom, email, téléphone,
|
||||
commentaire — pas les disponibilités, qui se déclarent dans l'app), en deux
|
||||
commentaire), en deux
|
||||
temps : analyse (colonnes détectées, aperçu) puis exécution avec mapping
|
||||
validé. Rapport ligne par ligne. Détection des doublons : upsert sur
|
||||
(`email`, `last_name` + `first_name`) — l'email seul ne suffit pas,
|
||||
@@ -679,17 +892,18 @@ adapté à son tableau bénévole × créneau.
|
||||
5. Endpoint matrice d'une édition :
|
||||
`[{ position, shifts: [{ shift, min_staff, ideal_staff, assigned }] }]`.
|
||||
6. Jeu de données de démonstration : 120 bénévoles, 15 postes, 4 jours,
|
||||
quelques trous d'effectif, 2–3 avertissements actifs, programmation
|
||||
quelques trous d'effectif, programmation
|
||||
(artistes les jours de festival) et vœux des bénévoles (postes, artistes)
|
||||
pour que le solveur ait matière à arbitrer.
|
||||
7. Solveur de suggestions OR-Tools CP-SAT (section 4) — en cours.
|
||||
|
||||
### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte)
|
||||
|
||||
SMS · pointage sur site · fonctions d'IA. *(L'envoi d'emails par SMTP est en
|
||||
SMS · fonctions d'IA. *(L'envoi d'emails par SMTP est en
|
||||
place — voir § 2. Le formulaire public d'inscription est en place — voir § 2
|
||||
et § 9 ; ce n'était pas prévu à l'origine, retiré de cette liste à
|
||||
l'étape 21.)*
|
||||
l'étape 21. Le pointage présent / absent par le référent est en place depuis
|
||||
l'étape 58 — `Assignment.attendance`, § 2.)*
|
||||
|
||||
---
|
||||
|
||||
@@ -715,13 +929,14 @@ l'étape 21.)*
|
||||
│ │ ├── main.py # instance FastAPI
|
||||
│ │ ├── config.py # settings (pydantic-settings)
|
||||
│ │ ├── db.py # engine + session
|
||||
│ │ ├── logging_config.py # RGPD : pas de PII dans les logs
|
||||
│ │ ├── logging_config.py # RGPD : pas de PII dans les logs ; identifiant de requête
|
||||
│ ├── request_logging.py # une ligne de log par requête, X-Request-ID, erreurs 500
|
||||
│ │ ├── api/ # routers + schemas Pydantic + deps
|
||||
│ │ │ # health, auth, accounts, editions,
|
||||
│ │ │ # day_parts, imports, artists, volunteers,
|
||||
│ │ │ # availability, positions, shifts,
|
||||
│ │ │ # positions, shifts,
|
||||
│ │ │ # assignments, solver, matrix, notifications,
|
||||
│ │ │ # badges, position_sheets, forms
|
||||
│ │ │ # badges, position_sheets, forms, stats
|
||||
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
|
||||
│ │ ├── email/ # SMTP (sender) + rendu texte (templates) et HTML (html.py) + logo (branding.py)
|
||||
│ │ ├── importing/ # import CSV/Excel (parser, text, service)
|
||||
@@ -776,11 +991,28 @@ Aucune dépendance locale requise hors Docker.
|
||||
+ `locales/fr.json`. Frontend : fichier i18n dédié. Vrai même en monolingue.
|
||||
- **RGPD :**
|
||||
- Pas de log des noms, emails, téléphones. On logue des identifiants.
|
||||
- **Logs** (`app/logging_config.py`, `app/request_logging.py`) : une ligne
|
||||
par requête HTTP (`app.request` — méthode, route **modèle** + ses
|
||||
identifiants, jamais l'URL brute ni un paramètre `*token*`, statut, durée,
|
||||
code d'erreur métier `detail.code`, compte connecté) ; INFO / WARNING
|
||||
(4xx) / ERROR (5xx), `/health` en DEBUG. Chaque requête a un identifiant
|
||||
(`X-Request-ID`, repris s'il est fourni et bien formé) préfixé à toutes
|
||||
ses lignes de log, tâches de fond comprises, et affiché à l'utilisateur
|
||||
sous une erreur (`ErrorNote` : « Référence à communiquer ») — `grep` de
|
||||
cette référence dans les logs du conteneur pour diagnostiquer. Erreur 422
|
||||
de validation : emplacement + type, jamais la valeur saisie. Exception non
|
||||
gérée : trace complète + `500 {detail: {code: "error.internal",
|
||||
request_id}}`. Journal d'accès d'uvicorn **désactivé** (il écrivait la
|
||||
chaîne de requête, donc le `access_token` du formulaire public). Logs
|
||||
métier sur les parcours opaques : paiement SumUp (inscription en attente,
|
||||
checkout créé/refusé avec le code d'erreur SumUp, changements de statut,
|
||||
promotion, webhook), connexion (cause réelle d'un refus, côté logs
|
||||
seulement), import, envoi de plannings, solveur (durées).
|
||||
- `Edition.retention_days` : durée de rétention paramétrable.
|
||||
- Endpoint de suppression complète d'un bénévole : *hard delete* en cascade.
|
||||
- **Dates et heures :** stockées naïves, interprétées en heure locale
|
||||
Europe/Paris (heure murale). Les règles « 8 h », « journée », « demi-journée »
|
||||
sont toutes en heure locale.
|
||||
Europe/Paris (heure murale). La « journée de festival » est en heure
|
||||
locale.
|
||||
- **Validation TDD :** tests avant implémentation, un cas passant + un cas
|
||||
déclenchant par règle.
|
||||
- **Git :** un commit atomique par étape validée, message en français.
|
||||
@@ -863,7 +1095,39 @@ Aucune dépendance locale requise hors Docker.
|
||||
| 37 | Cerfa mécénat : nouveau modèle PDF, date du versement repositionnée, date de signature remplie (date du paiement), case « Virement, prélèvement, carte bancaire » cochée (paiement SumUp) ; message de confirmation du paiement annonçant l'email et le reçu fiscal | à valider |
|
||||
| 38 | Créneaux de nuit : une heure de début avant le début de journée (premier créneau type) saisie sur la carte d'un jour désigne la nuit qui suit (00:00 le samedi ⇒ dimanche 00:00) ; écran « Postes & créneaux », feuilles de poste et chaînage du solveur regroupent par journée de festival, comme la grille et la validation | à valider |
|
||||
| 39 | Mode de paiement de la cotisation visible partout (`lib/fee.ts:feeMode`, `app/exporting/xlsx.py:_payment_mode`) : en ligne (type, montant, statut, mécénat), sur place, ou rien — section « Cotisation » sur la fiche (case « Paiera sur place » modifiable à la main), colonne « Cotisation » dans la liste, colonne « Mode de paiement » dans l'export Excel | à valider |
|
||||
| 40 | Écran Statistiques (`/statistiques`, COORDINATION, `GET /editions/{id}/stats`) : inscriptions (nombre, évolution cumulée), réponses au formulaire, artistes demandés, cotisations (en ligne / sur place / aucune, par type, mécénat, paiements jamais aboutis), besoins par journée de festival face aux disponibilités déclarées et aux affectations, places pourvues et vœux par poste — graphiques SVG maison avec infobulles et vues tableau | à valider |
|
||||
| 41 | Début de « journée de festival » indépendant de l'ordre des créneaux types : début du plus long creux qu'ils laissent sur 24 h (`validation.engine.day_boundary`, miroir `lib/planning.ts:dayBoundaryFromDayParts`). Avant, un créneau type d'ordre 0 déplacé à 21:00 faisait commencer chaque journée des grilles (besoins, vue bénévoles) à 21:00, jusqu'à 03:00 le lendemain | à valider |
|
||||
| 42 | Retrait des disponibilités déclarées (table `availability` supprimée, migration `0022` ; API, éditeur de la fiche bénévole, badge de couverture du panneau d'affectation, colonnes des statistiques) et des limites de temps dans la journée (règles `OUTSIDE_AVAILABILITY`, `DAILY_HOURS_EXCEEDED`, `INSUFFICIENT_REST`, malus de disponibilité et de repos du solveur, filtre « Surchargés » de la vue bénévoles) — seul le chevauchement reste contrôlé. La case « je suis disponible » du formulaire public est conservée | à valider |
|
||||
| 43 | Logs de diagnostic (ligne par requête avec route modèle, statut, durée, code d'erreur et compte ; identifiant `X-Request-ID` repris dans chaque log et affiché à l'utilisateur sous une erreur ; exceptions non gérées tracées ; journal d'accès d'uvicorn coupé car il exposait le jeton du formulaire public ; logs métier SumUp, connexion, import, plannings, solveur) et rapprochement SumUp ↔ bénévole : nom du bénévole dans la description du checkout (pas de métadonnées libres dans l'API), code et identifiant de transaction enregistrés au paiement (`Volunteer.fee_transaction_code`/`fee_transaction_id`, migration `0023`), affichés sur la fiche et dans l'export Excel | à valider |
|
||||
| 44 | Un bénévole peut être affecté à deux postes en même temps : `SHIFT_OVERLAP` passe de `BLOCKING` à `WARNING` (affectation enregistrée, avertissement affiché) ; le solveur ne l'exclut plus mais lui applique un fort malus (`_ALREADY_BUSY_PENALTY`) — proposé seulement pour un créneau sous son minimum, faute de mieux — repris dans le classement par défaut du panneau et dans le mock | à valider |
|
||||
| 45 | Responsive mobile / tablette / desktop : en-tête (onglets sur une seconde ligne en tablette, menu déroulant en mobile), panneau d'affectation de la grille des besoins et fiche bénévole en tiroir par-dessus sous `lg:` (plein écran en mobile) au lieu d'une colonne fixe qui écrasait la grille, colonne des postes de la grille rétrécie, liste des bénévoles réduite à nom + cotisation en mobile, écran « Postes & créneaux » empilé, aperçu de l'éditeur de badges mis à l'échelle de l'écran (glisser-déposer tactile), ligne des types de paiement repliée, marges réduites — desktop inchangé | à valider |
|
||||
| 46 | Badges : champ « Photo du bénévole » (`PHOTO`, photo du formulaire recadrée au cadre, 300 dpi) et champ « Texte libre » (`FREE_TEXT`) alimenté par un nouveau « Texte du badge » sur la fiche bénévole (`Volunteer.badge_text`, migration `0024`, repris dans l'export Excel) | à valider |
|
||||
| 47 | Photo ajoutée par la coordination : section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer), `PUT`/`DELETE /editions/{id}/volunteers/{vid}/photo` (COORDINATION), validation de l'image partagée avec le formulaire public (`read_photo`) | à valider |
|
||||
| 48 | Badges : texte libre saisi dans l'éditeur (`BadgeField.text`, migration `0025`), commun à tous les badges, le « Texte du badge » de la fiche le remplaçant pour un bénévole ; champs ramenés dans le badge et champ sélectionné au premier plan (la photo restait bloquée hors d'un badge plus étroit que le CR80) | à valider |
|
||||
| 49 | Badges : le texte des champs (nom, postes et horaires…) passe à la ligne pour tenir dans la largeur de son cadre au lieu de déborder (`app/badges/pdf.py:wrap_text`) | à valider |
|
||||
| 50 | Badges : les créneaux affectés s'affichent en tableau — le jour de festival en en-tête, puis une colonne horaire et une colonne poste (le référent n'est plus imprimé sur le badge), filets à la couleur du champ, cellules repliées dans leur colonne ; même tableau dans l'aperçu de l'éditeur | à valider |
|
||||
| 51 | Refonte du créateur de badges d'après la maquette (`design/`) : champs sur le recto **et le verso** (`BadgeField.face`, verso en miroir dans le PDF), fond de zone, réduction automatique du texte, photo ovale, initiales sans photo, nom sur deux lignes (migration `0026`) ; éditeur en 3 colonnes (champs, aperçu recto/verso avec de vrais bénévoles, inspecteur), formats prédéfinis, annuler/flèches/zoom/repères, dépôt d'image sur l'aperçu, contrôle de tous les badges avant génération | à valider |
|
||||
| 52 | Nombre de badges par bénévole (`Volunteer.badge_count`, 1 par défaut, migration `0027`) : champ « Nombre de badges » sur la fiche, autant d'exemplaires sur les planches, total à imprimer affiché à la génération, colonne dans l'export Excel | à valider |
|
||||
| 53 | Vue par bénévole : tri des lignes par nom ou par total d'heures affectées, croissant / décroissant (boutons « Trier par » et clic sur l'en-tête « TOTAL H ») — à total égal, l'ordre par nom est conservé | à valider |
|
||||
| 54 | Badges : ordre de superposition des champs (`BadgeField.z_order`, migration `0028` — champs existants renumérotés photo dessous) : boutons « Premier plan / Avancer / Reculer / Arrière-plan » dans l'inspecteur, aperçu et PDF dessinés dans cet ordre — la photo recouvrait le texte | à valider |
|
||||
| 55 | Écrans bénévole et référent redessinés d'après la maquette (`design/Planning bénévole mobile.dc.html`, pensée pour le téléphone — tablette et desktop gardent la même colonne, centrée) : bénévole — créneau en cours ou prochain mis en avant (compte à rebours, lieu, chevauchement, bouton « Appeler » le référent), planning par journée de festival avec frise horaire et statut de chaque créneau (terminé / en cours), message de fin de festival ; référent — onglets de postes, choix du créneau (en cours ou prochain par défaut), compteurs présents / attendus / absents, alerte d'absence, pointage (toujours local à l'écran) avec appel direct. Pas encore repris de la maquette, faute de donnée : téléphone de la coordination, bouton « Demander un renfort » (consigne du poste et message de l'organisation : étape 56) | à valider |
|
||||
| 56 | Messages aux bénévoles : message de l'organisation, le même pour tous (`Edition.volunteer_message`, onglet « Message aux bénévoles » de la configuration), et message de chaque poste (`Position.message`, saisi par le référent sur son écran ou par la coordination), migration `0029` — affichés sur l'écran bénévole (bloc « Message de l'organisation » daté, consignes du poste sur le créneau mis en avant et dans la liste) | à valider |
|
||||
| 57 | Référent aussi bénévole : onglets « Mon équipe » / « Mon planning » sur l'écran référent (compte rattaché à une fiche bénévole), ouverture sur celui qui correspond à ce qu'il fait en ce moment, pastille sur l'onglet dont un créneau est en cours, mention « TOI » dans l'équipe pointée | à valider |
|
||||
| 58 | Pointage enregistré : présent / absent du référent sauvegardé sur l'affectation (`Assignment.attendance` + heure, migration `0030`, `PUT .../assignments/{id}/attendance`, COORDINATION ou référent du poste) — il survivait seulement jusqu'au rechargement de la page ; heure du pointage affichée, message si l'enregistrement échoue | à valider |
|
||||
| 59 | Statistiques de présence : section « Présence » de l'écran Statistiques — en ce moment (créneaux en cours : attendus, présents, absents, non pointés par poste) et créneaux terminés (même détail + taux de présence parmi les pointés), rafraîchie toutes les 30 s, en tête de l'écran pendant un créneau | à valider |
|
||||
| 60 | Vue par bénévole sur un vrai axe des temps (tranches de 30 min par jour, comme la grille des besoins) : affectations étirées sur toute leur durée avec le nom du poste, deux postes simultanés empilés sur deux lignes et signalés en jaune, ligne verticale orange de l'heure actuelle avec l'heure — une affectation n'apparaissait que dans la colonne de son propre créneau | à valider |
|
||||
| 61 | Badges : sans photo, le cadre de la photo reste vide (plus d'initiales sur fond beige) — PDF, aperçu de l'éditeur (repère pointillé non imprimé) et messages de contrôle | à valider |
|
||||
| 62 | Feuilles de poste : génération interminable (CPU à 300 %, puis « erreur inattendue ») — photos d'origine intégrées en pleine résolution pour chaque bénévole de chaque créneau ; désormais réduites à la vignette une fois par PDF (40 photos : 15 s / 24 Mo ⇒ 0,2 s / 109 Ko), photo illisible ignorée. Badges : photo recadrée une seule fois par génération et décodée à taille réduite (120 badges : 10 s ⇒ 0,3 s) | à valider |
|
||||
| 63 | Feuilles de poste : message de l'organisation en bas de chaque page (titre + texte, retours à la ligne conservés), rien si aucun message | à valider |
|
||||
| 64 | Vue par bénévole : barre de recherche sur le nom du bénévole ou d'un de ses postes (sans accents ni casse), postes trouvés cernés d'orange, cumulable avec les filtres et le tri | à valider |
|
||||
| 65 | Feuilles de poste compactées : bénévoles deux par ligne (vignette 10 mm, nom et téléphone superposés), titres, en-têtes de créneau et marges resserrés — sur un jeu réaliste de 6 feuilles, 11 pages ⇒ 6 (une par poste et par jour) | à valider |
|
||||
| 66 | Ajout manuel d'un bénévole sans champ obligatoire (prénom et nom facultatifs, côté formulaire et API) ; un bénévole sans nom s'affiche « Sans nom » / « ? » dans les listes, la grille, la vue par bénévole, les badges et l'écran référent | à valider |
|
||||
| 67 | Email de test : adresse + bouton dans l'onglet « Messages » de la configuration, envoi immédiat et résultat réel affiché (envoyé, envoi désactivé, connexion refusée, message refusé, avec la réponse du serveur SMTP) — `POST .../notifications/test` | à valider |
|
||||
| 68 | L'email de test est le récapitulatif de planning compilé tel qu'envoyé aux bénévoles (modèle en cours d'édition, variables remplies avec le planning d'un bénévole au choix, option lien de connexion — sans vrai jeton —, sujet « [Test] … ») | à valider |
|
||||
| 69 | Récapitulatif de planning : `{creneaux}` devient un tableau par jour de festival (horaire, poste, référent avec son téléphone), en HTML dans l'email et en lignes lisibles dans la version texte — envoi réel et email de test | à valider |
|
||||
| 70 | `{lien_connexion}` devient un lien de connexion directe permanent (réutilisable, sans expiration, signé HMAC, rien de stocké — avant : lien magique de 30 min à usage unique, ou simple lien vers le site sans compte) ; compte bénévole créé à l'envoi s'il manque ; révocation par « Renouveler le lien de connexion » sur la fiche (`Account.login_link_version`, migration `0031`) ; jamais pour la coordination | à valider |
|
||||
| 71 | Planches de badges centrées : espace libre réparti à parts égales (mêmes écarts à gauche, entre les badges, à droite ; en haut, entre les rangées, en bas), grille symétrique — le verso, imprimé en miroir, tombe exactement derrière le recto au lieu d'être décalé de l'espace restant | à valider |
|
||||
|
||||
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||
de formulaire, IA. Frontend : squelette + 6 écrans +
|
||||
connexion en place (voir `frontend/`).
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
`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 »."""
|
||||
validation « ces ids appartiennent bien à cette édition ». Idem pour
|
||||
`read_photo` (photo d'un bénévole)."""
|
||||
|
||||
import io
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy import delete
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.models import Artist, Position, VolunteerArtist, VolunteerPositionWish
|
||||
from app.models import Artist, Position, VolunteerArtist, VolunteerPositionWish, local_now
|
||||
|
||||
|
||||
def owned_or_404(instance, edition_id: uuid.UUID):
|
||||
@@ -77,3 +80,54 @@ def set_wished_positions(
|
||||
filter_value=volunteer.id,
|
||||
pairs=[{"volunteer_id": volunteer.id, "position_id": pid} for pid in ids],
|
||||
)
|
||||
|
||||
|
||||
MAX_PHOTO_BYTES = 8 * 1024 * 1024
|
||||
ALLOWED_PHOTO_TYPES = {"image/png", "image/jpeg"}
|
||||
|
||||
|
||||
async def read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None:
|
||||
"""Validation partagée par le formulaire public (inscription immédiate
|
||||
ou en attente de paiement) et l'ajout d'une photo par la coordination
|
||||
depuis la fiche (`volunteers.py:set_volunteer_photo`) — `None` si aucun
|
||||
fichier fourni. `Content-Type` est
|
||||
un en-tête fourni par l'appelant, jamais vérifié par lui-même (ce
|
||||
endpoint est public, sans authentification) : un fichier qui ne se
|
||||
décode pas comme une vraie image malgré un `Content-Type` correct
|
||||
(`image/png`/`image/jpeg` revendiqué) est rejeté ici plutôt que de
|
||||
planter plus tard la génération des badges/feuilles de poste
|
||||
(`app/badges/pdf.py`/`app/sheets/pdf.py`, qui lisent ces octets sans
|
||||
filet) — même validation que l'import d'image des badges
|
||||
(`app/api/badges.py:_pixel_size`, réservé à la coordination)."""
|
||||
if photo is None or not photo.filename:
|
||||
return None
|
||||
content_type = photo.content_type or ""
|
||||
if content_type not in ALLOWED_PHOTO_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "form.photo_type_not_supported"}
|
||||
)
|
||||
content = await photo.read()
|
||||
if len(content) > MAX_PHOTO_BYTES:
|
||||
raise HTTPException(status_code=413, detail={"code": "form.photo_too_large"})
|
||||
if not content:
|
||||
return None
|
||||
try:
|
||||
with Image.open(io.BytesIO(content)) as img:
|
||||
img.verify()
|
||||
except (UnidentifiedImageError, OSError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "form.photo_type_not_supported"}
|
||||
) from exc
|
||||
return content, content_type
|
||||
|
||||
|
||||
def set_message(target: object, field: str, value: str | None) -> None:
|
||||
"""Message affiché aux bénévoles (`Edition.volunteer_message`,
|
||||
`Position.message`) : espaces de bord retirés, vide = plus de message.
|
||||
`{field}_updated_at` n'est daté que si le texte change vraiment — il dit
|
||||
aux bénévoles de quand date le message."""
|
||||
text = (value or "").strip() or None
|
||||
if text == getattr(target, field):
|
||||
return
|
||||
setattr(target, field, text)
|
||||
setattr(target, f"{field}_updated_at", local_now() if text else None)
|
||||
|
||||
@@ -5,6 +5,7 @@ import datetime as dt
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.models import DayPart, Edition, Position, Shift
|
||||
from app.validation.engine import day_boundary
|
||||
|
||||
|
||||
def _dates(edition: Edition) -> list[dt.date]:
|
||||
@@ -12,17 +13,6 @@ def _dates(edition: Edition) -> list[dt.date]:
|
||||
return [edition.start_date + dt.timedelta(days=i) for i in range(span + 1)]
|
||||
|
||||
|
||||
def _day_boundary(day_parts: list[DayPart]) -> dt.time:
|
||||
"""Début du premier créneau type du jour (le plus petit `order`) — même
|
||||
frontière que `validation.engine._day_boundary`. Un créneau type dont
|
||||
l'heure de début tombe avant cette frontière est la suite de la nuit du
|
||||
jour précédent (ex. `Nuit3` 00 h–01 h après `Soirée` 19 h–00 h) : il doit
|
||||
être généré sur le jour *suivant*, pas sur `day` lui-même."""
|
||||
if not day_parts:
|
||||
return dt.time(0, 0)
|
||||
return min(day_parts, key=lambda part: part.order).start_time
|
||||
|
||||
|
||||
def _window(day: dt.date, part: DayPart, boundary: dt.time) -> tuple[dt.datetime, dt.datetime]:
|
||||
actual_day = day + dt.timedelta(days=1) if part.start_time < boundary else day
|
||||
start = dt.datetime.combine(actual_day, part.start_time)
|
||||
@@ -40,7 +30,7 @@ def generate_missing_shifts(
|
||||
day_parts = list(
|
||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||
)
|
||||
boundary = _day_boundary(day_parts)
|
||||
boundary = day_boundary(day_parts)
|
||||
existing = {
|
||||
(s.day_part_id, s.starts_at.date())
|
||||
for s in session.exec(
|
||||
|
||||
@@ -109,6 +109,24 @@ def update_account(
|
||||
return account
|
||||
|
||||
|
||||
@router.post("/{account_id}/reset-login-link", response_model=AccountRead)
|
||||
def reset_login_link(
|
||||
account_id: uuid.UUID,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Account:
|
||||
"""Révoque le lien de connexion permanent du compte (`app/auth/
|
||||
permanent_link.py`) : tous les liens déjà envoyés cessent de
|
||||
fonctionner ; le prochain récapitulatif portera un nouveau lien. Ex.
|
||||
email transféré, téléphone perdu."""
|
||||
account = owned_or_404(session.get(Account, account_id), edition.id)
|
||||
account.login_link_version += 1
|
||||
session.commit()
|
||||
session.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_account(
|
||||
account_id: uuid.UUID,
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.api.schemas import (
|
||||
AssignmentValidateRequest,
|
||||
AssignmentValidateResult,
|
||||
AssignmentWriteResult,
|
||||
AttendanceUpdate,
|
||||
)
|
||||
from app.auth.deps import (
|
||||
current_account_in_edition,
|
||||
@@ -24,7 +25,7 @@ from app.auth.deps import (
|
||||
require_staff,
|
||||
)
|
||||
from app.db import get_session
|
||||
from app.models import Account, Assignment, Edition, Role, Shift, Volunteer
|
||||
from app.models import Account, Assignment, Edition, Role, Shift, Volunteer, local_now
|
||||
from app.validation import has_blocking, validate
|
||||
from app.validation.loader import build_validation_input
|
||||
|
||||
@@ -206,6 +207,30 @@ def update_assignment(
|
||||
return AssignmentWriteResult(assignment=assignment, warnings=warnings)
|
||||
|
||||
|
||||
@router.put("/{assignment_id}/attendance", response_model=AssignmentRead)
|
||||
def set_attendance(
|
||||
assignment_id: uuid.UUID,
|
||||
payload: AttendanceUpdate,
|
||||
edition: Edition = Depends(get_edition),
|
||||
account: Account = Depends(current_account_in_edition),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Assignment:
|
||||
"""Pointe un bénévole présent ou absent sur son créneau (`None` annule) —
|
||||
le référent du poste ou la coordination. Enregistré en base : le
|
||||
pointage survit à un rechargement et se retrouve sur tout appareil."""
|
||||
assignment = owned_or_404(
|
||||
session.get(Assignment, assignment_id), edition.id
|
||||
)
|
||||
ensure_can_write_shift(
|
||||
account, session, session.get(Shift, assignment.shift_id)
|
||||
)
|
||||
assignment.attendance = payload.attendance
|
||||
assignment.attendance_marked_at = local_now() if payload.attendance else None
|
||||
session.commit()
|
||||
session.refresh(assignment)
|
||||
return assignment
|
||||
|
||||
|
||||
@router.delete("/{assignment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_assignment(
|
||||
assignment_id: uuid.UUID,
|
||||
|
||||
@@ -25,6 +25,8 @@ from app.api.schemas import (
|
||||
from app.auth.deps import get_current_account
|
||||
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
|
||||
from app.auth.passwords import MIN_LENGTH, hash_password, verify_password
|
||||
from app.auth.permanent_link import PREFIX as PERMANENT_PREFIX
|
||||
from app.auth.permanent_link import account_from_permanent_token
|
||||
from app.auth.rate_limit import login_limiter, magic_link_limiter
|
||||
from app.auth.tokens import create_access_token, hash_token
|
||||
from app.config import get_settings
|
||||
@@ -84,7 +86,17 @@ def login(
|
||||
or account.password_hash is None
|
||||
or not verify_password(payload.password, account.password_hash)
|
||||
):
|
||||
# Cause réelle dans les logs seulement — la réponse reste la même
|
||||
# (pas de divulgation). Jamais l'email saisi.
|
||||
if account is None:
|
||||
reason = "aucun compte pour cet email"
|
||||
elif account.password_hash is None:
|
||||
reason = f"compte {account.id} sans mot de passe (lien magique)"
|
||||
else:
|
||||
reason = f"mot de passe incorrect pour le compte {account.id}"
|
||||
logger.info("Connexion refusée (édition %s) : %s", edition_id, reason)
|
||||
raise _invalid_credentials()
|
||||
logger.info("Connexion par mot de passe : compte %s (édition %s)", account.id, edition_id)
|
||||
return _issue(account)
|
||||
|
||||
|
||||
@@ -135,7 +147,19 @@ def request_magic_link(
|
||||
tâche de fond (une erreur SMTP est journalisée, pas remontée)."""
|
||||
magic_link_limiter.hit(f"{edition_id}:{payload.email.strip().lower()}")
|
||||
account = _account_by_email(session, edition_id, payload.email)
|
||||
if account is None:
|
||||
logger.info(
|
||||
"Lien magique demandé sans compte correspondant (édition %s) — rien envoyé "
|
||||
"(un email sur une fiche bénévole ne crée pas d'accès)",
|
||||
edition_id,
|
||||
)
|
||||
elif account.password_hash is not None:
|
||||
logger.info(
|
||||
"Lien magique demandé pour le compte %s, qui a un mot de passe — rien envoyé",
|
||||
account.id,
|
||||
)
|
||||
if account is not None and account.password_hash is None:
|
||||
logger.info("Lien magique émis pour le compte %s (édition %s)", account.id, edition_id)
|
||||
settings = get_settings()
|
||||
raw = issue_magic_link_token(session, account)
|
||||
session.commit()
|
||||
@@ -162,6 +186,16 @@ def verify_magic_link(
|
||||
payload: VerifyRequest,
|
||||
session: Session = Depends(get_session),
|
||||
) -> TokenResponse:
|
||||
raw = payload.token.strip()
|
||||
if raw.startswith(PERMANENT_PREFIX):
|
||||
# Lien permanent du récapitulatif de planning : réutilisable, sans
|
||||
# expiration, révocable (voir `app/auth/permanent_link.py`).
|
||||
account = account_from_permanent_token(session, raw)
|
||||
if account is None or account.edition_id != edition_id:
|
||||
logger.info("Lien permanent refusé (édition %s)", edition_id)
|
||||
raise _invalid_token()
|
||||
logger.info("Connexion par lien permanent : compte %s (édition %s)", account.id, edition_id)
|
||||
return _issue(account)
|
||||
token = session.exec(
|
||||
select(MagicLinkToken).where(
|
||||
MagicLinkToken.token_hash == hash_token(payload.token.strip())
|
||||
@@ -172,13 +206,26 @@ def verify_magic_link(
|
||||
or token.used_at is not None
|
||||
or token.expires_at < local_now()
|
||||
):
|
||||
if token is None:
|
||||
reason = "jeton inconnu"
|
||||
elif token.used_at is not None:
|
||||
reason = f"jeton déjà utilisé (compte {token.account_id})"
|
||||
else:
|
||||
reason = f"jeton expiré (compte {token.account_id})"
|
||||
logger.info("Lien magique refusé (édition %s) : %s", edition_id, reason)
|
||||
raise _invalid_token()
|
||||
account = session.get(Account, token.account_id)
|
||||
if account is None or account.edition_id != edition_id:
|
||||
logger.info(
|
||||
"Lien magique refusé (édition %s) : compte %s absent ou d'une autre édition",
|
||||
edition_id,
|
||||
token.account_id,
|
||||
)
|
||||
raise _invalid_token()
|
||||
token.used_at = local_now()
|
||||
session.commit()
|
||||
session.refresh(account)
|
||||
logger.info("Connexion par lien magique : compte %s (édition %s)", account.id, edition_id)
|
||||
return _issue(account)
|
||||
|
||||
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
"""Disponibilités déclarées par les bénévoles.
|
||||
|
||||
Une disponibilité = « le <date>, je peux de <start_time> à <end_time> ».
|
||||
Fenêtre horaire libre (pas liée aux créneaux types) ; plusieurs par jour
|
||||
possibles. `end_time <= start_time` ⇒ la fenêtre se termine le lendemain.
|
||||
|
||||
Ces fenêtres sont prises en compte par le moteur de validation (règle
|
||||
`OUTSIDE_AVAILABILITY`) au moment d'affecter un bénévole à un créneau.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import delete
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api._common import owned_or_404
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import AvailabilityRead, AvailabilitySet
|
||||
from app.auth.deps import (
|
||||
current_account_in_edition,
|
||||
require_coordinator,
|
||||
require_staff,
|
||||
)
|
||||
from app.db import get_session
|
||||
from app.models import Account, Availability, Edition, Role, Volunteer
|
||||
|
||||
router = APIRouter(prefix="/editions/{edition_id}", tags=["availability"])
|
||||
|
||||
|
||||
def _ensure_can_read(account: Account, volunteer_id: uuid.UUID) -> None:
|
||||
if account.role in (Role.COORDINATOR, Role.LEAD):
|
||||
return
|
||||
if account.volunteer_id == volunteer_id:
|
||||
return
|
||||
raise HTTPException(status_code=403, detail={"code": "error.forbidden"})
|
||||
|
||||
|
||||
def _windows(session: Session, volunteer_id: uuid.UUID) -> list[Availability]:
|
||||
return list(
|
||||
session.exec(
|
||||
select(Availability)
|
||||
.where(Availability.volunteer_id == volunteer_id)
|
||||
.order_by(Availability.date, Availability.start_time)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/volunteers/{volunteer_id}/availability",
|
||||
response_model=list[AvailabilityRead],
|
||||
)
|
||||
def list_availability(
|
||||
volunteer_id: uuid.UUID,
|
||||
edition: Edition = Depends(get_edition),
|
||||
account: Account = Depends(current_account_in_edition),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[Availability]:
|
||||
_ensure_can_read(account, volunteer_id)
|
||||
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||
return _windows(session, volunteer_id)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/volunteers/{volunteer_id}/availability",
|
||||
response_model=list[AvailabilityRead],
|
||||
)
|
||||
def replace_availability(
|
||||
volunteer_id: uuid.UUID,
|
||||
payload: AvailabilitySet,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[Availability]:
|
||||
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||
|
||||
for w in payload.windows:
|
||||
if not edition.start_date <= w.date <= edition.end_date:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"code": "availability.date_outside_edition",
|
||||
"params": {"date": w.date.isoformat()},
|
||||
},
|
||||
)
|
||||
if w.start_time == w.end_time:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"code": "availability.empty_window"},
|
||||
)
|
||||
|
||||
session.execute(
|
||||
delete(Availability).where(Availability.volunteer_id == volunteer_id)
|
||||
)
|
||||
# Dé-doublonne (date, start, end) : la contrainte d'unicité l'imposerait.
|
||||
seen: set[tuple] = set()
|
||||
for w in payload.windows:
|
||||
key = (w.date, w.start_time, w.end_time)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
session.add(
|
||||
Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer_id,
|
||||
date=w.date,
|
||||
start_time=w.start_time,
|
||||
end_time=w.end_time,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return _windows(session, volunteer_id)
|
||||
|
||||
|
||||
@router.get("/availability", response_model=list[AvailabilityRead])
|
||||
def list_edition_availability(
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_staff),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[Availability]:
|
||||
"""Toutes les disponibilités de l'édition (pour la grille des besoins)."""
|
||||
return list(
|
||||
session.exec(
|
||||
select(Availability)
|
||||
.where(Availability.edition_id == edition.id)
|
||||
.order_by(
|
||||
Availability.volunteer_id,
|
||||
Availability.date,
|
||||
Availability.start_time,
|
||||
)
|
||||
)
|
||||
)
|
||||
+109
-51
@@ -1,20 +1,22 @@
|
||||
"""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.
|
||||
Chaque face (recto, verso) combine une image de fond (la même pour tous les
|
||||
bénévoles) et des champs positionnés (nom, poste(s) affectés, catégorie,
|
||||
rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py` pour la
|
||||
composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
|
||||
`BadgeField`) pour le modèle.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import io
|
||||
import re
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.deps import get_edition
|
||||
@@ -31,6 +33,8 @@ from app.badges.pdf import (
|
||||
DEFAULT_BADGE_WIDTH_MM,
|
||||
BadgeFieldSpec,
|
||||
BadgeSpec,
|
||||
BadgeTable,
|
||||
BadgeTableSection,
|
||||
generate_badge_sheets_pdf,
|
||||
image_size_mm,
|
||||
)
|
||||
@@ -39,20 +43,26 @@ from app.i18n import t
|
||||
from app.models import (
|
||||
Account,
|
||||
Assignment,
|
||||
BadgeFace,
|
||||
BadgeField,
|
||||
BadgeFieldType,
|
||||
BadgeTemplate,
|
||||
DayPart,
|
||||
Edition,
|
||||
Position,
|
||||
Role,
|
||||
Shift,
|
||||
Volunteer,
|
||||
)
|
||||
from app.validation.engine import day_boundary, festival_day
|
||||
|
||||
router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"])
|
||||
|
||||
_MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
||||
_ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg"}
|
||||
_HEX_COLOR = re.compile(r"#[0-9a-fA-F]{6}")
|
||||
# Champs centrés verticalement dans leur cadre (les autres partent du haut).
|
||||
_MIDDLE_ALIGNED = {BadgeFieldType.FREE_TEXT, BadgeFieldType.ROLE}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -75,7 +85,14 @@ def _fields(session: Session, edition_id: uuid.UUID) -> list[BadgeField]:
|
||||
session.exec(
|
||||
select(BadgeField)
|
||||
.where(BadgeField.edition_id == edition_id)
|
||||
.order_by(BadgeField.field_type)
|
||||
# Ordre de dessin (voir `BadgeField.z_order`) ; à égalité, la
|
||||
# photo dessous, pour ne jamais masquer un texte.
|
||||
.order_by(
|
||||
BadgeField.face,
|
||||
BadgeField.z_order,
|
||||
(BadgeField.field_type == BadgeFieldType.PHOTO).desc(),
|
||||
BadgeField.field_type,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -201,9 +218,11 @@ def set_fields(
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[BadgeField]:
|
||||
seen: set[BadgeFieldType] = set()
|
||||
# Au plus un champ de chaque type par face ; le même sur les deux faces
|
||||
# est permis.
|
||||
seen: set[tuple[BadgeFace, BadgeFieldType]] = set()
|
||||
for f in payload.fields:
|
||||
if f.field_type in seen:
|
||||
if (f.face, f.field_type) in seen:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
@@ -211,7 +230,12 @@ def set_fields(
|
||||
"params": {"field_type": f.field_type},
|
||||
},
|
||||
)
|
||||
seen.add(f.field_type)
|
||||
seen.add((f.face, f.field_type))
|
||||
if f.bg_color is not None and not _HEX_COLOR.fullmatch(f.bg_color):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"code": "badge.invalid_color", "params": {"color": f.bg_color}},
|
||||
)
|
||||
|
||||
session.execute(delete(BadgeField).where(BadgeField.edition_id == edition.id))
|
||||
for f in payload.fields:
|
||||
@@ -245,7 +269,7 @@ def set_size(
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Génération du PDF #
|
||||
# --------------------------------------------------------------------------- #
|
||||
AssignmentRow = tuple[Position, Shift, Volunteer | None] # (poste, créneau, référent)
|
||||
AssignmentRow = tuple[Position, Shift]
|
||||
|
||||
|
||||
def _role_label(volunteer_id: uuid.UUID, lead_volunteer_ids: set[uuid.UUID]) -> str:
|
||||
@@ -258,16 +282,29 @@ 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 _day_label(day: dt.date) -> str:
|
||||
"""En-tête d'un jour dans le tableau des créneaux : « Samedi 11 »."""
|
||||
return t("badge.table.day_label", weekday=t(f"badge.weekday.{day.weekday()}"), day=day.day)
|
||||
|
||||
|
||||
def _positions_table(assignments: list[AssignmentRow], boundary: dt.time) -> BadgeTable | None:
|
||||
"""Créneaux affectés regroupés par jour de festival (une soirée qui
|
||||
passe minuit reste sur le jour où elle a commencé — `festival_day`) :
|
||||
le jour en en-tête, puis une ligne horaire / poste par créneau, dans
|
||||
l'ordre chronologique. `None` sans aucun créneau (rien à dessiner)."""
|
||||
by_day: dict[dt.date, list[tuple[str, str]]] = defaultdict(list)
|
||||
for position, shift in assignments:
|
||||
by_day[festival_day(shift.starts_at, boundary)].append(
|
||||
(f"{shift.starts_at:%H:%M}–{shift.ends_at:%H:%M}", position.name)
|
||||
)
|
||||
if not by_day:
|
||||
return None
|
||||
return BadgeTable(
|
||||
sections=tuple(
|
||||
BadgeTableSection(title=_day_label(day), rows=tuple(rows))
|
||||
for day, rows in sorted(by_day.items())
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _field_text(
|
||||
@@ -276,17 +313,22 @@ def _field_text(
|
||||
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]],
|
||||
lead_volunteer_ids: set[uuid.UUID],
|
||||
) -> str:
|
||||
"""Texte d'un champ — `POSITIONS` n'en a pas : c'est un tableau
|
||||
(`_positions_table`)."""
|
||||
if field.field_type == BadgeFieldType.FULL_NAME:
|
||||
return f"{volunteer.first_name} {volunteer.last_name}"
|
||||
# Prénom puis nom, chacun sur sa ligne.
|
||||
return f"{volunteer.first_name}\n{volunteer.last_name}"
|
||||
if field.field_type == BadgeFieldType.PHOTO:
|
||||
# Une image, pas un texte (rien d'imprimé sans photo).
|
||||
return ""
|
||||
if field.field_type == BadgeFieldType.FREE_TEXT:
|
||||
# Texte commun saisi dans l'éditeur, remplacé pour un bénévole par
|
||||
# le « Texte du badge » de sa fiche s'il en a un.
|
||||
return volunteer.badge_text or field.text or ""
|
||||
assignments = assignments_by_volunteer.get(volunteer.id, [])
|
||||
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}
|
||||
{_category_label(position.category) for position, _shift in assignments}
|
||||
)
|
||||
return ", ".join(categories)
|
||||
if field.field_type == BadgeFieldType.ROLE:
|
||||
@@ -314,20 +356,20 @@ def generate_badges(
|
||||
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)
|
||||
select(Assignment.volunteer_id, Position, Shift)
|
||||
.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))
|
||||
for volunteer_id, position, shift in rows:
|
||||
assignments_by_volunteer[volunteer_id].append((position, shift))
|
||||
|
||||
boundary = day_boundary(
|
||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||
)
|
||||
lead_volunteer_ids = {
|
||||
account.volunteer_id
|
||||
for account in session.exec(
|
||||
@@ -340,26 +382,42 @@ def generate_badges(
|
||||
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
|
||||
)
|
||||
def spec(field: BadgeField, volunteer: Volunteer) -> BadgeFieldSpec:
|
||||
return BadgeFieldSpec(
|
||||
text=_field_text(field, volunteer, assignments_by_volunteer, lead_volunteer_ids),
|
||||
x_mm=field.x_mm,
|
||||
y_mm=field.y_mm,
|
||||
width_mm=field.width_mm,
|
||||
height_mm=field.height_mm,
|
||||
font_size=field.font_size,
|
||||
align=field.align.value,
|
||||
bold=field.bold,
|
||||
color=field.color,
|
||||
is_photo=field.field_type == BadgeFieldType.PHOTO,
|
||||
image=volunteer.photo if field.field_type == BadgeFieldType.PHOTO else None,
|
||||
table=_positions_table(assignments_by_volunteer.get(volunteer.id, []), boundary)
|
||||
if field.field_type == BadgeFieldType.POSITIONS
|
||||
else None,
|
||||
bg_color=field.bg_color,
|
||||
auto_fit=field.auto_fit,
|
||||
valign="MIDDLE" if field.field_type in _MIDDLE_ALIGNED else "TOP",
|
||||
photo_shape=field.photo_shape.value,
|
||||
)
|
||||
|
||||
front_fields = [f for f in fields if f.face == BadgeFace.FRONT]
|
||||
back_fields = [f for f in fields if f.face == BadgeFace.BACK]
|
||||
# Un badge par exemplaire demandé sur la fiche (`Volunteer.badge_count`),
|
||||
# à la suite les uns des autres.
|
||||
badges = [
|
||||
badge
|
||||
for volunteer in volunteers
|
||||
for badge in [
|
||||
BadgeSpec(
|
||||
fields=tuple(spec(field, volunteer) for field in front_fields),
|
||||
back_fields=tuple(spec(field, volunteer) for field in back_fields),
|
||||
)
|
||||
]
|
||||
* volunteer.badge_count
|
||||
]
|
||||
|
||||
pdf_bytes = generate_badge_sheets_pdf(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.api._common import set_message
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import EditionRead, EditionUpdate
|
||||
from app.auth.deps import current_account_in_edition, require_coordinator
|
||||
@@ -27,7 +28,10 @@ def update_edition(
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Edition:
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if "volunteer_message" in changes:
|
||||
set_message(edition, "volunteer_message", changes.pop("volunteer_message"))
|
||||
for field, value in changes.items():
|
||||
setattr(edition, field, value)
|
||||
if edition.end_date < edition.start_date:
|
||||
raise HTTPException(
|
||||
|
||||
+119
-43
@@ -25,7 +25,6 @@ coordination, faute de savoir si la personne visée est déjà inscrite (voir
|
||||
CLAUDE.md § 2, `Volunteer`)."""
|
||||
|
||||
import datetime as dt
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
@@ -43,13 +42,15 @@ from fastapi import (
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api._common import (
|
||||
ALLOWED_PHOTO_TYPES,
|
||||
MAX_PHOTO_BYTES,
|
||||
owned_or_404,
|
||||
read_photo,
|
||||
resolve_owned_ids,
|
||||
set_artists,
|
||||
set_wished_positions,
|
||||
@@ -109,8 +110,6 @@ logger = logging.getLogger("app.forms")
|
||||
|
||||
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
|
||||
@@ -340,14 +339,14 @@ def delete_payment_option(
|
||||
|
||||
async def _read_logo(file: UploadFile) -> tuple[bytes, str]:
|
||||
content_type = file.content_type or ""
|
||||
if content_type not in _ALLOWED_PHOTO_TYPES:
|
||||
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:
|
||||
if len(content) > MAX_PHOTO_BYTES:
|
||||
raise HTTPException(status_code=413, detail={"code": "form.logo_too_large"})
|
||||
return content, content_type
|
||||
|
||||
@@ -462,39 +461,6 @@ def _prune_stale_pending(session: Session, edition_id: uuid.UUID) -> None:
|
||||
session.commit()
|
||||
|
||||
|
||||
async def _read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None:
|
||||
"""Validation partagée par l'inscription immédiate et l'inscription en
|
||||
attente de paiement — `None` si aucun fichier fourni. `Content-Type` est
|
||||
un en-tête fourni par l'appelant, jamais vérifié par lui-même (ce
|
||||
endpoint est public, sans authentification) : un fichier qui ne se
|
||||
décode pas comme une vraie image malgré un `Content-Type` correct
|
||||
(`image/png`/`image/jpeg` revendiqué) est rejeté ici plutôt que de
|
||||
planter plus tard la génération des badges/feuilles de poste
|
||||
(`app/badges/pdf.py`/`app/sheets/pdf.py`, qui lisent ces octets sans
|
||||
filet) — même validation que l'import d'image des badges
|
||||
(`app/api/badges.py:_pixel_size`, réservé à la coordination)."""
|
||||
if photo is None or not photo.filename:
|
||||
return None
|
||||
content_type = photo.content_type or ""
|
||||
if content_type not in _ALLOWED_PHOTO_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "form.photo_type_not_supported"}
|
||||
)
|
||||
content = await photo.read()
|
||||
if len(content) > _MAX_PHOTO_BYTES:
|
||||
raise HTTPException(status_code=413, detail={"code": "form.photo_too_large"})
|
||||
if not content:
|
||||
return None
|
||||
try:
|
||||
with Image.open(io.BytesIO(content)) as img:
|
||||
img.verify()
|
||||
except (UnidentifiedImageError, OSError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "form.photo_type_not_supported"}
|
||||
) from exc
|
||||
return content, content_type
|
||||
|
||||
|
||||
def _filter_owned_ids(
|
||||
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID]
|
||||
) -> list[uuid.UUID]:
|
||||
@@ -688,7 +654,7 @@ async def submit_public_form(
|
||||
else payment_option.amount if payment_option is not None else None
|
||||
)
|
||||
|
||||
photo_data = await _read_photo(photo) if FormFieldType.PHOTO in fields else None
|
||||
photo_data = await read_photo(photo) if FormFieldType.PHOTO in fields else None
|
||||
|
||||
if payment_option is None:
|
||||
# Pas de paiement en ligne (case désactivée, ou « je paierai sur
|
||||
@@ -718,6 +684,11 @@ async def submit_public_form(
|
||||
set_artists(session, volunteer, artist_ids)
|
||||
session.commit()
|
||||
session.refresh(volunteer)
|
||||
logger.info(
|
||||
"Formulaire public : bénévole %s créé (édition %s, sans paiement en ligne)",
|
||||
volunteer.id,
|
||||
edition.id,
|
||||
)
|
||||
if volunteer.email:
|
||||
subject, body = render_registration_confirmation(first_name=volunteer.first_name)
|
||||
background.add_task(
|
||||
@@ -758,6 +729,14 @@ async def submit_public_form(
|
||||
session.add(pending)
|
||||
session.commit()
|
||||
session.refresh(pending)
|
||||
logger.info(
|
||||
"Formulaire public : inscription en attente de paiement %s (édition %s, "
|
||||
"montant %.2f, mécénat=%s)",
|
||||
pending.id,
|
||||
edition.id,
|
||||
pending.payment_amount,
|
||||
pending.requires_mecenat_info,
|
||||
)
|
||||
|
||||
if pending.requires_mecenat_info:
|
||||
return FormSubmissionResult(pending_token=pending.id, requires_mecenat_info=True)
|
||||
@@ -783,19 +762,53 @@ def _create_pending_checkout(
|
||||
amount=pending.payment_amount,
|
||||
currency="EUR",
|
||||
checkout_reference=f"{pending.id}-{uuid.uuid4().hex[:8]}",
|
||||
description=f"{pending.payment_label} — édition {edition.year}",
|
||||
description=_checkout_description(edition, pending),
|
||||
return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook",
|
||||
redirect_url=f"{base}/inscription/{template.access_token}?paiement={pending.id}",
|
||||
)
|
||||
if checkout is None:
|
||||
logger.warning(
|
||||
"Checkout SumUp non créé pour l'inscription en attente %s (édition %s) — "
|
||||
"le bénévole devra réessayer",
|
||||
pending.id,
|
||||
edition.id,
|
||||
)
|
||||
return None
|
||||
pending.checkout_id = checkout.id
|
||||
pending.checkout_status = FeePaymentStatus.PENDING
|
||||
session.add(pending)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"Checkout SumUp %s créé pour l'inscription en attente %s (édition %s)",
|
||||
checkout.id,
|
||||
pending.id,
|
||||
edition.id,
|
||||
)
|
||||
if not checkout.hosted_checkout_url:
|
||||
logger.warning(
|
||||
"Checkout SumUp %s sans hosted_checkout_url — le paiement hébergé "
|
||||
"est-il activé sur le compte marchand ?",
|
||||
checkout.id,
|
||||
)
|
||||
return checkout.hosted_checkout_url
|
||||
|
||||
|
||||
# Longueur prudente : la doc SumUp ne fixe pas de maximum pour `description`.
|
||||
_CHECKOUT_DESCRIPTION_MAX = 255
|
||||
|
||||
|
||||
def _checkout_description(edition: Edition, pending: PendingFormSubmission) -> str:
|
||||
"""Libellé de la transaction côté SumUp (tableau de bord, exports) : le
|
||||
nom du bénévole en tête, pour qu'un paiement s'y rapproche d'un coup
|
||||
d'œil de la bonne personne — l'API de création de checkout n'a pas de
|
||||
champ de métadonnées libre (voir `app/sumup/client.py:create_checkout`).
|
||||
Donnée transmise au prestataire de paiement, pas un log : la règle RGPD
|
||||
de `app/logging_config.py` ne s'applique pas ici."""
|
||||
name = " ".join(p for p in (pending.first_name, pending.last_name) if p)
|
||||
parts = [name, pending.payment_label, f"édition {edition.year}"]
|
||||
return " — ".join(p for p in parts if p)[:_CHECKOUT_DESCRIPTION_MAX]
|
||||
|
||||
|
||||
def _promote_pending(
|
||||
session: Session,
|
||||
edition: Edition,
|
||||
@@ -824,6 +837,8 @@ def _promote_pending(
|
||||
fee_checkout_id=checkout.id,
|
||||
fee_payment_status=FeePaymentStatus.PAID,
|
||||
fee_paid_at=local_now(),
|
||||
fee_transaction_code=checkout.transaction_code,
|
||||
fee_transaction_id=checkout.transaction_id,
|
||||
mecenat_address=pending.mecenat_address,
|
||||
mecenat_city=pending.mecenat_city,
|
||||
mecenat_postal_code=pending.mecenat_postal_code,
|
||||
@@ -866,6 +881,22 @@ def _promote_pending(
|
||||
pending.mecenat_country = None
|
||||
session.add(pending)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"Paiement confirmé : inscription en attente %s → bénévole %s (édition %s, "
|
||||
"checkout %s, transaction %s, reçu mécénat %s)",
|
||||
pending.id,
|
||||
volunteer.id,
|
||||
edition.id,
|
||||
checkout.id,
|
||||
checkout.transaction_code or "inconnue",
|
||||
volunteer.mecenat_receipt_number or "-",
|
||||
)
|
||||
if not checkout.transaction_code:
|
||||
logger.warning(
|
||||
"Checkout SumUp %s payé sans code de transaction renvoyé — rapprochement "
|
||||
"à faire depuis le tableau de bord SumUp",
|
||||
checkout.id,
|
||||
)
|
||||
|
||||
if volunteer.email:
|
||||
# Paiement « mécénat » (adresse postale saisie) : le cerfa rempli part
|
||||
@@ -906,7 +937,10 @@ def _send_paid_confirmation(
|
||||
pdf = generate_mecenat_receipts_pdf([receipt])
|
||||
attachments.append((pdf, "application/pdf", RECEIPT_FILENAME))
|
||||
except Exception:
|
||||
logger.exception("Reçu fiscal mécénat non généré — email envoyé sans pièce jointe")
|
||||
logger.exception(
|
||||
"Reçu fiscal mécénat n° %s non généré — email envoyé sans pièce jointe",
|
||||
receipt.number or "?",
|
||||
)
|
||||
send(to, subject, body, logo=logo, attachments=attachments)
|
||||
|
||||
|
||||
@@ -925,10 +959,21 @@ def _apply_pending_checkout_result(
|
||||
try:
|
||||
new_status = FeePaymentStatus(checkout.status)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Checkout SumUp %s : statut inconnu %r ignoré", checkout.id, checkout.status
|
||||
)
|
||||
return
|
||||
if new_status == FeePaymentStatus.PAID:
|
||||
_promote_pending(session, edition, pending, checkout, background)
|
||||
else:
|
||||
if new_status != pending.checkout_status:
|
||||
logger.info(
|
||||
"Checkout SumUp %s (inscription en attente %s) : %s → %s",
|
||||
checkout.id,
|
||||
pending.id,
|
||||
pending.checkout_status,
|
||||
new_status,
|
||||
)
|
||||
pending.checkout_status = new_status
|
||||
session.add(pending)
|
||||
session.commit()
|
||||
@@ -1043,6 +1088,12 @@ def retry_pending_payment(
|
||||
)
|
||||
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
logger.info(
|
||||
"Relance du paiement de l'inscription en attente %s (édition %s, statut %s)",
|
||||
pending.id,
|
||||
edition.id,
|
||||
pending.checkout_status,
|
||||
)
|
||||
checkout_url = _create_pending_checkout(session, edition, template, pending)
|
||||
return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url)
|
||||
|
||||
@@ -1059,13 +1110,23 @@ def sumup_webhook(
|
||||
identifiant : jamais fait confiance sans relire l'état réel via l'API
|
||||
(voir `app/sumup/client.py:get_checkout`). Répond toujours `200`, y
|
||||
compris sur un événement inconnu — SumUp réessaie sinon inutilement."""
|
||||
logger.info(
|
||||
"Webhook SumUp reçu (édition %s, événement %s, checkout %s)",
|
||||
edition.id,
|
||||
payload.event_type,
|
||||
payload.id,
|
||||
)
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
if not template.sumup_api_key:
|
||||
logger.warning(
|
||||
"Webhook SumUp ignoré : aucune clé API SumUp configurée (édition %s)",
|
||||
edition.id,
|
||||
)
|
||||
return Response(status_code=status.HTTP_200_OK)
|
||||
|
||||
checkout = get_checkout(api_key=template.sumup_api_key, checkout_id=payload.id)
|
||||
if checkout is None:
|
||||
logger.warning("Webhook SumUp : relecture du checkout impossible.")
|
||||
logger.warning("Webhook SumUp : relecture du checkout %s impossible.", payload.id)
|
||||
return Response(status_code=status.HTTP_200_OK)
|
||||
|
||||
pending = session.exec(
|
||||
@@ -1094,6 +1155,21 @@ def sumup_webhook(
|
||||
volunteer.fee_payment_status = new_status
|
||||
if new_status == FeePaymentStatus.PAID and volunteer.fee_paid_at is None:
|
||||
volunteer.fee_paid_at = local_now()
|
||||
if checkout.transaction_code and not volunteer.fee_transaction_code:
|
||||
volunteer.fee_transaction_code = checkout.transaction_code
|
||||
volunteer.fee_transaction_id = checkout.transaction_id
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"Webhook SumUp : bénévole %s (checkout %s) → %s",
|
||||
volunteer.id,
|
||||
payload.id,
|
||||
new_status,
|
||||
)
|
||||
return Response(status_code=status.HTTP_200_OK)
|
||||
logger.warning(
|
||||
"Webhook SumUp : checkout %s inconnu dans l'édition %s (inscription purgée ?)",
|
||||
payload.id,
|
||||
edition.id,
|
||||
)
|
||||
return Response(status_code=status.HTTP_200_OK)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Endpoints d'import : analyse puis exécution."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from pydantic import ValidationError
|
||||
from sqlmodel import Session
|
||||
@@ -17,6 +19,8 @@ from app.importing.schemas import (
|
||||
from app.importing.service import run_import, suggest_mapping, validate_mapping
|
||||
from app.models import Account, Edition
|
||||
|
||||
logger = logging.getLogger("app.importing")
|
||||
|
||||
router = APIRouter(prefix="/editions/{edition_id}/import", tags=["import"])
|
||||
|
||||
_PREVIEW_ROWS = 5
|
||||
@@ -107,9 +111,19 @@ async def execute(
|
||||
},
|
||||
)
|
||||
|
||||
return run_import(
|
||||
report = run_import(
|
||||
session=session,
|
||||
edition=edition,
|
||||
table=table,
|
||||
mapping=parsed_mapping,
|
||||
)
|
||||
logger.info(
|
||||
"Import %s (édition %s) : %d lignes — %d créés, %d mis à jour, %d ignorés",
|
||||
"simulé" if report.dry_run else "exécuté",
|
||||
edition.id,
|
||||
report.total_rows,
|
||||
report.created,
|
||||
report.updated,
|
||||
report.skipped,
|
||||
)
|
||||
return report
|
||||
|
||||
@@ -1,22 +1,36 @@
|
||||
"""Envoi d'emails d'information aux bénévoles (récapitulatif de planning)."""
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import (
|
||||
PlanningNotificationRequest,
|
||||
PlanningNotificationResult,
|
||||
TestEmailRequest,
|
||||
TestEmailResult,
|
||||
)
|
||||
from app.auth.deps import require_coordinator
|
||||
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
|
||||
from app.auth.magic_link import build_magic_link_url
|
||||
from app.auth.permanent_link import permanent_login_token
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.email import edition_logo, render_planning, send_many
|
||||
from app.email import edition_logo, render_planning
|
||||
from app.email.html import render_planning_table
|
||||
from app.email.sender import build_message, deliver_test, send_messages
|
||||
from app.i18n import t
|
||||
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
|
||||
from app.models import Account, Assignment, DayPart, Edition, Position, Role, Shift, Volunteer
|
||||
from app.validation.engine import day_boundary, festival_day
|
||||
|
||||
logger = logging.getLogger("app.notifications")
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/editions/{edition_id}/notifications", tags=["notifications"]
|
||||
@@ -26,16 +40,244 @@ _LOGIN_PLACEHOLDER = "{lien_connexion}"
|
||||
|
||||
|
||||
def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str:
|
||||
"""Lien magique personnel si le bénévole a un compte, sinon l'accueil."""
|
||||
"""Lien de connexion **permanent** du bénévole (`app/auth/
|
||||
permanent_link.py` : connexion directe en un clic, réutilisable, sans
|
||||
expiration, révocable). Envoyer ce lien vaut « donner un accès de
|
||||
connexion » : un bénévole qui n'a pas encore de compte en reçoit un
|
||||
(rôle `VOLUNTEER`, à son email). Si son email est déjà celui d'un autre
|
||||
compte de l'édition (couple, famille partageant une adresse), pas de
|
||||
compte possible : simple lien vers la page de connexion."""
|
||||
account = session.exec(
|
||||
select(Account)
|
||||
.where(Account.edition_id == edition.id)
|
||||
.where(Account.volunteer_id == volunteer.id)
|
||||
.where(Account.password_hash.is_(None))
|
||||
).first()
|
||||
if account is None:
|
||||
return build_magic_link_url("").split("?")[0]
|
||||
return build_magic_link_url(issue_magic_link_token(session, account))
|
||||
if account is None and volunteer.email:
|
||||
email = volunteer.email.strip()
|
||||
taken = session.exec(
|
||||
select(Account.id)
|
||||
.where(Account.edition_id == edition.id)
|
||||
.where(func.lower(Account.email) == email.lower())
|
||||
).first()
|
||||
if taken is None:
|
||||
account = Account(
|
||||
edition_id=edition.id, email=email, role=Role.VOLUNTEER, volunteer_id=volunteer.id
|
||||
)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
logger.info(
|
||||
"Compte créé pour le lien de connexion : bénévole %s (compte %s)",
|
||||
volunteer.id,
|
||||
account.id,
|
||||
)
|
||||
if (
|
||||
account is None
|
||||
or account.password_hash is not None
|
||||
or account.role not in (Role.VOLUNTEER, Role.LEAD)
|
||||
):
|
||||
return _login_page_url()
|
||||
return build_magic_link_url(permanent_login_token(account))
|
||||
|
||||
|
||||
def _login_page_url() -> str:
|
||||
"""Page de connexion, sans jeton."""
|
||||
return build_magic_link_url("").split("?")[0]
|
||||
|
||||
|
||||
# Repère du tableau des créneaux dans la version HTML (caractère d'usage
|
||||
# privé : jamais saisi dans un modèle), remplacé par le vrai tableau.
|
||||
_SHIFTS_MARKER = "\ue000creneaux\ue000"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PlanningContext:
|
||||
"""Ce qui sert à composer le récapitulatif de chaque bénévole, chargé
|
||||
une fois par envoi."""
|
||||
|
||||
positions: dict[uuid.UUID, Position]
|
||||
# Nom + téléphone du référent de chaque poste qui en a un.
|
||||
referents: dict[uuid.UUID, str]
|
||||
shifts_by_volunteer: dict[uuid.UUID, list[Shift]]
|
||||
boundary: dt.time
|
||||
|
||||
|
||||
def _load_context(session: Session, edition: Edition) -> _PlanningContext:
|
||||
positions = {
|
||||
p.id: p for p in session.exec(select(Position).where(Position.edition_id == edition.id))
|
||||
}
|
||||
referents: dict[uuid.UUID, str] = {}
|
||||
for position_id, referent in session.exec(
|
||||
select(Position.id, Volunteer)
|
||||
.join(Account, Account.id == Position.lead_account_id)
|
||||
.join(Volunteer, Volunteer.id == Account.volunteer_id)
|
||||
.where(Position.edition_id == edition.id)
|
||||
):
|
||||
name = f"{referent.first_name} {referent.last_name}".strip()
|
||||
referents[position_id] = f"{name} · {referent.phone}" if referent.phone else name
|
||||
shifts_by_volunteer: dict[uuid.UUID, list[Shift]] = defaultdict(list)
|
||||
for volunteer_id, shift in session.exec(
|
||||
select(Assignment.volunteer_id, Shift)
|
||||
.join(Shift, Shift.id == Assignment.shift_id)
|
||||
.where(Assignment.edition_id == edition.id)
|
||||
):
|
||||
shifts_by_volunteer[volunteer_id].append(shift)
|
||||
boundary = day_boundary(session.exec(select(DayPart).where(DayPart.edition_id == edition.id)))
|
||||
return _PlanningContext(positions, referents, shifts_by_volunteer, boundary)
|
||||
|
||||
|
||||
def _day_label(day: dt.date) -> str:
|
||||
"""« Samedi 11 » — même en-tête de jour que le tableau des badges."""
|
||||
return t("badge.table.day_label", weekday=t(f"badge.weekday.{day.weekday()}"), day=day.day)
|
||||
|
||||
|
||||
def _shift_sections(
|
||||
shifts: list[Shift], ctx: _PlanningContext
|
||||
) -> list[tuple[str, list[tuple[str, str, str]]]]:
|
||||
"""Créneaux regroupés par journée de festival (une nuit reste sur le
|
||||
jour où elle a commencé), comme le tableau des badges :
|
||||
`[(jour, [(horaire, poste, référent)])]`."""
|
||||
by_day: dict[dt.date, list[tuple[str, str, str]]] = defaultdict(list)
|
||||
for s in sorted(shifts, key=lambda s: s.starts_at):
|
||||
by_day[festival_day(s.starts_at, ctx.boundary)].append(
|
||||
(
|
||||
f"{s.starts_at:%H:%M}–{s.ends_at:%H:%M}",
|
||||
ctx.positions[s.position_id].name,
|
||||
ctx.referents.get(s.position_id) or t("email.planning.table.no_referent"),
|
||||
)
|
||||
)
|
||||
return [(_day_label(day), rows) for day, rows in sorted(by_day.items())]
|
||||
|
||||
|
||||
def _shifts_text(sections: list[tuple[str, list[tuple[str, str, str]]]]) -> str:
|
||||
"""Version texte du tableau (clients mail sans HTML) : le jour, puis
|
||||
une ligne par créneau."""
|
||||
if not sections:
|
||||
return t("email.planning.no_shifts")
|
||||
return "\n\n".join(
|
||||
day
|
||||
+ "\n"
|
||||
+ "\n".join(
|
||||
t("email.planning.table.text_line", time=time, position=position, referent=referent)
|
||||
for time, position, referent in rows
|
||||
)
|
||||
for day, rows in sections
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PlanningEmail:
|
||||
subject: str
|
||||
body: str
|
||||
# Corps de la version HTML : `{creneaux}` y devient un vrai tableau.
|
||||
html_body: str
|
||||
html_blocks: dict[str, str]
|
||||
|
||||
|
||||
def _planning_email(
|
||||
*,
|
||||
ctx: _PlanningContext,
|
||||
subject_template: str,
|
||||
body_template: str,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
shifts: list[Shift],
|
||||
login_link: str,
|
||||
) -> _PlanningEmail:
|
||||
"""Récapitulatif de planning d'un bénévole — partagé par l'envoi réel
|
||||
et l'email de test. `{creneaux}` : tableau par jour (horaire, poste,
|
||||
référent avec son téléphone), en HTML et en texte."""
|
||||
minutes = sum((s.ends_at - s.starts_at).total_seconds() / 60 for s in shifts)
|
||||
sections = _shift_sections(shifts, ctx)
|
||||
|
||||
def render(shifts_text: str) -> tuple[str, str]:
|
||||
return render_planning(
|
||||
subject_template=subject_template,
|
||||
body_template=body_template,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
shift_count=len(shifts),
|
||||
hours=round(minutes / 60, 1),
|
||||
shifts_text=shifts_text,
|
||||
login_link=login_link,
|
||||
)
|
||||
|
||||
subject, body = render(_shifts_text(sections))
|
||||
if not sections:
|
||||
return _PlanningEmail(subject, body, body, {})
|
||||
_, html_body = render(_SHIFTS_MARKER)
|
||||
table = render_planning_table(
|
||||
sections,
|
||||
(
|
||||
t("email.planning.table.time"),
|
||||
t("email.planning.table.position"),
|
||||
t("email.planning.table.referent"),
|
||||
),
|
||||
)
|
||||
return _PlanningEmail(subject, body, html_body, {_SHIFTS_MARKER: table})
|
||||
|
||||
|
||||
@router.post("/test", response_model=TestEmailResult)
|
||||
def send_test_email(
|
||||
payload: TestEmailRequest,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> TestEmailResult:
|
||||
"""Envoie tout de suite à l'adresse saisie le récapitulatif de planning
|
||||
**tel que les bénévoles le recevront** : le modèle en cours d'édition
|
||||
(`subject`/`body`), variables remplies avec les données d'un vrai
|
||||
bénévole, même mise en forme et même logo — sujet préfixé « [Test] ».
|
||||
Renvoie le résultat réel du serveur SMTP, pour vérifier à la fois le
|
||||
contenu et la configuration d'envoi depuis l'écran « Messages ».
|
||||
`{lien_connexion}` y est remplacé par l'adresse de la page de connexion,
|
||||
**sans jeton** : un vrai lien magique ouvrirait le compte du bénévole à
|
||||
quiconque lit la boîte de test."""
|
||||
email = payload.email.strip()
|
||||
if "@" not in email or email.startswith("@") or email.endswith("@") or " " in email:
|
||||
raise HTTPException(status_code=422, detail={"code": "notifications.invalid_email"})
|
||||
|
||||
ctx = _load_context(session, edition)
|
||||
shifts_by_volunteer = ctx.shifts_by_volunteer
|
||||
volunteers = list(
|
||||
session.exec(
|
||||
select(Volunteer)
|
||||
.where(Volunteer.edition_id == edition.id)
|
||||
.order_by(Volunteer.last_name, Volunteer.first_name)
|
||||
)
|
||||
)
|
||||
example = next((v for v in volunteers if v.id == payload.volunteer_id), None) or next(
|
||||
(v for v in volunteers if shifts_by_volunteer.get(v.id)),
|
||||
volunteers[0] if volunteers else None,
|
||||
)
|
||||
composed = _planning_email(
|
||||
ctx=ctx,
|
||||
subject_template=payload.subject or t("email.planning.default_subject"),
|
||||
body_template=payload.body or t("email.planning.default_body"),
|
||||
first_name=example.first_name if example else t("email.test.example_first_name"),
|
||||
last_name=example.last_name if example else t("email.test.example_last_name"),
|
||||
shifts=shifts_by_volunteer.get(example.id, []) if example else [],
|
||||
login_link=_login_page_url(),
|
||||
)
|
||||
message = build_message(
|
||||
email,
|
||||
t("email.test.subject_prefix", subject=composed.subject),
|
||||
composed.body,
|
||||
logo=edition_logo(session, edition.id),
|
||||
html_body=composed.html_body,
|
||||
html_blocks=composed.html_blocks,
|
||||
)
|
||||
status, detail = deliver_test(message)
|
||||
settings = get_settings()
|
||||
return TestEmailResult(
|
||||
status=status,
|
||||
detail=detail,
|
||||
smtp_host=f"{settings.smtp_host}:{settings.smtp_port}",
|
||||
smtp_from=settings.smtp_from,
|
||||
example_volunteer_id=example.id if example else None,
|
||||
example_volunteer_name=(
|
||||
f"{example.first_name} {example.last_name}".strip() if example else ""
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/planning", response_model=PlanningNotificationResult)
|
||||
@@ -57,64 +299,51 @@ def send_planning_notification(
|
||||
stmt = stmt.where(Volunteer.id.in_(payload.volunteer_ids))
|
||||
volunteers = list(session.exec(stmt.order_by(Volunteer.last_name)))
|
||||
|
||||
positions = {
|
||||
p.id: p
|
||||
for p in session.exec(
|
||||
select(Position).where(Position.edition_id == edition.id)
|
||||
)
|
||||
}
|
||||
shifts_by_volunteer: dict[uuid.UUID, list[Shift]] = defaultdict(list)
|
||||
for volunteer_id, shift in session.exec(
|
||||
select(Assignment.volunteer_id, Shift)
|
||||
.join(Shift, Shift.id == Assignment.shift_id)
|
||||
.where(Assignment.edition_id == edition.id)
|
||||
):
|
||||
shifts_by_volunteer[volunteer_id].append(shift)
|
||||
ctx = _load_context(session, edition)
|
||||
logo = edition_logo(session, edition.id)
|
||||
|
||||
items: list[tuple[str, str, str]] = []
|
||||
messages: list[EmailMessage] = []
|
||||
recipients: list[uuid.UUID] = []
|
||||
skipped_no_email = 0
|
||||
for volunteer in volunteers:
|
||||
if not volunteer.email:
|
||||
skipped_no_email += 1
|
||||
continue
|
||||
shifts = sorted(
|
||||
shifts_by_volunteer.get(volunteer.id, []),
|
||||
key=lambda s: s.starts_at,
|
||||
)
|
||||
minutes = sum(
|
||||
(s.ends_at - s.starts_at).total_seconds() / 60 for s in shifts
|
||||
)
|
||||
shifts_text = "\n".join(
|
||||
t(
|
||||
"email.planning.shift_line",
|
||||
position=positions[s.position_id].name,
|
||||
start=s.starts_at.strftime("%d/%m %H:%M"),
|
||||
end=s.ends_at.strftime("%d/%m %H:%M"),
|
||||
)
|
||||
for s in shifts
|
||||
) or t("email.planning.no_shifts")
|
||||
subject, body = render_planning(
|
||||
composed = _planning_email(
|
||||
ctx=ctx,
|
||||
subject_template=subject_template,
|
||||
body_template=body_template,
|
||||
first_name=volunteer.first_name,
|
||||
last_name=volunteer.last_name,
|
||||
shift_count=len(shifts),
|
||||
hours=round(minutes / 60, 1),
|
||||
shifts_text=shifts_text,
|
||||
shifts=ctx.shifts_by_volunteer.get(volunteer.id, []),
|
||||
login_link=(
|
||||
_login_link(session, edition, volunteer)
|
||||
if wants_login_link
|
||||
else ""
|
||||
),
|
||||
)
|
||||
items.append((volunteer.email, subject, body))
|
||||
messages.append(
|
||||
build_message(
|
||||
volunteer.email,
|
||||
composed.subject,
|
||||
composed.body,
|
||||
logo=logo,
|
||||
html_body=composed.html_body,
|
||||
html_blocks=composed.html_blocks,
|
||||
)
|
||||
)
|
||||
recipients.append(volunteer.id)
|
||||
|
||||
session.commit() # persiste les éventuels MagicLinkToken émis
|
||||
background.add_task(send_many, items, logo=edition_logo(session, edition.id))
|
||||
session.commit() # persiste les comptes créés pour les liens de connexion
|
||||
logger.info(
|
||||
"Récapitulatif de planning (édition %s) : %d emails en file, %d bénévoles sans email",
|
||||
edition.id,
|
||||
len(messages),
|
||||
skipped_no_email,
|
||||
)
|
||||
background.add_task(send_messages, messages)
|
||||
return PlanningNotificationResult(
|
||||
queued=len(items),
|
||||
queued=len(messages),
|
||||
skipped_no_email=skipped_no_email,
|
||||
recipients=recipients,
|
||||
)
|
||||
|
||||
@@ -17,10 +17,12 @@ from app.api.deps import get_edition
|
||||
from app.api.schemas import PositionSheetsRequest
|
||||
from app.auth.deps import require_coordinator
|
||||
from app.db import get_session
|
||||
from app.i18n import t
|
||||
from app.models import Account, Artist, Assignment, DayPart, Edition, Position, Shift, Volunteer
|
||||
from app.sheets.pdf import (
|
||||
PositionDaySheet,
|
||||
SheetArtist,
|
||||
SheetFooter,
|
||||
SheetShift,
|
||||
SheetVolunteer,
|
||||
generate_position_sheets_pdf,
|
||||
@@ -151,7 +153,14 @@ def generate_position_sheets(
|
||||
status_code=422, detail={"code": "position_sheets.no_shifts"}
|
||||
)
|
||||
|
||||
pdf_bytes = generate_position_sheets_pdf(sheets)
|
||||
# Message de l'organisation (le même que sur l'écran bénévole) en bas de
|
||||
# chaque page.
|
||||
footer = (
|
||||
SheetFooter(title=t("position_sheets.footer_title"), message=edition.volunteer_message)
|
||||
if edition.volunteer_message
|
||||
else None
|
||||
)
|
||||
pdf_bytes = generate_position_sheets_pdf(sheets, footer)
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
|
||||
@@ -10,7 +10,7 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api._common import owned_or_404
|
||||
from app.api._common import owned_or_404, set_message
|
||||
from app.api._shiftgen import generate_missing_shifts
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import (
|
||||
@@ -103,6 +103,8 @@ def update_position(
|
||||
ensure_can_write_position(account, position)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
_check_lead_account(session, edition, changes.get("lead_account_id"))
|
||||
if "message" in changes:
|
||||
set_message(position, "message", changes.pop("message"))
|
||||
for field, value in changes.items():
|
||||
setattr(position, field, value)
|
||||
session.commit()
|
||||
|
||||
+217
-31
@@ -7,8 +7,11 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models import (
|
||||
AssignmentSource,
|
||||
AttendanceStatus,
|
||||
BadgeAlign,
|
||||
BadgeFace,
|
||||
BadgeFieldType,
|
||||
BadgePhotoShape,
|
||||
FeePaymentStatus,
|
||||
FormFieldType,
|
||||
PositionCategory,
|
||||
@@ -33,23 +36,34 @@ class EditionRead(ApiModel):
|
||||
start_date: dt.date
|
||||
end_date: dt.date
|
||||
retention_days: int | None
|
||||
volunteer_message: str | None
|
||||
volunteer_message_updated_at: dt.datetime | None
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
# Plafond d'un message (organisation, poste) : quelques paragraphes, pas un
|
||||
# document.
|
||||
MESSAGE_MAX_LENGTH = 2000
|
||||
|
||||
|
||||
class EditionUpdate(ApiModel):
|
||||
name: str | None = None
|
||||
year: int | None = None
|
||||
start_date: dt.date | None = None
|
||||
end_date: dt.date | None = None
|
||||
retention_days: int | None = None
|
||||
# Vide ou blanc = plus de message.
|
||||
volunteer_message: str | None = Field(default=None, max_length=MESSAGE_MAX_LENGTH)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Volunteer #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class VolunteerCreate(ApiModel):
|
||||
first_name: str
|
||||
last_name: str
|
||||
# Facultatifs à l'ajout manuel par la coordination (fiche complétée plus
|
||||
# tard) ; le formulaire public et l'import les exigent toujours.
|
||||
first_name: str = ""
|
||||
last_name: str = ""
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
comment: str | None = None
|
||||
@@ -74,6 +88,11 @@ class VolunteerUpdate(ApiModel):
|
||||
# Issus du formulaire public (`app/api/forms.py`), éditables ensuite
|
||||
# depuis la fiche — voir CLAUDE.md § 2 (Volunteer).
|
||||
companion_wish_note: str | None = None
|
||||
# Texte libre du badge (champ `FREE_TEXT`), saisi par la coordination.
|
||||
badge_text: str | None = None
|
||||
# Exemplaires du badge sur les planches (1 à 100 ; garde-fou contre une
|
||||
# faute de frappe qui imprimerait des centaines de pages).
|
||||
badge_count: int | None = Field(default=None, ge=1, le=100)
|
||||
self_declared_available: bool | None = None
|
||||
willing_to_pay_fee: bool | None = None
|
||||
|
||||
@@ -91,6 +110,8 @@ class VolunteerRead(ApiModel):
|
||||
wished_companion_ids: list[uuid.UUID]
|
||||
has_photo: bool
|
||||
companion_wish_note: str | None
|
||||
badge_text: str | None
|
||||
badge_count: int
|
||||
self_declared_available: bool
|
||||
willing_to_pay_fee: bool
|
||||
# Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) —
|
||||
@@ -102,6 +123,10 @@ class VolunteerRead(ApiModel):
|
||||
fee_amount: float | None
|
||||
fee_payment_status: FeePaymentStatus | None
|
||||
fee_paid_at: dt.datetime | None
|
||||
# Transaction SumUp ayant réglé la cotisation (code du tableau de bord
|
||||
# SumUp + identifiant technique) — `None` tant que non payée.
|
||||
fee_transaction_code: str | None
|
||||
fee_transaction_id: str | None
|
||||
# Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un
|
||||
# type de paiement « mécénat » (voir `FormPaymentOption.requires_mecenat_info`).
|
||||
mecenat_address: str | None
|
||||
@@ -128,6 +153,9 @@ class VolunteerPlanningEntry(ApiModel):
|
||||
# pas rattaché à une fiche bénévole (pas de téléphone à donner alors).
|
||||
referent_name: str | None
|
||||
referent_phone: str | None
|
||||
# Message du poste (voir `Position.message`).
|
||||
position_message: str | None
|
||||
position_message_updated_at: dt.datetime | None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -174,6 +202,8 @@ class PositionUpdate(ApiModel):
|
||||
location: str | None = None
|
||||
category: PositionCategory | None = None
|
||||
lead_account_id: uuid.UUID | None = None
|
||||
# Vide ou blanc = plus de message.
|
||||
message: str | None = Field(default=None, max_length=MESSAGE_MAX_LENGTH)
|
||||
|
||||
|
||||
class PositionRead(ApiModel):
|
||||
@@ -183,6 +213,8 @@ class PositionRead(ApiModel):
|
||||
location: str | None
|
||||
category: PositionCategory
|
||||
lead_account_id: uuid.UUID | None
|
||||
message: str | None
|
||||
message_updated_at: dt.datetime | None
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
@@ -254,33 +286,6 @@ class DayPartUpdate(ApiModel):
|
||||
order: int | None = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Availability (disponibilités déclarées par le bénévole) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class AvailabilityWindowIn(ApiModel):
|
||||
date: dt.date
|
||||
start_time: dt.time
|
||||
# end_time <= start_time ⇒ la fenêtre se termine le lendemain.
|
||||
end_time: dt.time
|
||||
|
||||
|
||||
class AvailabilitySet(ApiModel):
|
||||
"""Remplace l'intégralité des disponibilités d'un bénévole."""
|
||||
|
||||
windows: list[AvailabilityWindowIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AvailabilityRead(ApiModel):
|
||||
id: uuid.UUID
|
||||
edition_id: uuid.UUID
|
||||
volunteer_id: uuid.UUID
|
||||
date: dt.date
|
||||
start_time: dt.time
|
||||
end_time: dt.time
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Assignment #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -303,10 +308,17 @@ class AssignmentRead(ApiModel):
|
||||
shift_id: uuid.UUID
|
||||
source: AssignmentSource
|
||||
locked: bool
|
||||
attendance: AttendanceStatus | None
|
||||
attendance_marked_at: dt.datetime | None
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
class AttendanceUpdate(ApiModel):
|
||||
# `None` = annuler le pointage.
|
||||
attendance: AttendanceStatus | None
|
||||
|
||||
|
||||
class AssignmentWriteResult(ApiModel):
|
||||
assignment: AssignmentRead
|
||||
warnings: list[Violation] = Field(default_factory=list)
|
||||
@@ -366,6 +378,7 @@ class MatrixResponse(ApiModel):
|
||||
# Badges (planches PDF) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class BadgeFieldIn(ApiModel):
|
||||
face: BadgeFace = BadgeFace.FRONT
|
||||
field_type: BadgeFieldType
|
||||
x_mm: float
|
||||
y_mm: float
|
||||
@@ -375,11 +388,21 @@ class BadgeFieldIn(ApiModel):
|
||||
align: BadgeAlign = BadgeAlign.LEFT
|
||||
bold: bool = False
|
||||
color: str = "#000000"
|
||||
# `FREE_TEXT` seulement (voir `BadgeField.text`), ignoré sinon.
|
||||
text: str | None = None
|
||||
# Fond de la zone (`#rrggbb`), `None` = transparent — voir `BadgeField`.
|
||||
bg_color: str | None = None
|
||||
auto_fit: bool = False
|
||||
# `PHOTO` seulement, ignoré sinon.
|
||||
photo_shape: BadgePhotoShape = BadgePhotoShape.RECT
|
||||
# Ordre d'empilement sur la face (voir `BadgeField.z_order`).
|
||||
z_order: int = 0
|
||||
|
||||
|
||||
class BadgeFieldsSet(ApiModel):
|
||||
"""Remplace l'intégralité des champs du recto — au plus un par
|
||||
`field_type` (décocher un champ dans l'éditeur retire sa ligne)."""
|
||||
"""Remplace l'intégralité des champs du recto et du verso — au plus un
|
||||
par (`face`, `field_type`) (décocher un champ dans l'éditeur retire sa
|
||||
ligne)."""
|
||||
|
||||
fields: list[BadgeFieldIn] = Field(default_factory=list)
|
||||
|
||||
@@ -493,6 +516,33 @@ class PlanningNotificationResult(ApiModel):
|
||||
recipients: list[uuid.UUID] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TestEmailRequest(ApiModel):
|
||||
email: str
|
||||
# Modèle du récapitulatif de planning en cours d'édition (mêmes
|
||||
# variables que `PlanningNotificationRequest`) ; absent = modèle par
|
||||
# défaut.
|
||||
subject: str | None = None
|
||||
body: str | None = None
|
||||
# Bénévole dont les données remplissent les variables ; absent = le
|
||||
# premier (par nom) qui a au moins un créneau.
|
||||
volunteer_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class TestEmailResult(ApiModel):
|
||||
"""`status` : `sent` / `disabled` / `connection_failed` / `rejected` —
|
||||
voir `app/email/sender.py:deliver_test`. `detail` : message du serveur
|
||||
SMTP en cas d'échec."""
|
||||
|
||||
status: str
|
||||
detail: str | None = None
|
||||
smtp_host: str
|
||||
smtp_from: str
|
||||
# Bénévole dont les données ont rempli le modèle (`None` : exemple
|
||||
# fictif, l'édition n'a encore aucun bénévole).
|
||||
example_volunteer_id: uuid.UUID | None = None
|
||||
example_volunteer_name: str
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Formulaire public d'inscription #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -647,3 +697,139 @@ class SumUpWebhookPayload(ApiModel):
|
||||
|
||||
event_type: str
|
||||
id: str
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Statistiques d'une édition (`GET /editions/{id}/stats`, app/api/stats.py) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class StatsRegistrationDay(ApiModel):
|
||||
date: dt.date
|
||||
count: int
|
||||
cumulative: int
|
||||
|
||||
|
||||
class StatsRegistrations(ApiModel):
|
||||
"""Bénévoles en base, par jour d'ajout (`Volunteer.created_at`) — jours
|
||||
sans inscription inclus (à 0), pour une courbe sans trou."""
|
||||
|
||||
total: int
|
||||
last_7_days: int
|
||||
by_day: list[StatsRegistrationDay]
|
||||
|
||||
|
||||
class StatsAnswers(ApiModel):
|
||||
"""Nombre de bénévoles ayant renseigné chaque information (sur `total`)."""
|
||||
|
||||
total: int
|
||||
email: int
|
||||
phone: int
|
||||
photo: int
|
||||
comment: int
|
||||
companion_note: int
|
||||
available: int
|
||||
position_wishes: int
|
||||
artist_wishes: int
|
||||
companion_wishes: int
|
||||
|
||||
|
||||
class StatsPaymentOption(ApiModel):
|
||||
label: str
|
||||
mecenat: bool
|
||||
count: int
|
||||
amount_total: float
|
||||
|
||||
|
||||
class StatsPayments(ApiModel):
|
||||
"""Mode de paiement (même règle que `lib/fee.ts:feeMode`) : `online`
|
||||
(paiement en ligne confirmé), `on_site`, `none`. `pending_unfinished` :
|
||||
formulaires remplis dont le paiement en ligne n'a jamais abouti — aucun
|
||||
bénévole créé pour eux (`PendingFormSubmission` non promue)."""
|
||||
|
||||
online: int
|
||||
on_site: int
|
||||
none: int
|
||||
pending_unfinished: int
|
||||
total_collected: float
|
||||
mecenat_count: int
|
||||
mecenat_total: float
|
||||
by_option: list[StatsPaymentOption]
|
||||
|
||||
|
||||
class StatsArtistWish(ApiModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
date: dt.date
|
||||
count: int
|
||||
|
||||
|
||||
class StatsNeedDay(ApiModel):
|
||||
"""Par journée de festival (`validation/engine.py:festival_day`) : heures
|
||||
× personnes demandées (effectif minimal / idéal), heures déjà affectées."""
|
||||
|
||||
date: dt.date
|
||||
shifts: int
|
||||
min_hours: float
|
||||
ideal_hours: float
|
||||
assigned_hours: float
|
||||
|
||||
|
||||
class StatsNeedPosition(ApiModel):
|
||||
"""Places (personnes × créneaux) demandées et pourvues pour un poste, et
|
||||
nombre de bénévoles qui l'ont mis dans leurs vœux."""
|
||||
|
||||
position_id: uuid.UUID
|
||||
name: str
|
||||
shifts: int
|
||||
min_slots: int
|
||||
ideal_slots: int
|
||||
assigned: int
|
||||
# Places pourvues, plafonnées créneau par créneau à l'effectif idéal : un
|
||||
# sureffectif ne compense jamais un trou ailleurs.
|
||||
filled: int
|
||||
wishes: int
|
||||
|
||||
|
||||
class StatsNeeds(ApiModel):
|
||||
by_day: list[StatsNeedDay]
|
||||
by_position: list[StatsNeedPosition]
|
||||
volunteers_with_assignment: int
|
||||
|
||||
|
||||
class StatsAttendanceCounts(ApiModel):
|
||||
"""Pointage (`Assignment.attendance`) d'un ensemble d'affectations :
|
||||
`assigned` = `present` + `absent` + `unmarked`."""
|
||||
|
||||
assigned: int = 0
|
||||
present: int = 0
|
||||
absent: int = 0
|
||||
unmarked: int = 0
|
||||
|
||||
|
||||
class StatsAttendancePosition(ApiModel):
|
||||
position_id: uuid.UUID
|
||||
name: str
|
||||
# Créneaux terminés (`ends_at <= as_of`) et créneaux en cours.
|
||||
past: StatsAttendanceCounts
|
||||
live: StatsAttendanceCounts
|
||||
live_shifts: int
|
||||
|
||||
|
||||
class StatsAttendance(ApiModel):
|
||||
"""Présents / absents pointés par les référents, par poste : sur les
|
||||
créneaux déjà terminés et sur ceux en cours à `as_of` (heure du calcul,
|
||||
locale). Les créneaux à venir ne comptent pas."""
|
||||
|
||||
as_of: dt.datetime
|
||||
past: StatsAttendanceCounts
|
||||
live: StatsAttendanceCounts
|
||||
live_shifts: int
|
||||
by_position: list[StatsAttendancePosition]
|
||||
|
||||
|
||||
class EditionStats(ApiModel):
|
||||
registrations: StatsRegistrations
|
||||
answers: StatsAnswers
|
||||
payments: StatsPayments
|
||||
artist_wishes: list[StatsArtistWish]
|
||||
needs: StatsNeeds
|
||||
attendance: StatsAttendance
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
coordination affecte elle-même, dans l'écran habituel, en tenant compte des
|
||||
bénévoles remontés en tête de liste."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -14,6 +16,8 @@ from app.db import get_session
|
||||
from app.models import Account, Edition
|
||||
from app.solver import build_solver_input, solve
|
||||
|
||||
logger = logging.getLogger("app.solver")
|
||||
|
||||
router = APIRouter(prefix="/editions/{edition_id}/solver", tags=["solver"])
|
||||
|
||||
|
||||
@@ -23,8 +27,20 @@ def run_solver(
|
||||
_guard: Account = Depends(require_staff),
|
||||
session: Session = Depends(get_session),
|
||||
) -> SolverRunResult:
|
||||
started = time.monotonic()
|
||||
data = build_solver_input(session=session, edition=edition)
|
||||
loaded = time.monotonic()
|
||||
solution = solve(data)
|
||||
logger.info(
|
||||
"Solveur (édition %s) : %d créneaux, %d bénévoles, %d suggestions — "
|
||||
"chargement %d ms, résolution %d ms",
|
||||
edition.id,
|
||||
len(data.shifts),
|
||||
len(data.volunteers),
|
||||
len(solution.suggestions),
|
||||
(loaded - started) * 1000,
|
||||
(time.monotonic() - loaded) * 1000,
|
||||
)
|
||||
return SolverRunResult(
|
||||
suggestions=[
|
||||
SolverSuggestion(
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Statistiques d'une édition : inscriptions (nombre, évolution), réponses au
|
||||
formulaire, paiements de la cotisation, besoins face aux disponibilités.
|
||||
|
||||
Lecture seule, recalculée à chaque appel (quelques centaines de lignes au
|
||||
plus pour un festival de 50 à 200 bénévoles) — rien n'est stocké.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import (
|
||||
EditionStats,
|
||||
StatsAnswers,
|
||||
StatsArtistWish,
|
||||
StatsAttendance,
|
||||
StatsAttendanceCounts,
|
||||
StatsAttendancePosition,
|
||||
StatsNeedDay,
|
||||
StatsNeedPosition,
|
||||
StatsNeeds,
|
||||
StatsPaymentOption,
|
||||
StatsPayments,
|
||||
StatsRegistrationDay,
|
||||
StatsRegistrations,
|
||||
)
|
||||
from app.auth.deps import require_coordinator
|
||||
from app.db import get_session
|
||||
from app.models import (
|
||||
Account,
|
||||
Artist,
|
||||
Assignment,
|
||||
AttendanceStatus,
|
||||
DayPart,
|
||||
Edition,
|
||||
FeePaymentStatus,
|
||||
PendingFormSubmission,
|
||||
Position,
|
||||
Shift,
|
||||
Volunteer,
|
||||
VolunteerArtist,
|
||||
VolunteerCompanionWish,
|
||||
VolunteerPositionWish,
|
||||
local_now,
|
||||
)
|
||||
from app.validation.engine import day_boundary, festival_day
|
||||
|
||||
router = APIRouter(prefix="/editions/{edition_id}/stats", tags=["stats"])
|
||||
|
||||
|
||||
def _hours(starts_at: dt.datetime, ends_at: dt.datetime) -> float:
|
||||
return (ends_at - starts_at).total_seconds() / 3600
|
||||
|
||||
|
||||
def _round(value: float) -> float:
|
||||
return round(value, 2)
|
||||
|
||||
|
||||
def _registrations(volunteers: list[Volunteer]) -> StatsRegistrations:
|
||||
per_day = Counter(v.created_at.date() for v in volunteers)
|
||||
by_day: list[StatsRegistrationDay] = []
|
||||
if per_day:
|
||||
day, last, cumulative = min(per_day), max(per_day), 0
|
||||
while day <= last:
|
||||
cumulative += per_day[day]
|
||||
by_day.append(
|
||||
StatsRegistrationDay(date=day, count=per_day[day], cumulative=cumulative)
|
||||
)
|
||||
day += dt.timedelta(days=1)
|
||||
since = local_now() - dt.timedelta(days=7)
|
||||
return StatsRegistrations(
|
||||
total=len(volunteers),
|
||||
last_7_days=sum(1 for v in volunteers if v.created_at >= since),
|
||||
by_day=by_day,
|
||||
)
|
||||
|
||||
|
||||
def _payments(
|
||||
volunteers: list[Volunteer], pending_unfinished: int
|
||||
) -> StatsPayments:
|
||||
# Même règle que `lib/fee.ts:feeMode` / `exporting/xlsx.py:_payment_mode`.
|
||||
online = [v for v in volunteers if v.fee_payment_status is not None]
|
||||
paid = [v for v in online if v.fee_payment_status == FeePaymentStatus.PAID]
|
||||
by_label: dict[str, list[Volunteer]] = defaultdict(list)
|
||||
for v in paid:
|
||||
by_label[v.fee_payment_label or ""].append(v)
|
||||
mecenat = [v for v in paid if v.mecenat_address]
|
||||
return StatsPayments(
|
||||
online=len(online),
|
||||
on_site=sum(
|
||||
1 for v in volunteers if v.fee_payment_status is None and v.willing_to_pay_fee
|
||||
),
|
||||
none=sum(
|
||||
1 for v in volunteers if v.fee_payment_status is None and not v.willing_to_pay_fee
|
||||
),
|
||||
pending_unfinished=pending_unfinished,
|
||||
total_collected=_round(sum(v.fee_amount or 0 for v in paid)),
|
||||
mecenat_count=len(mecenat),
|
||||
mecenat_total=_round(sum(v.fee_amount or 0 for v in mecenat)),
|
||||
by_option=[
|
||||
StatsPaymentOption(
|
||||
label=label,
|
||||
mecenat=any(v.mecenat_address for v in group),
|
||||
count=len(group),
|
||||
amount_total=_round(sum(v.fee_amount or 0 for v in group)),
|
||||
)
|
||||
for label, group in sorted(by_label.items())
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _count(counts: StatsAttendanceCounts, assignment: Assignment) -> None:
|
||||
counts.assigned += 1
|
||||
if assignment.attendance == AttendanceStatus.PRESENT:
|
||||
counts.present += 1
|
||||
elif assignment.attendance == AttendanceStatus.ABSENT:
|
||||
counts.absent += 1
|
||||
else:
|
||||
counts.unmarked += 1
|
||||
|
||||
|
||||
def _attendance(
|
||||
positions: list[Position], shifts: list[Shift], assignments: list[Assignment]
|
||||
) -> StatsAttendance:
|
||||
"""Pointage par poste, sur les créneaux terminés et ceux en cours."""
|
||||
now = local_now()
|
||||
past = StatsAttendanceCounts()
|
||||
live = StatsAttendanceCounts()
|
||||
by_position = {
|
||||
p.id: StatsAttendancePosition(
|
||||
position_id=p.id,
|
||||
name=p.name,
|
||||
past=StatsAttendanceCounts(),
|
||||
live=StatsAttendanceCounts(),
|
||||
live_shifts=0,
|
||||
)
|
||||
for p in positions
|
||||
}
|
||||
# Créneau terminé ⇒ « past », en cours ⇒ « live », à venir : ignoré.
|
||||
phase: dict = {}
|
||||
for s in shifts:
|
||||
if s.ends_at <= now:
|
||||
phase[s.id] = ("past", s.position_id)
|
||||
elif s.starts_at <= now:
|
||||
phase[s.id] = ("live", s.position_id)
|
||||
by_position[s.position_id].live_shifts += 1
|
||||
for a in assignments:
|
||||
if a.shift_id not in phase:
|
||||
continue
|
||||
when, position_id = phase[a.shift_id]
|
||||
row = by_position[position_id]
|
||||
_count(past if when == "past" else live, a)
|
||||
_count(row.past if when == "past" else row.live, a)
|
||||
return StatsAttendance(
|
||||
as_of=now,
|
||||
past=past,
|
||||
live=live,
|
||||
live_shifts=sum(r.live_shifts for r in by_position.values()),
|
||||
by_position=[
|
||||
r for r in by_position.values() if r.past.assigned or r.live.assigned or r.live_shifts
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=EditionStats)
|
||||
def get_stats(
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> EditionStats:
|
||||
volunteers = list(session.exec(select(Volunteer).where(Volunteer.edition_id == edition.id)))
|
||||
volunteer_ids = [v.id for v in volunteers]
|
||||
positions = list(
|
||||
session.exec(
|
||||
select(Position).where(Position.edition_id == edition.id).order_by(Position.name)
|
||||
)
|
||||
)
|
||||
shifts = list(session.exec(select(Shift).where(Shift.edition_id == edition.id)))
|
||||
assignments = list(
|
||||
session.exec(select(Assignment).where(Assignment.edition_id == edition.id))
|
||||
)
|
||||
artists = list(
|
||||
session.exec(
|
||||
select(Artist)
|
||||
.where(Artist.edition_id == edition.id)
|
||||
.order_by(Artist.performance_date, Artist.start_time)
|
||||
)
|
||||
)
|
||||
position_wishes = (
|
||||
list(
|
||||
session.exec(
|
||||
select(VolunteerPositionWish).where(
|
||||
VolunteerPositionWish.volunteer_id.in_(volunteer_ids)
|
||||
)
|
||||
)
|
||||
)
|
||||
if volunteer_ids
|
||||
else []
|
||||
)
|
||||
artist_wishes = (
|
||||
list(
|
||||
session.exec(
|
||||
select(VolunteerArtist).where(VolunteerArtist.volunteer_id.in_(volunteer_ids))
|
||||
)
|
||||
)
|
||||
if volunteer_ids
|
||||
else []
|
||||
)
|
||||
companion_wishers = (
|
||||
set(
|
||||
session.exec(
|
||||
select(VolunteerCompanionWish.volunteer_id).where(
|
||||
VolunteerCompanionWish.volunteer_id.in_(volunteer_ids)
|
||||
)
|
||||
)
|
||||
)
|
||||
if volunteer_ids
|
||||
else set()
|
||||
)
|
||||
pending_unfinished = len(
|
||||
session.exec(
|
||||
select(PendingFormSubmission.id).where(
|
||||
PendingFormSubmission.edition_id == edition.id,
|
||||
PendingFormSubmission.promoted_volunteer_id.is_(None),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
boundary = day_boundary(
|
||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||
)
|
||||
|
||||
answers = StatsAnswers(
|
||||
total=len(volunteers),
|
||||
email=sum(1 for v in volunteers if v.email),
|
||||
phone=sum(1 for v in volunteers if v.phone),
|
||||
photo=sum(1 for v in volunteers if v.photo is not None),
|
||||
comment=sum(1 for v in volunteers if v.comment),
|
||||
companion_note=sum(1 for v in volunteers if v.companion_wish_note),
|
||||
available=sum(1 for v in volunteers if v.self_declared_available),
|
||||
position_wishes=len({w.volunteer_id for w in position_wishes}),
|
||||
artist_wishes=len({w.volunteer_id for w in artist_wishes}),
|
||||
companion_wishes=len(companion_wishers),
|
||||
)
|
||||
|
||||
fans = Counter(w.artist_id for w in artist_wishes)
|
||||
artist_stats = [
|
||||
StatsArtistWish(id=a.id, name=a.name, date=a.performance_date, count=fans[a.id])
|
||||
for a in artists
|
||||
]
|
||||
|
||||
# Besoins par journée de festival : une soirée qui passe minuit reste sur
|
||||
# le jour où elle a commencé (CLAUDE.md § 3).
|
||||
shifts_by_id = {s.id: s for s in shifts}
|
||||
day_rows: dict[dt.date, dict] = defaultdict(
|
||||
lambda: {
|
||||
"shifts": 0, "min_hours": 0.0, "ideal_hours": 0.0, "assigned_hours": 0.0,
|
||||
}
|
||||
)
|
||||
for s in shifts:
|
||||
row = day_rows[festival_day(s.starts_at, boundary)]
|
||||
hours = _hours(s.starts_at, s.ends_at)
|
||||
row["shifts"] += 1
|
||||
row["min_hours"] += hours * s.min_staff
|
||||
row["ideal_hours"] += hours * s.ideal_staff
|
||||
for a in assignments:
|
||||
s = shifts_by_id.get(a.shift_id)
|
||||
if s is not None:
|
||||
day_rows[festival_day(s.starts_at, boundary)]["assigned_hours"] += _hours(
|
||||
s.starts_at, s.ends_at
|
||||
)
|
||||
|
||||
wishers = Counter(w.position_id for w in position_wishes)
|
||||
assigned_by_shift = Counter(a.shift_id for a in assignments)
|
||||
shifts_by_position: dict = defaultdict(list)
|
||||
for s in shifts:
|
||||
shifts_by_position[s.position_id].append(s)
|
||||
|
||||
needs = StatsNeeds(
|
||||
by_day=[
|
||||
StatsNeedDay(
|
||||
date=day,
|
||||
shifts=row["shifts"],
|
||||
min_hours=_round(row["min_hours"]),
|
||||
ideal_hours=_round(row["ideal_hours"]),
|
||||
assigned_hours=_round(row["assigned_hours"]),
|
||||
)
|
||||
for day, row in sorted(day_rows.items())
|
||||
],
|
||||
by_position=[
|
||||
StatsNeedPosition(
|
||||
position_id=p.id,
|
||||
name=p.name,
|
||||
shifts=len(shifts_by_position[p.id]),
|
||||
min_slots=sum(s.min_staff for s in shifts_by_position[p.id]),
|
||||
ideal_slots=sum(s.ideal_staff for s in shifts_by_position[p.id]),
|
||||
assigned=sum(assigned_by_shift[s.id] for s in shifts_by_position[p.id]),
|
||||
filled=sum(
|
||||
min(assigned_by_shift[s.id], s.ideal_staff) for s in shifts_by_position[p.id]
|
||||
),
|
||||
wishes=wishers[p.id],
|
||||
)
|
||||
for p in positions
|
||||
],
|
||||
volunteers_with_assignment=len({a.volunteer_id for a in assignments}),
|
||||
)
|
||||
|
||||
return EditionStats(
|
||||
registrations=_registrations(volunteers),
|
||||
answers=answers,
|
||||
payments=_payments(volunteers, pending_unfinished),
|
||||
artist_wishes=artist_stats,
|
||||
needs=needs,
|
||||
attendance=_attendance(positions, shifts, assignments),
|
||||
)
|
||||
@@ -5,12 +5,13 @@ L'email n'est pas unique : plusieurs bénévoles peuvent en partager un même
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api._common import (
|
||||
owned_or_404,
|
||||
read_photo,
|
||||
replace_links,
|
||||
resolve_owned_ids,
|
||||
set_artists,
|
||||
@@ -316,6 +317,8 @@ def get_volunteer_planning(
|
||||
position_location=p.location,
|
||||
referent_name=f"{r.first_name} {r.last_name}" if r else None,
|
||||
referent_phone=r.phone if r else None,
|
||||
position_message=p.message,
|
||||
position_message_updated_at=p.message_updated_at,
|
||||
)
|
||||
for a, s, p, r in rows
|
||||
]
|
||||
@@ -342,6 +345,44 @@ def get_volunteer_photo(
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{volunteer_id}/photo", response_model=VolunteerRead)
|
||||
async def set_volunteer_photo(
|
||||
volunteer_id: uuid.UUID,
|
||||
file: UploadFile = File(...),
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> VolunteerRead:
|
||||
"""Ajout (ou remplacement) de la photo par la coordination, depuis la
|
||||
fiche — pour un bénévole qui n'en a pas envoyé au formulaire public ou
|
||||
ajouté à la main. Même validation que le formulaire (`read_photo`)."""
|
||||
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||
photo = await read_photo(file)
|
||||
if photo is None:
|
||||
raise HTTPException(status_code=400, detail={"code": "volunteer.empty_photo"})
|
||||
volunteer.photo, volunteer.photo_content_type = photo
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
session.refresh(volunteer)
|
||||
return _read(session, volunteer)
|
||||
|
||||
|
||||
@router.delete("/{volunteer_id}/photo", response_model=VolunteerRead)
|
||||
def delete_volunteer_photo(
|
||||
volunteer_id: uuid.UUID,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> VolunteerRead:
|
||||
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||
volunteer.photo = None
|
||||
volunteer.photo_content_type = None
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
session.refresh(volunteer)
|
||||
return _read(session, volunteer)
|
||||
|
||||
|
||||
@router.patch("/{volunteer_id}", response_model=VolunteerRead)
|
||||
def update_volunteer(
|
||||
volunteer_id: uuid.UUID,
|
||||
|
||||
@@ -4,7 +4,7 @@ import uuid
|
||||
from collections.abc import Callable
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlmodel import Session
|
||||
|
||||
@@ -31,6 +31,7 @@ def _forbidden() -> HTTPException:
|
||||
|
||||
|
||||
def get_current_account(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Account:
|
||||
@@ -44,6 +45,9 @@ def get_current_account(
|
||||
account = session.get(Account, account_id)
|
||||
if account is None:
|
||||
raise _unauthorized()
|
||||
# Repris sur la ligne d'accès de la requête (`app/request_logging.py`) :
|
||||
# qui a rencontré l'erreur, par identifiant seulement.
|
||||
request.state.account_id = account.id
|
||||
return account
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Lien de connexion permanent — celui du récapitulatif de planning
|
||||
(`{lien_connexion}`, `app/api/notifications.py`).
|
||||
|
||||
Contrairement au lien magique de la page de connexion (`magic_link.py` :
|
||||
aléatoire, haché en base, 30 min, usage unique), ce lien se réutilise sans
|
||||
limite de durée : le bénévole retrouve son planning en un clic depuis
|
||||
n'importe quel email reçu. Il n'est stocké nulle part : c'est une signature
|
||||
HMAC (clé `JWT_SECRET`) de l'identifiant du compte et de sa
|
||||
`login_link_version`, donc le même lien figure dans tous les emails d'un
|
||||
bénévole. Révocation : incrémenter la version (`POST .../accounts/{id}/
|
||||
reset-login-link`) invalide tous les liens déjà envoyés à ce compte ;
|
||||
changer `JWT_SECRET` les invalide tous. Bénévoles et référents seulement,
|
||||
jamais la coordination ni un compte à mot de passe."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import uuid
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Account, Role
|
||||
|
||||
# Jamais la coordination, même sans mot de passe : son accès complet ne
|
||||
# doit pas tenir à un lien qui circule par email.
|
||||
_ALLOWED_ROLES = (Role.VOLUNTEER, Role.LEAD)
|
||||
|
||||
PREFIX = "pl."
|
||||
|
||||
|
||||
def _signature(account_id: uuid.UUID, version: int) -> str:
|
||||
digest = hmac.new(
|
||||
get_settings().jwt_secret.encode("utf-8"),
|
||||
f"permanent-login:{account_id}:{version}".encode(),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def permanent_login_token(account: Account) -> str:
|
||||
"""Jeton en clair du lien permanent d'un compte (toujours le même tant
|
||||
que sa version ne change pas)."""
|
||||
return f"{PREFIX}{account.id.hex}.{_signature(account.id, account.login_link_version)}"
|
||||
|
||||
|
||||
def account_from_permanent_token(session: Session, token: str) -> Account | None:
|
||||
"""Compte désigné par un jeton permanent valide, `None` sinon (format,
|
||||
compte inconnu, signature ou version périmée, compte à mot de passe ou
|
||||
de la coordination)."""
|
||||
if not token.startswith(PREFIX):
|
||||
return None
|
||||
raw_id, _, signature = token[len(PREFIX) :].partition(".")
|
||||
try:
|
||||
account_id = uuid.UUID(hex=raw_id)
|
||||
except ValueError:
|
||||
return None
|
||||
account = session.get(Account, account_id)
|
||||
if account is None or account.password_hash is not None or account.role not in _ALLOWED_ROLES:
|
||||
return None
|
||||
expected = _signature(account.id, account.login_link_version)
|
||||
if not hmac.compare_digest(signature, expected):
|
||||
return None
|
||||
return account
|
||||
+423
-37
@@ -8,27 +8,43 @@ Format du badge : dérivé de l'image de fond du recto (pas de format fixe
|
||||
imposé) — voir `image_size_mm`. Tant qu'aucune image n'est importée,
|
||||
`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.
|
||||
l'éditeur. Planche : grille sur page A4 portrait. Chaque page de recto est
|
||||
suivie de sa page de verso (même image de fond à chaque emplacement rempli,
|
||||
plus les champs du verso de chaque bénévole) — impression recto-verso
|
||||
classique, retournement sur le bord long : sur la page de verso, chaque
|
||||
badge est placé en miroir horizontal (colonne `cols - 1 - col`, même rangée)
|
||||
pour tomber exactement derrière son recto — voir `_cell_origin`.
|
||||
"""
|
||||
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
from reportlab.lib.colors import HexColor
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
DEFAULT_BADGE_WIDTH_MM = 85.6
|
||||
DEFAULT_BADGE_HEIGHT_MM = 54.0
|
||||
# Minimums pour compter combien de badges tiennent sur la page (marge
|
||||
# d'impression, écart entre deux badges) ; l'espace effectivement laissé est
|
||||
# ensuite réparti à parts égales — voir `_spacing`.
|
||||
_MARGIN_MM = 10.0
|
||||
_GUTTER_MM = 4.0
|
||||
_LINE_HEIGHT_FACTOR = 1.25
|
||||
# Tableau (champ « postes ») : marges intérieures des cellules et épaisseur
|
||||
# des filets.
|
||||
_CELL_PAD_X_MM = 0.8
|
||||
_CELL_PAD_Y_MM = 0.5
|
||||
_TABLE_LINE_WIDTH_PT = 0.4
|
||||
# Réduction automatique de la taille du texte (`auto_fit`) : pas et plancher.
|
||||
_AUTO_FIT_STEP_PT = 0.5
|
||||
_AUTO_FIT_MIN_PT = 4.0
|
||||
# Photo absente ou illisible : initiales sur ce fond, à cette taille (part de
|
||||
# la largeur du cadre).
|
||||
|
||||
# Résolution supposée d'une image de fond importée, pour convertir ses pixels
|
||||
# en millimètres réels (donc en taille de badge) — 300 dpi est la résolution
|
||||
@@ -46,11 +62,40 @@ def image_size_mm(width_px: int, height_px: int, dpi: float = ASSUMED_DPI) -> tu
|
||||
return (width_px / dpi * 25.4, height_px / dpi * 25.4)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BadgeTableSection:
|
||||
"""Un bloc du tableau : une ligne d'en-tête sur toute la largeur (le
|
||||
jour), puis ses lignes (horaire, poste)."""
|
||||
|
||||
title: str
|
||||
rows: tuple[tuple[str, ...], ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BadgeTable:
|
||||
"""Contenu d'un champ affiché en tableau plutôt qu'en texte libre : les
|
||||
créneaux du bénévole regroupés par jour."""
|
||||
|
||||
sections: tuple[BadgeTableSection, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BadgeFieldSpec:
|
||||
"""Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n`
|
||||
possibles — ex. un poste par ligne avec son horaire), positionné depuis
|
||||
le coin haut-gauche du badge (comme dans l'éditeur visuel du front-end)."""
|
||||
possibles — ex. prénom puis nom), positionné depuis le coin haut-gauche
|
||||
du badge (comme dans l'éditeur visuel du front-end).
|
||||
|
||||
`is_photo` : le champ est la photo du bénévole (`image`, ses octets
|
||||
bruts tels que stockés) plutôt qu'un texte, découpée selon
|
||||
`photo_shape` (`RECT`/`OVAL`). Sans photo (ou photo illisible), rien
|
||||
n'est imprimé : le cadre reste vide.
|
||||
|
||||
`table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`.
|
||||
|
||||
`bg_color` : fond plein de la zone du champ (`None` = transparent ;
|
||||
ignoré pour la photo). `auto_fit` : réduit la taille du texte jusqu'à ce
|
||||
que tout tienne dans le cadre (sinon, les lignes en trop ne sont pas
|
||||
imprimées). `valign` : `TOP` ou `MIDDLE` (centré verticalement)."""
|
||||
|
||||
text: str
|
||||
x_mm: float
|
||||
@@ -61,13 +106,22 @@ class BadgeFieldSpec:
|
||||
align: str # "LEFT" | "CENTER" | "RIGHT"
|
||||
bold: bool
|
||||
color: str # "#rrggbb"
|
||||
is_photo: bool = False
|
||||
image: bytes | None = None
|
||||
table: BadgeTable | None = None
|
||||
bg_color: str | None = None
|
||||
auto_fit: bool = False
|
||||
valign: str = "TOP" # "TOP" | "MIDDLE"
|
||||
photo_shape: str = "RECT" # "RECT" | "OVAL"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BadgeSpec:
|
||||
"""Un badge = un bénévole, avec ses champs déjà résolus."""
|
||||
"""Un badge = un bénévole, avec ses champs déjà résolus : `fields` pour
|
||||
le recto, `back_fields` pour le verso."""
|
||||
|
||||
fields: tuple[BadgeFieldSpec, ...]
|
||||
back_fields: tuple[BadgeFieldSpec, ...] = ()
|
||||
|
||||
|
||||
def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]:
|
||||
@@ -78,14 +132,45 @@ def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]
|
||||
return cols, rows
|
||||
|
||||
|
||||
def _spacing(count: int, page_mm: float, size_mm: float) -> tuple[float, float]:
|
||||
"""`(marge extérieure, écart entre badges)` sur un axe, en mm : l'espace
|
||||
libre est réparti **à parts égales** — même écart à gauche, entre chaque
|
||||
badge et à droite (resp. en haut, entre les rangées et en bas) — pour une
|
||||
grille centrée et symétrique. Si cet écart passait sous la marge
|
||||
d'impression (`_MARGIN_MM`), la marge est gardée de chaque côté et le
|
||||
reste réparti entre les badges : toujours symétrique. Avant, la grille
|
||||
partait du coin haut-gauche et tout l'espace restant tombait à droite et
|
||||
en bas ; une fois la feuille retournée, le verso se décalait d'autant par
|
||||
rapport au recto (bug corrigé une fois)."""
|
||||
free = page_mm - count * size_mm
|
||||
even = free / (count + 1)
|
||||
if even >= _MARGIN_MM or count == 1:
|
||||
return (even, even) if count > 1 else (free / 2, 0.0)
|
||||
return _MARGIN_MM, (free - 2 * _MARGIN_MM) / (count - 1)
|
||||
|
||||
|
||||
def _cell_origin(
|
||||
index: int, cols: int, badge_width_mm: float, badge_height_mm: float
|
||||
index: int,
|
||||
cols: int,
|
||||
badge_width_mm: float,
|
||||
badge_height_mm: float,
|
||||
mirrored: bool = False,
|
||||
) -> tuple[float, float]:
|
||||
"""Coin bas-gauche (repère PDF) de l'emplacement `index` sur la page,
|
||||
en mm depuis le coin bas-gauche de la page."""
|
||||
en mm depuis le coin bas-gauche de la page.
|
||||
|
||||
`mirrored` : page de verso. Une feuille retournée sur son bord long
|
||||
inverse gauche et droite : le verso du badge de la colonne `col` doit
|
||||
être imprimé en colonne `cols - 1 - col` (même rangée) pour tomber
|
||||
derrière son recto — y compris sur une dernière planche incomplète."""
|
||||
col, row = index % cols, index // cols
|
||||
x0 = _MARGIN_MM + col * (badge_width_mm + _GUTTER_MM)
|
||||
y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * badge_height_mm - row * _GUTTER_MM
|
||||
if mirrored:
|
||||
col = cols - 1 - col
|
||||
rows = _grid_size(badge_width_mm, badge_height_mm)[1]
|
||||
outer_x, gap_x = _spacing(cols, _PAGE_WIDTH_MM, badge_width_mm)
|
||||
outer_y, gap_y = _spacing(rows, _PAGE_HEIGHT_MM, badge_height_mm)
|
||||
x0 = outer_x + col * (badge_width_mm + gap_x)
|
||||
y0 = _PAGE_HEIGHT_MM - outer_y - (row + 1) * badge_height_mm - row * gap_y
|
||||
return x0, y0
|
||||
|
||||
|
||||
@@ -114,36 +199,335 @@ def _draw_background(
|
||||
)
|
||||
|
||||
|
||||
def _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None:
|
||||
"""Recadre la photo au ratio du cadre (centrée, sans déformation — une
|
||||
photo de téléphone n'a jamais le ratio du cadre) et la réduit à la
|
||||
résolution d'impression : une photo de plusieurs Mo répétée sur 200
|
||||
badges alourdirait inutilement le PDF. Applique l'orientation EXIF (une
|
||||
photo prise en portrait au téléphone est souvent stockée couchée).
|
||||
`None` si l'image est illisible : le badge s'imprime sans photo plutôt
|
||||
que de faire échouer toute la planche."""
|
||||
target = (
|
||||
max(1, round(width_mm / 25.4 * ASSUMED_DPI)),
|
||||
max(1, round(height_mm / 25.4 * ASSUMED_DPI)),
|
||||
)
|
||||
try:
|
||||
with Image.open(io.BytesIO(image)) as img:
|
||||
# JPEG : décodage directement à taille réduite (bien plus rapide
|
||||
# qu'une photo de téléphone décodée en pleine résolution).
|
||||
img.draft("RGB", (target[0] * 2, target[1] * 2))
|
||||
fitted = ImageOps.fit(ImageOps.exif_transpose(img).convert("RGB"), target)
|
||||
except (UnidentifiedImageError, OSError):
|
||||
return None
|
||||
out = io.BytesIO()
|
||||
fitted.save(out, format="JPEG", quality=88)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def _frame_rect(
|
||||
f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""Cadre du champ en points, repère PDF : (gauche, bas, largeur,
|
||||
hauteur). `f.y_mm` est le haut du cadre depuis le HAUT du badge ;
|
||||
reportlab dessine depuis le BAS de la page."""
|
||||
return (
|
||||
(x0_mm + f.x_mm) * mm,
|
||||
(y0_mm + badge_height_mm - f.y_mm - f.height_mm) * mm,
|
||||
f.width_mm * mm,
|
||||
f.height_mm * mm,
|
||||
)
|
||||
|
||||
|
||||
def _shape_path(c: canvas.Canvas, shape: str, x: float, y: float, w: float, h: float):
|
||||
path = c.beginPath()
|
||||
if shape == "OVAL":
|
||||
path.ellipse(x, y, w, h)
|
||||
else:
|
||||
path.rect(x, y, w, h)
|
||||
return path
|
||||
|
||||
|
||||
# Photos déjà recadrées pendant une génération, par (photo, largeur,
|
||||
# hauteur du cadre) : un bénévole a souvent la même photo au recto, au verso
|
||||
# et sur plusieurs exemplaires (`badge_count`) — elle n'est traitée qu'une
|
||||
# fois.
|
||||
PhotoCache = dict[tuple[bytes, float, float], bytes | None]
|
||||
|
||||
|
||||
def _draw_photo(
|
||||
c: canvas.Canvas,
|
||||
f: BadgeFieldSpec,
|
||||
x0_mm: float,
|
||||
y0_mm: float,
|
||||
badge_height_mm: float,
|
||||
cache: PhotoCache,
|
||||
) -> None:
|
||||
x, y, w, h = _frame_rect(f, x0_mm, y0_mm, badge_height_mm)
|
||||
photo = None
|
||||
if f.image:
|
||||
key = (f.image, f.width_mm, f.height_mm)
|
||||
if key not in cache:
|
||||
cache[key] = _fit_photo(f.image, f.width_mm, f.height_mm)
|
||||
photo = cache[key]
|
||||
if photo is None:
|
||||
# Pas de photo (ou illisible) : cadre laissé vide, choix de la
|
||||
# coordination — avant, les initiales étaient imprimées à la place.
|
||||
return
|
||||
c.saveState()
|
||||
if f.photo_shape == "OVAL":
|
||||
# Découpe en ellipse inscrite dans le cadre.
|
||||
c.clipPath(_shape_path(c, "OVAL", x, y, w, h), stroke=0, fill=0)
|
||||
c.drawImage(ImageReader(io.BytesIO(photo)), x, y, width=w, height=h)
|
||||
c.restoreState()
|
||||
|
||||
|
||||
def _draw_zone_background(
|
||||
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||||
) -> None:
|
||||
if not f.bg_color:
|
||||
return
|
||||
c.saveState()
|
||||
c.setFillColor(HexColor(f.bg_color))
|
||||
c.rect(*_frame_rect(f, x0_mm, y0_mm, badge_height_mm), stroke=0, fill=1)
|
||||
c.restoreState()
|
||||
|
||||
|
||||
def wrap_text(text: str, font_name: str, font_size: float, max_width_pt: float) -> list[str]:
|
||||
"""Découpe `text` en lignes qui tiennent dans `max_width_pt` : les `\\n`
|
||||
du texte restent des retours à la ligne, puis chaque ligne trop longue
|
||||
passe à la ligne entre deux mots — ou au milieu d'un mot qui, seul, ne
|
||||
tient pas (nom très long dans un cadre étroit). Sans cela, un prénom +
|
||||
nom ou une ligne de poste avec horaire débordait du cadre du champ."""
|
||||
|
||||
def fits(candidate: str) -> bool:
|
||||
return stringWidth(candidate, font_name, font_size) <= max_width_pt
|
||||
|
||||
lines: list[str] = []
|
||||
for paragraph in text.split("\n"):
|
||||
current = ""
|
||||
# `split(" ")` (et non `split()`) garde les doubles espaces voulus,
|
||||
# ex. entre le nom du poste et son horaire.
|
||||
for word in paragraph.split(" "):
|
||||
candidate = f"{current} {word}" if current else word
|
||||
if fits(candidate):
|
||||
current = candidate
|
||||
continue
|
||||
if current:
|
||||
lines.append(current.rstrip())
|
||||
current = word
|
||||
while current and not fits(current):
|
||||
cut = len(current) - 1
|
||||
while cut > 1 and not fits(current[:cut]):
|
||||
cut -= 1
|
||||
lines.append(current[:cut])
|
||||
current = current[cut:]
|
||||
lines.append(current.rstrip())
|
||||
return lines
|
||||
|
||||
|
||||
def _column_widths(natural: list[float], available: float) -> list[float]:
|
||||
"""Largeurs des colonnes d'un tableau dans `available` : une colonne
|
||||
courte (jour, horaire) garde sa largeur naturelle, les plus longues
|
||||
(poste, référent) se partagent le reste à parts égales et passent à la
|
||||
ligne. S'il reste de la place, la dernière colonne la prend."""
|
||||
widths = [0.0] * len(natural)
|
||||
pending = sorted(range(len(natural)), key=lambda i: natural[i])
|
||||
remaining = available
|
||||
while pending:
|
||||
share = remaining / len(pending)
|
||||
if natural[pending[0]] > share:
|
||||
for i in pending:
|
||||
widths[i] = share
|
||||
return widths
|
||||
i = pending.pop(0)
|
||||
widths[i] = natural[i]
|
||||
remaining -= natural[i]
|
||||
widths[-1] += remaining
|
||||
return widths
|
||||
|
||||
|
||||
_TableLine = tuple[list[list[str]], str, bool, float]
|
||||
|
||||
|
||||
def _table_layout(
|
||||
table: BadgeTable, width_pt: float, font_size: float
|
||||
) -> tuple[list[float], list[_TableLine]]:
|
||||
"""Largeurs des colonnes et lignes du tableau à `font_size` : pour
|
||||
chaque ligne, (texte replié de chaque cellule, police, en-tête de
|
||||
section ?, hauteur en points)."""
|
||||
regular, bold = "Helvetica", "Helvetica-Bold"
|
||||
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
|
||||
line_height = font_size * _LINE_HEIGHT_FACTOR
|
||||
body = [row for section in table.sections for row in section.rows]
|
||||
columns = max(len(row) for row in body) if body else 1
|
||||
natural = [
|
||||
max(stringWidth(row[col], regular, font_size) for row in body) + 2 * pad_x
|
||||
for col in range(columns)
|
||||
] if body else [0.0]
|
||||
widths = _column_widths(natural, width_pt)
|
||||
total_width = sum(widths)
|
||||
|
||||
# Un en-tête n'a qu'une cellule, sur toute la largeur.
|
||||
cells_to_draw = []
|
||||
for section in table.sections:
|
||||
cells_to_draw.append(((section.title,), bold, True))
|
||||
cells_to_draw.extend((row, regular, False) for row in section.rows)
|
||||
|
||||
lines: list[_TableLine] = []
|
||||
for cells, font, is_title in cells_to_draw:
|
||||
cell_widths = [total_width] if is_title else widths
|
||||
wrapped = [
|
||||
wrap_text(text, font, font_size, max(1.0, cell_widths[col] - 2 * pad_x))
|
||||
for col, text in enumerate(cells)
|
||||
]
|
||||
row_height = max(len(w) for w in wrapped) * line_height + 2 * pad_y
|
||||
lines.append((wrapped, font, is_title, row_height))
|
||||
return widths, lines
|
||||
|
||||
|
||||
def _draw_table(
|
||||
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||||
) -> None:
|
||||
"""Tableau dans le cadre du champ : pour chaque section, une ligne
|
||||
d'en-tête en gras sur toute la largeur (le jour), puis ses lignes ;
|
||||
filets à la couleur du champ, texte de chaque cellule replié dans sa
|
||||
colonne (`wrap_text`). Une ligne qui ne tient plus dans la hauteur du
|
||||
cadre n'est pas dessinée, ni les suivantes — comme les lignes d'un champ
|
||||
texte — sauf avec `auto_fit`, qui réduit la taille du texte jusqu'à ce
|
||||
que toutes les lignes tiennent (plancher `_AUTO_FIT_MIN_PT`)."""
|
||||
table = f.table
|
||||
if table is None or not table.sections:
|
||||
return
|
||||
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
|
||||
width_pt, height_pt = f.width_mm * mm, f.height_mm * mm
|
||||
font_size = f.font_size
|
||||
widths, lines_to_draw = _table_layout(table, width_pt, font_size)
|
||||
if f.auto_fit:
|
||||
while (
|
||||
sum(line[3] for line in lines_to_draw) > height_pt + 0.01
|
||||
and font_size - _AUTO_FIT_STEP_PT >= _AUTO_FIT_MIN_PT
|
||||
):
|
||||
font_size -= _AUTO_FIT_STEP_PT
|
||||
widths, lines_to_draw = _table_layout(table, width_pt, font_size)
|
||||
line_height = font_size * _LINE_HEIGHT_FACTOR
|
||||
total_width = sum(widths)
|
||||
|
||||
left = (x0_mm + f.x_mm) * mm
|
||||
top = (y0_mm + badge_height_mm - f.y_mm) * mm
|
||||
bottom_limit = top - height_pt
|
||||
color = HexColor(f.color)
|
||||
c.setFillColor(color)
|
||||
c.setStrokeColor(color)
|
||||
c.setLineWidth(_TABLE_LINE_WIDTH_PT)
|
||||
|
||||
y = top
|
||||
for wrapped, font, is_title, row_height in lines_to_draw:
|
||||
cell_widths = [total_width] if is_title else widths
|
||||
if y - row_height < bottom_limit - 0.01:
|
||||
break # la ligne déborderait du cadre du champ
|
||||
c.setFont(font, font_size)
|
||||
x = left
|
||||
for col, lines in enumerate(wrapped):
|
||||
for i, line in enumerate(lines):
|
||||
baseline = y - pad_y - font_size * 0.85 - i * line_height
|
||||
c.drawString(x + pad_x, baseline, line)
|
||||
x += cell_widths[col]
|
||||
if not is_title:
|
||||
# Séparations de colonnes, sur la hauteur de cette ligne seulement.
|
||||
x = left
|
||||
for width in widths[:-1]:
|
||||
x += width
|
||||
c.line(x, y, x, y - row_height)
|
||||
y -= row_height
|
||||
c.line(left, y, left + total_width, y)
|
||||
|
||||
if y == top:
|
||||
return # pas même le premier en-tête ne tient
|
||||
c.rect(left, y, total_width, top - y, stroke=1, fill=0)
|
||||
|
||||
|
||||
def _lines_that_fit(font_size: float, height_mm: float) -> int:
|
||||
"""Nombre de lignes de texte qui tiennent dans `height_mm` (première
|
||||
ligne de base à 0,85 × la taille sous le haut du cadre, puis un
|
||||
interligne par ligne)."""
|
||||
font_size_mm = font_size / mm
|
||||
first = font_size_mm * 0.85
|
||||
if first > height_mm + 1e-9:
|
||||
return 0
|
||||
return 1 + int((height_mm - first) / (font_size_mm * _LINE_HEIGHT_FACTOR) + 1e-9)
|
||||
|
||||
|
||||
def fit_text(
|
||||
text: str, font_name: str, font_size: float, width_mm: float, height_mm: float,
|
||||
auto_fit: bool,
|
||||
) -> tuple[float, list[str]]:
|
||||
"""Taille retenue et lignes à imprimer d'un champ texte. Sans
|
||||
`auto_fit` : la taille du champ, lignes en trop retirées. Avec : taille
|
||||
réduite par pas de `_AUTO_FIT_STEP_PT` (plancher `_AUTO_FIT_MIN_PT`)
|
||||
jusqu'à ce que toutes les lignes repliées tiennent dans le cadre."""
|
||||
lines = wrap_text(text, font_name, font_size, width_mm * mm)
|
||||
if auto_fit:
|
||||
while (
|
||||
len(lines) > _lines_that_fit(font_size, height_mm)
|
||||
and font_size - _AUTO_FIT_STEP_PT >= _AUTO_FIT_MIN_PT
|
||||
):
|
||||
font_size -= _AUTO_FIT_STEP_PT
|
||||
lines = wrap_text(text, font_name, font_size, width_mm * mm)
|
||||
return font_size, lines[: _lines_that_fit(font_size, height_mm)]
|
||||
|
||||
|
||||
def _draw_text(
|
||||
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||||
) -> None:
|
||||
font_name = "Helvetica-Bold" if f.bold else "Helvetica"
|
||||
font_size, lines = fit_text(
|
||||
f.text, font_name, f.font_size, f.width_mm, f.height_mm, f.auto_fit
|
||||
)
|
||||
if not lines:
|
||||
return
|
||||
c.setFont(font_name, font_size)
|
||||
c.setFillColor(HexColor(f.color))
|
||||
left_x = x0_mm + f.x_mm
|
||||
# `font_size` est en points (unité reportlab) ; le reste du calcul
|
||||
# travaille en mm ⇒ on convertit d'abord (1 pt = 1/mm mm, `mm` étant
|
||||
# ici le facteur de conversion pt/mm de reportlab).
|
||||
font_size_mm = font_size / mm
|
||||
line_height_mm = font_size_mm * _LINE_HEIGHT_FACTOR
|
||||
# `f.y_mm` est depuis le HAUT du badge (repère écran) ; reportlab
|
||||
# dessine depuis le BAS de la page.
|
||||
top_y = y0_mm + badge_height_mm - f.y_mm
|
||||
if f.valign == "MIDDLE":
|
||||
# Hauteur du bloc : interlignes entre les lignes + une hauteur de
|
||||
# caractère pour la dernière.
|
||||
block = (len(lines) - 1) * line_height_mm + font_size_mm
|
||||
top_y -= max(0.0, (f.height_mm - block) / 2)
|
||||
for i, line in enumerate(lines):
|
||||
baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm
|
||||
if f.align == "CENTER":
|
||||
c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, line)
|
||||
elif f.align == "RIGHT":
|
||||
c.drawRightString((left_x + f.width_mm) * mm, baseline_y * mm, line)
|
||||
else:
|
||||
c.drawString(left_x * mm, baseline_y * mm, line)
|
||||
|
||||
|
||||
def _draw_fields(
|
||||
c: canvas.Canvas,
|
||||
fields: tuple[BadgeFieldSpec, ...],
|
||||
x0_mm: float,
|
||||
y0_mm: float,
|
||||
badge_height_mm: float,
|
||||
photos: PhotoCache,
|
||||
) -> None:
|
||||
for f in fields:
|
||||
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)
|
||||
if f.is_photo:
|
||||
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm, photos)
|
||||
continue
|
||||
_draw_zone_background(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||
if f.table is not None:
|
||||
_draw_table(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||
else:
|
||||
_draw_text(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||
|
||||
|
||||
def generate_badge_sheets_pdf(
|
||||
@@ -161,16 +545,18 @@ def generate_badge_sheets_pdf(
|
||||
|
||||
buffer = io.BytesIO()
|
||||
c = canvas.Canvas(buffer, pagesize=A4)
|
||||
photos: PhotoCache = {}
|
||||
for page_badges in pages:
|
||||
for i, badge in enumerate(page_badges):
|
||||
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
|
||||
_draw_background(c, front_image, x0, y0, badge_width_mm, badge_height_mm)
|
||||
_draw_fields(c, badge.fields, x0, y0, badge_height_mm)
|
||||
_draw_fields(c, badge.fields, x0, y0, badge_height_mm, photos)
|
||||
c.showPage()
|
||||
|
||||
for i in range(len(page_badges)):
|
||||
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
|
||||
for i, badge in enumerate(page_badges):
|
||||
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm, mirrored=True)
|
||||
_draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm)
|
||||
_draw_fields(c, badge.back_fields, x0, y0, badge_height_mm, photos)
|
||||
c.showPage()
|
||||
c.save()
|
||||
return buffer.getvalue()
|
||||
|
||||
+63
-10
@@ -9,6 +9,8 @@ que dans le texte déjà journalisable). Pas de CSS externe ni de police web
|
||||
en ligne, polices système en repli du style « Archivo »/« DM Mono »."""
|
||||
|
||||
import html as _html
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
_INK = "#14110E"
|
||||
_CREAM = "#F5EFE1"
|
||||
@@ -26,21 +28,72 @@ _FONT_MONO = "'Courier New', Courier, monospace"
|
||||
LOGO_CID = "edition-logo"
|
||||
|
||||
|
||||
def _paragraphs(body: str) -> str:
|
||||
_P_OPEN = '<p style="margin:0 0 14px;">'
|
||||
|
||||
|
||||
def _paragraphs(body: str, blocks: Mapping[str, str]) -> str:
|
||||
"""`\n\n` sépare les paragraphes, un simple `\n` reste un retour à la
|
||||
ligne — même convention que les modèles texte (`app/email/templates.py`)."""
|
||||
blocks = body.strip().split("\n\n")
|
||||
return "".join(
|
||||
f'<p style="margin:0 0 14px;">{_html.escape(block).replace(chr(10), "<br>")}</p>'
|
||||
for block in blocks
|
||||
if block.strip()
|
||||
ligne — même convention que les modèles texte (`app/email/templates.py`).
|
||||
`blocks` : repère → HTML déjà construit (ex. tableau des créneaux),
|
||||
inséré tel quel à la place du repère, hors de tout paragraphe."""
|
||||
out = []
|
||||
for block in body.strip().split("\n\n"):
|
||||
if not block.strip():
|
||||
continue
|
||||
html = _P_OPEN + _html.escape(block).replace(chr(10), "<br>") + "</p>"
|
||||
for marker, fragment in blocks.items():
|
||||
html = html.replace(_html.escape(marker), f"</p>{fragment}{_P_OPEN}")
|
||||
# Paragraphes vidés par l'insertion d'un bloc (repère seul, ou
|
||||
# entouré de retours à la ligne).
|
||||
html = re.sub(rf"{re.escape(_P_OPEN)}(?:<br>|\s)*</p>", "", html)
|
||||
out.append(html)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
_CELL = f"padding:6px 8px;border:1px solid {_INK};vertical-align:top;"
|
||||
|
||||
|
||||
def render_planning_table(
|
||||
sections: Sequence[tuple[str, Sequence[tuple[str, str, str]]]],
|
||||
headers: tuple[str, str, str],
|
||||
) -> str:
|
||||
"""Tableau des créneaux d'un bénévole, comme sur les badges : le jour
|
||||
en en-tête sur toute la largeur, puis une ligne par créneau (horaire,
|
||||
poste, référent). `sections` = `[(jour, [(horaire, poste, référent)])]`."""
|
||||
head = "".join(
|
||||
f'<th align="left" style="{_CELL}background:{_INK};color:{_CREAM};'
|
||||
f'font-family:{_FONT_DISPLAY};font-size:11px;text-transform:uppercase;">'
|
||||
f"{_html.escape(h)}</th>"
|
||||
for h in headers
|
||||
)
|
||||
rows = [f"<tr>{head}</tr>"]
|
||||
for day, lines in sections:
|
||||
rows.append(
|
||||
f'<tr><td colspan="3" style="{_CELL}background:{_YELLOW};'
|
||||
f'font-family:{_FONT_DISPLAY};font-weight:800;">{_html.escape(day)}</td></tr>'
|
||||
)
|
||||
for time, position, referent in lines:
|
||||
rows.append(
|
||||
f'<tr><td style="{_CELL}white-space:nowrap;font-weight:700;">'
|
||||
f"{_html.escape(time)}</td>"
|
||||
f'<td style="{_CELL}font-family:{_FONT_DISPLAY};font-weight:700;">'
|
||||
f"{_html.escape(position)}</td>"
|
||||
f'<td style="{_CELL}">{_html.escape(referent).replace(chr(10), "<br>")}</td></tr>'
|
||||
)
|
||||
return (
|
||||
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
|
||||
f'style="border-collapse:collapse;margin:0 0 14px;font-size:12px;'
|
||||
f'font-family:{_FONT_MONO};background:{_PAPER};">' + "".join(rows) + "</table>"
|
||||
)
|
||||
|
||||
|
||||
def render_html(*, subject: str, body: str, has_logo: bool) -> str:
|
||||
def render_html(
|
||||
*, subject: str, body: str, has_logo: bool, blocks: Mapping[str, str] | None = None
|
||||
) -> str:
|
||||
"""`has_logo` : voir `app/email/branding.py:edition_logo` — `False` tant
|
||||
que l'édition n'a pas importé de logo, l'en-tête retombe alors sur un
|
||||
simple bandeau texte (le sujet, en capitales)."""
|
||||
simple bandeau texte (le sujet, en capitales). `blocks` : voir
|
||||
`_paragraphs`."""
|
||||
if has_logo:
|
||||
header = (
|
||||
f'<img src="cid:{LOGO_CID}" alt="" width="160" '
|
||||
@@ -72,7 +125,7 @@ def render_html(*, subject: str, body: str, has_logo: bool) -> str:
|
||||
<td style="background:{_PAPER};border:3px solid {_INK};
|
||||
border-top:none;padding:22px;font-size:13px;
|
||||
line-height:1.6;color:{_INK};font-family:{_FONT_MONO};">
|
||||
{_paragraphs(body)}
|
||||
{_paragraphs(body, blocks or {})}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -8,7 +8,7 @@ Pour diagnostiquer : `python -m app.email.check <destinataire>`."""
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
from collections.abc import Iterable, Sequence
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from email.message import EmailMessage
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
@@ -44,8 +44,15 @@ def build_message(
|
||||
*,
|
||||
logo: tuple[bytes, str] | None = None,
|
||||
attachments: Sequence[Attachment] = (),
|
||||
html_body: str | None = None,
|
||||
html_blocks: Mapping[str, str] | None = None,
|
||||
) -> EmailMessage:
|
||||
"""`logo` = (octets, content-type) (voir `app/email/branding.py:
|
||||
"""`html_body` / `html_blocks` : corps de la version HTML quand il
|
||||
diffère du texte — repères remplacés par des blocs HTML déjà construits
|
||||
(ex. tableau des créneaux, `app/email/html.py:render_planning_table`) ;
|
||||
le texte brut garde sa propre version lisible.
|
||||
|
||||
`logo` = (octets, content-type) (voir `app/email/branding.py:
|
||||
edition_logo`) : ajoute une version HTML au thème de l'appli
|
||||
(`app/email/html.py`) en alternative au texte brut — le texte reste la
|
||||
version de référence, envoyée telle quelle si aucun client ne préfère le
|
||||
@@ -61,7 +68,12 @@ def build_message(
|
||||
message["Subject"] = subject
|
||||
message.set_content(body)
|
||||
message.add_alternative(
|
||||
render_html(subject=subject, body=body, has_logo=logo is not None),
|
||||
render_html(
|
||||
subject=subject,
|
||||
body=html_body if html_body is not None else body,
|
||||
has_logo=logo is not None,
|
||||
blocks=html_blocks,
|
||||
),
|
||||
subtype="html",
|
||||
)
|
||||
if logo is not None:
|
||||
@@ -145,6 +157,39 @@ def deliver_many(messages: list[EmailMessage]) -> None:
|
||||
logger.info("Emails remis : %d/%d", sent, len(messages))
|
||||
|
||||
|
||||
def deliver_test(message: EmailMessage) -> tuple[str, str | None]:
|
||||
"""Envoi d'un email de test, **synchrone** et sans « best effort » :
|
||||
renvoie `(statut, détail)` au lieu de seulement journaliser, pour que la
|
||||
coordination voie tout de suite ce qui ne va pas (`POST
|
||||
.../notifications/test`). Statuts : `sent`, `disabled`
|
||||
(`EMAIL_ENABLED=false`), `connection_failed` (connexion ou
|
||||
authentification), `rejected` (le serveur a refusé le message). Détail :
|
||||
message du serveur ou type d'erreur — jamais l'adresse du destinataire
|
||||
(`_smtp_error_summary`)."""
|
||||
settings = get_settings()
|
||||
if not settings.email_enabled:
|
||||
logger.info("Email de test non envoyé (EMAIL_ENABLED=false)")
|
||||
return "disabled", None
|
||||
try:
|
||||
smtp = open_smtp(settings)
|
||||
except smtplib.SMTPException as exc:
|
||||
logger.error("Email de test : connexion SMTP refusée : %s", _smtp_error_summary(exc))
|
||||
return "connection_failed", _smtp_error_summary(exc)
|
||||
except OSError as exc:
|
||||
logger.error("Email de test : SMTP injoignable : %s", exc)
|
||||
return "connection_failed", f"{exc.__class__.__name__} : {exc}"
|
||||
with smtp:
|
||||
try:
|
||||
smtp.send_message(message)
|
||||
except smtplib.SMTPException as exc:
|
||||
logger.error(
|
||||
"Email de test refusé (From=%s) : %s", message["From"], _smtp_error_summary(exc)
|
||||
)
|
||||
return "rejected", _smtp_error_summary(exc)
|
||||
logger.info("Email de test remis")
|
||||
return "sent", None
|
||||
|
||||
|
||||
def send(
|
||||
to: str,
|
||||
subject: str,
|
||||
@@ -156,6 +201,12 @@ def send(
|
||||
deliver_many([build_message(to, subject, body, logo=logo, attachments=attachments)])
|
||||
|
||||
|
||||
def send_messages(messages: list[EmailMessage]) -> None:
|
||||
"""Remet des messages déjà construits (`build_message`) — ex. le
|
||||
récapitulatif de planning, dont la version HTML porte un tableau."""
|
||||
deliver_many(messages)
|
||||
|
||||
|
||||
def send_many(
|
||||
items: Iterable[tuple[str, str, str]], *, logo: tuple[bytes, str] | None = None
|
||||
) -> None:
|
||||
|
||||
@@ -29,11 +29,15 @@ _HEADER = [
|
||||
"Montant (€)",
|
||||
"Statut du paiement",
|
||||
"Payée le",
|
||||
"Transaction SumUp (code)",
|
||||
"Transaction SumUp (identifiant)",
|
||||
"Mécénat — adresse",
|
||||
"Mécénat — ville",
|
||||
"Mécénat — code postal",
|
||||
"Mécénat — pays",
|
||||
"Commentaire",
|
||||
"Texte du badge",
|
||||
"Nombre de badges",
|
||||
"Inscrit le",
|
||||
]
|
||||
|
||||
@@ -114,11 +118,15 @@ def generate_volunteers_xlsx(
|
||||
v.fee_amount if v.fee_amount is not None else "",
|
||||
_FEE_STATUS_LABELS.get(v.fee_payment_status, ""),
|
||||
_fmt_dt(v.fee_paid_at),
|
||||
_safe_text(v.fee_transaction_code or ""),
|
||||
_safe_text(v.fee_transaction_id or ""),
|
||||
_safe_text(v.mecenat_address or ""),
|
||||
_safe_text(v.mecenat_city or ""),
|
||||
_safe_text(v.mecenat_postal_code or ""),
|
||||
_safe_text(v.mecenat_country or ""),
|
||||
_safe_text(v.comment or ""),
|
||||
_safe_text(v.badge_text or ""),
|
||||
v.badge_count,
|
||||
_fmt_dt(v.created_at),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -43,9 +43,6 @@
|
||||
"import.row.updated_existing": "Bénévole existant mis à jour.",
|
||||
"import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l'édition.",
|
||||
"validation.SHIFT_OVERLAP": "Chevauchement avec un autre créneau du bénévole.",
|
||||
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées le {date} ({uncovered_minutes} min non couvertes).",
|
||||
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h d'affectation cumulées le {date}.",
|
||||
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux.",
|
||||
"notification.no_recipients": "Aucun bénévole ciblé.",
|
||||
"email.magic_link.subject": "Ton lien de connexion",
|
||||
"email.magic_link.body": "Bonjour,\n\nVoici ton lien de connexion, valable {minutes} minutes :\n\n{link}\n\nSi tu n'es pas à l'origine de cette demande, ignore simplement cet email.",
|
||||
@@ -56,20 +53,36 @@
|
||||
"email.registration_confirmation_paid.mecenat_receipt_note": "Tu trouveras en pièce jointe ton reçu fiscal au titre des dons (cerfa n° 11580*05), à conserver pour ta déclaration de revenus.",
|
||||
"email.planning.default_subject": "Ton planning bénévole",
|
||||
"email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !",
|
||||
"email.test.subject_prefix": "[Test] {subject}",
|
||||
"email.test.example_first_name": "Camille",
|
||||
"email.test.example_last_name": "Exemple",
|
||||
"notifications.invalid_email": "Adresse email invalide.",
|
||||
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
|
||||
"email.planning.shift_line": "- {position} : {start} → {end}",
|
||||
"availability.date_outside_edition": "La date {date} est hors des dates de l'édition.",
|
||||
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes.",
|
||||
"email.planning.table.time": "Horaire",
|
||||
"email.planning.table.position": "Poste",
|
||||
"email.planning.table.referent": "Référent",
|
||||
"email.planning.table.no_referent": "à désigner",
|
||||
"email.planning.table.text_line": " {time} · {position} · référent : {referent}",
|
||||
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
|
||||
"badge.image_unreadable": "L'image est illisible ou corrompue.",
|
||||
"badge.empty_image": "L'image est vide.",
|
||||
"badge.image_too_large": "L'image est trop volumineuse.",
|
||||
"badge.duplicate_field_type": "Le champ « {field_type} » ne peut apparaître qu'une fois.",
|
||||
"badge.invalid_size": "La taille du badge doit être strictement positive.",
|
||||
"badge.no_fields": "Aucun champ n'est configuré sur le recto du badge.",
|
||||
"badge.invalid_color": "La couleur « {color} » n'est pas valide (format attendu : #rrggbb).",
|
||||
"badge.no_fields": "Aucun champ n'est configuré sur le badge.",
|
||||
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
|
||||
"badge.role.referent": "Référent",
|
||||
"badge.role.volunteer": "Bénévole",
|
||||
"position_sheets.footer_title": "Message de l'organisation",
|
||||
"badge.table.day_label": "{weekday} {day}",
|
||||
"badge.weekday.0": "Lundi",
|
||||
"badge.weekday.1": "Mardi",
|
||||
"badge.weekday.2": "Mercredi",
|
||||
"badge.weekday.3": "Jeudi",
|
||||
"badge.weekday.4": "Vendredi",
|
||||
"badge.weekday.5": "Samedi",
|
||||
"badge.weekday.6": "Dimanche",
|
||||
"position.category.bar": "Bar",
|
||||
"position.category.ticketing": "Billetterie",
|
||||
"position.category.welcome": "Accueil",
|
||||
@@ -83,6 +96,7 @@
|
||||
"form.captcha_failed": "Vérification anti-robot invalide, réessayez.",
|
||||
"form.invalid_payload": "Données du formulaire invalides.",
|
||||
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
|
||||
"volunteer.empty_photo": "Le fichier de la photo est vide.",
|
||||
"form.photo_too_large": "La photo est trop volumineuse.",
|
||||
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
|
||||
"form.logo_empty": "Le logo est vide.",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from contextvars import ContextVar
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
@@ -7,6 +8,19 @@ from app.config import get_settings
|
||||
# téléphone dans les logs. On journalise des identifiants (UUID/entier) et des
|
||||
# codes. Toute nouvelle instruction de log doit respecter cette règle.
|
||||
|
||||
# Identifiant de la requête HTTP en cours (voir `app/request_logging.py`),
|
||||
# repris sur chaque ligne de log émise pendant son traitement — y compris
|
||||
# dans ses tâches de fond (`BackgroundTasks`, même contexte) — pour retrouver
|
||||
# d'un `grep` tout ce qui s'est passé lors de l'erreur qu'un utilisateur
|
||||
# signale (la référence lui est affichée, voir `frontend/.../state.tsx`).
|
||||
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
|
||||
|
||||
|
||||
class RequestIdFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.request_id = request_id_var.get()
|
||||
return True
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
# `force=True` : on réinstalle le handler racine même si l'import de
|
||||
@@ -14,6 +28,17 @@ def configure_logging() -> None:
|
||||
# sinon les logs `app.*` n'apparaissent pas dans la sortie du conteneur.
|
||||
logging.basicConfig(
|
||||
level=get_settings().log_level,
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
format="%(asctime)s %(levelname)s [%(request_id)s] %(name)s %(message)s",
|
||||
force=True,
|
||||
)
|
||||
# Filtre posé sur le handler (pas sur un logger) : il voit aussi les
|
||||
# lignes des bibliothèques (uvicorn, sqlalchemy…) propagées à la racine.
|
||||
for handler in logging.getLogger().handlers:
|
||||
handler.addFilter(RequestIdFilter())
|
||||
# Journal d'accès d'uvicorn remplacé par `app/request_logging.py` : le
|
||||
# sien écrit l'URL complète, chaîne de requête comprise — donc le jeton
|
||||
# `access_token` du formulaire public (CLAUDE.md § 2, `FormTemplate`).
|
||||
logging.getLogger("uvicorn.access").disabled = True
|
||||
# Bavard au niveau INFO (une ligne par appel SumUp/hCaptcha), sans
|
||||
# intérêt à côté des logs de l'appli.
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
+5
-2
@@ -7,7 +7,6 @@ from app.api.accounts import router as account_router
|
||||
from app.api.artists import router as artist_router
|
||||
from app.api.assignments import router as assignment_router
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.availability import router as availability_router
|
||||
from app.api.badges import router as badge_router
|
||||
from app.api.day_parts import router as day_part_router
|
||||
from app.api.editions import router as edition_router
|
||||
@@ -20,9 +19,11 @@ from app.api.position_sheets import router as position_sheets_router
|
||||
from app.api.positions import router as position_router
|
||||
from app.api.shifts import router as shift_router
|
||||
from app.api.solver import router as solver_router
|
||||
from app.api.stats import router as stats_router
|
||||
from app.api.volunteers import router as volunteer_router
|
||||
from app.config import get_settings
|
||||
from app.logging_config import configure_logging
|
||||
from app.request_logging import REQUEST_ID_HEADER, install_request_logging
|
||||
|
||||
configure_logging()
|
||||
|
||||
@@ -56,7 +57,9 @@ app.add_middleware(
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=[REQUEST_ID_HEADER],
|
||||
)
|
||||
install_request_logging(app)
|
||||
|
||||
app.include_router(health_router)
|
||||
app.include_router(auth_router)
|
||||
@@ -66,12 +69,12 @@ app.include_router(account_router)
|
||||
app.include_router(import_router)
|
||||
app.include_router(artist_router)
|
||||
app.include_router(volunteer_router)
|
||||
app.include_router(availability_router)
|
||||
app.include_router(position_router)
|
||||
app.include_router(shift_router)
|
||||
app.include_router(assignment_router)
|
||||
app.include_router(solver_router)
|
||||
app.include_router(matrix_router)
|
||||
app.include_router(stats_router)
|
||||
app.include_router(notification_router)
|
||||
app.include_router(badge_router)
|
||||
app.include_router(form_router)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Suppression des disponibilités déclarées
|
||||
|
||||
Revision ID: 0022_drop_availability
|
||||
Revises: 0021_payment_option_free_amount
|
||||
Create Date: 2026-10-05
|
||||
|
||||
Les fenêtres de disponibilité (`availability`) disparaissent, avec la règle
|
||||
`OUTSIDE_AVAILABILITY` qui les exploitait. Les données existantes sont
|
||||
perdues : le `downgrade` recrée la table vide. La case « je suis
|
||||
disponible » du formulaire public (`volunteer.self_declared_available`)
|
||||
n'est pas concernée.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0022_drop_availability"
|
||||
down_revision: str | None = "0021_payment_option_free_amount"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_index(op.f("ix_availability_volunteer_id"), table_name="availability")
|
||||
op.drop_index(op.f("ix_availability_edition_id"), table_name="availability")
|
||||
op.drop_table("availability")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.create_table(
|
||||
"availability",
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("date", sa.Date(), nullable=False),
|
||||
sa.Column("start_time", sa.Time(), nullable=False),
|
||||
sa.Column("end_time", sa.Time(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"volunteer_id", "date", "start_time", "end_time",
|
||||
name="uq_availability_window",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_availability_edition_id"), "availability", ["edition_id"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_availability_volunteer_id"), "availability", ["volunteer_id"], unique=False
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Transaction SumUp de la cotisation
|
||||
|
||||
Revision ID: 0023_fee_transaction
|
||||
Revises: 0022_drop_availability
|
||||
Create Date: 2026-10-12
|
||||
|
||||
`volunteer.fee_transaction_code` (code affiché dans le tableau de bord SumUp,
|
||||
ex. `TEENSK4W2K`) et `fee_transaction_id` (identifiant technique) : la
|
||||
transaction qui a réglé la cotisation, posée à la confirmation du paiement —
|
||||
voir `app/api/forms.py:_promote_pending`. Les bénévoles déjà payés restent
|
||||
à `NULL`.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0023_fee_transaction"
|
||||
down_revision: str | None = "0022_drop_availability"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"volunteer", sa.Column("fee_transaction_code", sa.String(), nullable=True)
|
||||
)
|
||||
op.create_index(
|
||||
"ix_volunteer_fee_transaction_code", "volunteer", ["fee_transaction_code"]
|
||||
)
|
||||
op.add_column(
|
||||
"volunteer", sa.Column("fee_transaction_id", sa.String(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("volunteer", "fee_transaction_id")
|
||||
op.drop_index("ix_volunteer_fee_transaction_code", table_name="volunteer")
|
||||
op.drop_column("volunteer", "fee_transaction_code")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Texte libre du badge
|
||||
|
||||
Revision ID: 0024_volunteer_badge_text
|
||||
Revises: 0023_fee_transaction
|
||||
Create Date: 2026-10-27
|
||||
|
||||
`volunteer.badge_text` : texte propre à chaque bénévole, saisi par la
|
||||
coordination sur sa fiche et imprimé sur le badge par le champ
|
||||
`FREE_TEXT`. Les nouveaux types de champ de badge (`PHOTO`, `FREE_TEXT`)
|
||||
tiennent dans la colonne `badge_field.field_type` existante (VARCHAR sans
|
||||
contrainte CHECK, longueur 9 inchangée) : rien à migrer de ce côté.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0024_volunteer_badge_text"
|
||||
down_revision: str | None = "0023_fee_transaction"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("volunteer", sa.Column("badge_text", sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("volunteer", "badge_text")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Texte commun du champ libre des badges
|
||||
|
||||
Revision ID: 0025_badge_field_text
|
||||
Revises: 0024_volunteer_badge_text
|
||||
Create Date: 2026-10-27
|
||||
|
||||
`badge_field.text` : texte du champ `FREE_TEXT` saisi dans l'éditeur de
|
||||
badges, imprimé sur tous les badges — sauf ceux dont la fiche porte un
|
||||
`volunteer.badge_text`, qui le remplace pour ce bénévole.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0025_badge_field_text"
|
||||
down_revision: str | None = "0024_volunteer_badge_text"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("badge_field", sa.Column("text", sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("badge_field", "text")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Éditeur de badges v2 (verso, fond de zone, auto-ajustement, photo ovale)
|
||||
|
||||
Revision ID: 0026_badge_editor_v2
|
||||
Revises: 0025_badge_field_text
|
||||
Create Date: 2026-10-27
|
||||
|
||||
`badge_field.face` : face du badge (`FRONT`/`BACK`) — les champs existants
|
||||
deviennent des champs du recto. La contrainte d'unicité passe de
|
||||
(`edition_id`, `field_type`) à (`edition_id`, `face`, `field_type`) : un même
|
||||
champ peut figurer sur les deux faces. `bg_color` (fond de la zone,
|
||||
transparent si `NULL`), `auto_fit` (réduction automatique du texte) et
|
||||
`photo_shape` (`RECT`/`OVAL`, champ `PHOTO`).
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0026_badge_editor_v2"
|
||||
down_revision: str | None = "0025_badge_field_text"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_FACE = sa.Enum("FRONT", "BACK", name="badgeface", native_enum=False)
|
||||
_PHOTO_SHAPE = sa.Enum("RECT", "OVAL", name="badgephotoshape", native_enum=False)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"badge_field",
|
||||
sa.Column("face", _FACE, server_default="FRONT", nullable=False),
|
||||
)
|
||||
op.add_column("badge_field", sa.Column("bg_color", sa.String(), nullable=True))
|
||||
op.add_column(
|
||||
"badge_field",
|
||||
sa.Column("auto_fit", sa.Boolean(), server_default="false", nullable=False),
|
||||
)
|
||||
op.add_column(
|
||||
"badge_field",
|
||||
sa.Column("photo_shape", _PHOTO_SHAPE, server_default="RECT", nullable=False),
|
||||
)
|
||||
op.drop_constraint("uq_badge_field_edition_type", "badge_field", type_="unique")
|
||||
op.create_unique_constraint(
|
||||
"uq_badge_field_edition_face_type",
|
||||
"badge_field",
|
||||
["edition_id", "face", "field_type"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Le verso n'existait pas : ses champs sont perdus (sinon l'ancienne
|
||||
# contrainte d'unicité échouerait sur un champ présent sur les deux faces).
|
||||
op.execute("DELETE FROM badge_field WHERE face = 'BACK'")
|
||||
op.drop_constraint("uq_badge_field_edition_face_type", "badge_field", type_="unique")
|
||||
op.create_unique_constraint(
|
||||
"uq_badge_field_edition_type", "badge_field", ["edition_id", "field_type"]
|
||||
)
|
||||
op.drop_column("badge_field", "photo_shape")
|
||||
op.drop_column("badge_field", "auto_fit")
|
||||
op.drop_column("badge_field", "bg_color")
|
||||
op.drop_column("badge_field", "face")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Nombre de badges par bénévole
|
||||
|
||||
Revision ID: 0027_volunteer_badge_count
|
||||
Revises: 0026_badge_editor_v2
|
||||
Create Date: 2026-10-27
|
||||
|
||||
`volunteer.badge_count` : nombre d'exemplaires de son badge imprimés sur les
|
||||
planches (`POST .../badges/generate`), 1 par défaut — les bénévoles existants
|
||||
gardent un seul badge.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0027_volunteer_badge_count"
|
||||
down_revision: str | None = "0026_badge_editor_v2"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"volunteer",
|
||||
sa.Column("badge_count", sa.Integer(), nullable=False, server_default="1"),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_volunteer_badge_count_positive", "volunteer", "badge_count >= 1"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_volunteer_badge_count_positive", "volunteer", type_="check")
|
||||
op.drop_column("volunteer", "badge_count")
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Ordre d'empilement des champs du badge
|
||||
|
||||
Revision ID: 0028_badge_field_z_order
|
||||
Revises: 0027_volunteer_badge_count
|
||||
Create Date: 2026-10-27
|
||||
|
||||
`badge_field.z_order` : les champs d'une face sont dessinés du plus petit au
|
||||
plus grand. Avant, ils l'étaient dans l'ordre alphabétique de leur type, et
|
||||
la photo recouvrait le nom et le texte libre. Les champs existants sont
|
||||
numérotés photo d'abord (dessous), puis les autres par type.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0028_badge_field_z_order"
|
||||
down_revision: str | None = "0027_volunteer_badge_count"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"badge_field",
|
||||
sa.Column("z_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE badge_field AS b SET z_order = ranked.rank
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY edition_id, face
|
||||
ORDER BY (field_type = 'PHOTO') DESC, field_type
|
||||
) - 1 AS rank
|
||||
FROM badge_field
|
||||
) AS ranked
|
||||
WHERE b.id = ranked.id
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("badge_field", "z_order")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Messages aux bénévoles
|
||||
|
||||
Revision ID: 0029_volunteer_messages
|
||||
Revises: 0028_badge_field_z_order
|
||||
Create Date: 2026-10-28
|
||||
|
||||
`edition.volunteer_message` : message de l'organisation, le même pour tous
|
||||
les bénévoles, saisi par la coordination. `position.message` : message du
|
||||
poste (consignes…), saisi par son référent, montré aux bénévoles affectés.
|
||||
Chacun avec la date de sa dernière modification (`*_updated_at`).
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0029_volunteer_messages"
|
||||
down_revision: str | None = "0028_badge_field_z_order"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("edition", sa.Column("volunteer_message", sa.String(), nullable=True))
|
||||
op.add_column(
|
||||
"edition", sa.Column("volunteer_message_updated_at", sa.DateTime(), nullable=True)
|
||||
)
|
||||
op.add_column("position", sa.Column("message", sa.String(), nullable=True))
|
||||
op.add_column("position", sa.Column("message_updated_at", sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("position", "message_updated_at")
|
||||
op.drop_column("position", "message")
|
||||
op.drop_column("edition", "volunteer_message_updated_at")
|
||||
op.drop_column("edition", "volunteer_message")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Pointage des bénévoles
|
||||
|
||||
Revision ID: 0030_assignment_attendance
|
||||
Revises: 0029_volunteer_messages
|
||||
Create Date: 2026-10-28
|
||||
|
||||
`assignment.attendance` (`PRESENT`/`ABSENT`, `NULL` = pas pointé) et
|
||||
`attendance_marked_at` : le pointage du référent était gardé dans le
|
||||
navigateur seulement et se perdait au rechargement.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0030_assignment_attendance"
|
||||
down_revision: str | None = "0029_volunteer_messages"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_ATTENDANCE = sa.Enum("PRESENT", "ABSENT", name="attendancestatus", native_enum=False)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("assignment", sa.Column("attendance", _ATTENDANCE, nullable=True))
|
||||
op.add_column(
|
||||
"assignment", sa.Column("attendance_marked_at", sa.DateTime(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("assignment", "attendance_marked_at")
|
||||
op.drop_column("assignment", "attendance")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Lien de connexion permanent
|
||||
|
||||
Revision ID: 0031_account_login_link_version
|
||||
Revises: 0030_assignment_attendance
|
||||
Create Date: 2026-10-28
|
||||
|
||||
`account.login_link_version` : version du lien de connexion permanent du
|
||||
récapitulatif de planning (`app/auth/permanent_link.py`) — l'incrémenter
|
||||
révoque tous les liens déjà envoyés à ce compte.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0031_account_login_link_version"
|
||||
down_revision: str | None = "0030_assignment_attendance"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"account",
|
||||
sa.Column("login_link_version", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("account", "login_link_version")
|
||||
@@ -9,7 +9,6 @@ from app.models.domain import (
|
||||
Account,
|
||||
Artist,
|
||||
Assignment,
|
||||
Availability,
|
||||
BadgeField,
|
||||
BadgeTemplate,
|
||||
DayPart,
|
||||
@@ -28,8 +27,11 @@ from app.models.domain import (
|
||||
)
|
||||
from app.models.enums import (
|
||||
AssignmentSource,
|
||||
AttendanceStatus,
|
||||
BadgeAlign,
|
||||
BadgeFace,
|
||||
BadgeFieldType,
|
||||
BadgePhotoShape,
|
||||
FeePaymentStatus,
|
||||
FormFieldType,
|
||||
PositionCategory,
|
||||
@@ -41,10 +43,12 @@ __all__ = [
|
||||
"Artist",
|
||||
"Assignment",
|
||||
"AssignmentSource",
|
||||
"Availability",
|
||||
"AttendanceStatus",
|
||||
"BadgeAlign",
|
||||
"BadgeFace",
|
||||
"BadgeField",
|
||||
"BadgeFieldType",
|
||||
"BadgePhotoShape",
|
||||
"BadgeTemplate",
|
||||
"DayPart",
|
||||
"Edition",
|
||||
|
||||
@@ -17,8 +17,11 @@ from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
|
||||
from app.models.base import TimestampMixin
|
||||
from app.models.enums import (
|
||||
AssignmentSource,
|
||||
AttendanceStatus,
|
||||
BadgeAlign,
|
||||
BadgeFace,
|
||||
BadgeFieldType,
|
||||
BadgePhotoShape,
|
||||
FeePaymentStatus,
|
||||
FormFieldType,
|
||||
PositionCategory,
|
||||
@@ -90,6 +93,11 @@ class Edition(TimestampMixin, table=True):
|
||||
end_date: dt.date
|
||||
# Durée de rétention des données nominatives, en jours. None = pas de purge.
|
||||
retention_days: int | None = None
|
||||
# Message de l'organisation, le même pour tous les bénévoles (écran
|
||||
# bénévole) — saisi par la coordination. `_updated_at` : date affichée
|
||||
# sous le message, posée par l'API à chaque changement de texte.
|
||||
volunteer_message: str | None = None
|
||||
volunteer_message_updated_at: dt.datetime | None = None
|
||||
|
||||
day_parts: list["DayPart"] = Relationship(
|
||||
back_populates="edition", sa_relationship_kwargs=_PASSIVE
|
||||
@@ -160,6 +168,10 @@ class Artist(TimestampMixin, table=True):
|
||||
|
||||
|
||||
class Volunteer(TimestampMixin, table=True):
|
||||
__table_args__ = (
|
||||
CheckConstraint("badge_count >= 1", name="ck_volunteer_badge_count_positive"),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
@@ -183,9 +195,17 @@ class Volunteer(TimestampMixin, table=True):
|
||||
# cette note sur la fiche et la transforme, si besoin, en un vrai
|
||||
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
|
||||
companion_wish_note: str | None = None
|
||||
# Texte du badge propre à ce bénévole (champ `BadgeFieldType.FREE_TEXT`),
|
||||
# saisi par la coordination sur la fiche — ex. « Accès backstage ». Vide :
|
||||
# le texte commun de l'éditeur (`BadgeField.text`) s'applique.
|
||||
badge_text: str | None = None
|
||||
# Nombre d'exemplaires de son badge à imprimer sur les planches (badge
|
||||
# perdu, plusieurs accréditations…) — 1 par défaut.
|
||||
badge_count: int = Field(
|
||||
default=1, sa_column=Column(Integer, nullable=False, server_default="1")
|
||||
)
|
||||
# Cases à cocher du formulaire public — purement informatives : aucune
|
||||
# logique n'y est attachée (pas de lien avec `Availability`, pas de
|
||||
# workflow de paiement).
|
||||
# logique n'y est attachée (pas de workflow de paiement).
|
||||
self_declared_available: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
@@ -212,6 +232,13 @@ class Volunteer(TimestampMixin, table=True):
|
||||
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
|
||||
)
|
||||
fee_paid_at: dt.datetime | None = None
|
||||
# Transaction SumUp qui a réglé la cotisation (`app/sumup/client.py:
|
||||
# SumUpCheckout`), posée à la confirmation du paiement : le code court
|
||||
# (`TEENSK4W2K`) est celui du tableau de bord SumUp et de ses reçus —
|
||||
# rapprochement comptable paiement ↔ bénévole. Exposés (fiche, export
|
||||
# Excel), contrairement à `fee_checkout_id`.
|
||||
fee_transaction_code: str | None = Field(default=None, index=True)
|
||||
fee_transaction_id: str | None = None
|
||||
# Coordonnées collectées uniquement pour un type de paiement « mécénat »
|
||||
# (`FormPaymentOption.requires_mecenat_info`), avant le paiement (voir
|
||||
# `PendingFormSubmission` et `app/api/forms.py:submit_mecenat_info`) —
|
||||
@@ -237,9 +264,6 @@ class Volunteer(TimestampMixin, table=True):
|
||||
link_model=VolunteerPositionWish,
|
||||
sa_relationship_kwargs=_PASSIVE,
|
||||
)
|
||||
availabilities: list["Availability"] = Relationship(
|
||||
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
|
||||
)
|
||||
assignments: list["Assignment"] = Relationship(
|
||||
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
|
||||
)
|
||||
@@ -270,6 +294,12 @@ class Account(TimestampMixin, table=True):
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Version du lien de connexion permanent (`app/auth/permanent_link.py`) :
|
||||
# l'incrémenter révoque tous les liens déjà envoyés à ce compte.
|
||||
login_link_version: int = Field(
|
||||
default=0, sa_column=Column(Integer, nullable=False, server_default="0")
|
||||
)
|
||||
|
||||
@property
|
||||
def auth_method(self) -> str:
|
||||
return "password" if self.password_hash else "magic_link"
|
||||
@@ -308,6 +338,10 @@ class Position(TimestampMixin, table=True):
|
||||
ondelete="SET NULL",
|
||||
nullable=True,
|
||||
)
|
||||
# Message du poste (consignes…), saisi par son référent ou la
|
||||
# coordination, montré aux bénévoles affectés au poste.
|
||||
message: str | None = None
|
||||
message_updated_at: dt.datetime | None = None
|
||||
|
||||
shifts: list["Shift"] = Relationship(
|
||||
back_populates="position", sa_relationship_kwargs=_PASSIVE
|
||||
@@ -352,43 +386,11 @@ class Shift(TimestampMixin, table=True):
|
||||
)
|
||||
|
||||
|
||||
class Availability(TimestampMixin, table=True):
|
||||
"""Déclaration du bénévole : « tel jour, je peux venir de telle à telle
|
||||
heure ». Fenêtre horaire libre (pas liée aux créneaux types). Plusieurs
|
||||
fenêtres par jour possibles.
|
||||
|
||||
Heures naïves, locales Europe/Paris. `end_time <= start_time` ⇒ la fenêtre
|
||||
passe minuit (se termine le lendemain). À ne pas confondre avec Assignment
|
||||
(décision de l'organisation).
|
||||
"""
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"volunteer_id", "date", "start_time", "end_time",
|
||||
name="uq_availability_window",
|
||||
),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
volunteer_id: uuid.UUID = Field(
|
||||
foreign_key="volunteer.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
date: dt.date
|
||||
start_time: dt.time
|
||||
end_time: dt.time
|
||||
|
||||
volunteer: Volunteer = Relationship(back_populates="availabilities")
|
||||
|
||||
|
||||
class Assignment(TimestampMixin, table=True):
|
||||
"""Décision de l'organisation : « tu tiens tel créneau ».
|
||||
|
||||
Existe ⇒ le bénévole est affecté (pas d'étape de confirmation). Retirer une
|
||||
affectation, c'est supprimer la ligne. À ne pas confondre avec Availability
|
||||
(déclaration du bénévole).
|
||||
affectation, c'est supprimer la ligne.
|
||||
"""
|
||||
|
||||
__table_args__ = (
|
||||
@@ -419,6 +421,14 @@ class Assignment(TimestampMixin, table=True):
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
# Pointage par le référent du poste (ou la coordination) : `None` tant
|
||||
# que personne n'a pointé. `attendance_marked_at` : heure du dernier
|
||||
# pointage, affichée sur l'écran référent.
|
||||
attendance: AttendanceStatus | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(SAEnum(AttendanceStatus, native_enum=False), nullable=True),
|
||||
)
|
||||
attendance_marked_at: dt.datetime | None = None
|
||||
|
||||
volunteer: Volunteer = Relationship(back_populates="assignments")
|
||||
shift: Shift = Relationship(back_populates="assignments")
|
||||
@@ -454,16 +464,17 @@ class BadgeTemplate(TimestampMixin, table=True):
|
||||
|
||||
|
||||
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
|
||||
"""Un champ positionné sur une face du badge (`face` : recto ou verso —
|
||||
nom, poste(s), catégorie, rôle… voir `BadgeFieldType`). Coordonnées en
|
||||
millimètres depuis le coin haut-gauche du badge (voir `app/badges/pdf.py`).
|
||||
Au plus un champ par `field_type` et par face pour une édition (le même
|
||||
champ peut figurer au recto et au verso) : décocher un champ dans
|
||||
l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`)."""
|
||||
|
||||
__tablename__ = "badge_field"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"edition_id", "field_type", name="uq_badge_field_edition_type"
|
||||
"edition_id", "face", "field_type", name="uq_badge_field_edition_face_type"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -471,6 +482,14 @@ class BadgeField(TimestampMixin, table=True):
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
face: BadgeFace = Field(
|
||||
default=BadgeFace.FRONT,
|
||||
sa_column=Column(
|
||||
SAEnum(BadgeFace, native_enum=False),
|
||||
nullable=False,
|
||||
server_default=BadgeFace.FRONT.value,
|
||||
),
|
||||
)
|
||||
field_type: BadgeFieldType = Field(
|
||||
sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False)
|
||||
)
|
||||
@@ -492,6 +511,33 @@ class BadgeField(TimestampMixin, table=True):
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
color: str = Field(default="#000000")
|
||||
# Champ `FREE_TEXT` seulement : texte saisi dans l'éditeur, imprimé sur
|
||||
# tous les badges — sauf ceux dont la fiche porte un « Texte du badge »
|
||||
# (`Volunteer.badge_text`), qui le remplace pour ce bénévole.
|
||||
text: str | None = None
|
||||
# Fond de la zone du champ (`#rrggbb`) ; `None` = transparent. Sans
|
||||
# effet sur le champ `PHOTO`.
|
||||
bg_color: str | None = None
|
||||
# Réduit la taille du texte (par pas de 0,5 pt) jusqu'à ce que tout
|
||||
# tienne dans le cadre, au lieu de tronquer les lignes en trop.
|
||||
auto_fit: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
# Champ `PHOTO` seulement : rectangle ou ellipse inscrite dans le cadre.
|
||||
photo_shape: BadgePhotoShape = Field(
|
||||
default=BadgePhotoShape.RECT,
|
||||
sa_column=Column(
|
||||
SAEnum(BadgePhotoShape, native_enum=False),
|
||||
nullable=False,
|
||||
server_default=BadgePhotoShape.RECT.value,
|
||||
),
|
||||
)
|
||||
# Ordre d'empilement sur sa face : les champs sont dessinés du plus petit
|
||||
# au plus grand, un champ recouvre donc ceux d'un `z_order` inférieur.
|
||||
z_order: int = Field(
|
||||
default=0, sa_column=Column(Integer, nullable=False, server_default="0")
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -18,6 +18,13 @@ class AssignmentSource(enum.StrEnum):
|
||||
MANUAL = "MANUAL"
|
||||
|
||||
|
||||
class AttendanceStatus(enum.StrEnum):
|
||||
"""Pointage d'un bénévole sur son créneau, par le référent du poste."""
|
||||
|
||||
PRESENT = "PRESENT"
|
||||
ABSENT = "ABSENT"
|
||||
|
||||
|
||||
class PositionCategory(enum.StrEnum):
|
||||
BAR = "BAR"
|
||||
TICKETING = "TICKETING"
|
||||
@@ -31,12 +38,34 @@ class PositionCategory(enum.StrEnum):
|
||||
|
||||
|
||||
class BadgeFieldType(enum.StrEnum):
|
||||
"""Champ affichable sur le recto d'un badge (voir `app/badges/`)."""
|
||||
"""Champ affichable sur le recto ou le verso d'un badge (voir `app/badges/`)."""
|
||||
|
||||
FULL_NAME = "FULL_NAME"
|
||||
POSITIONS = "POSITIONS"
|
||||
CATEGORY = "CATEGORY"
|
||||
ROLE = "ROLE"
|
||||
# Photo du bénévole (`Volunteer.photo`), ajustée au cadre du champ.
|
||||
PHOTO = "PHOTO"
|
||||
# Texte libre saisi par la coordination dans l'éditeur (`BadgeField.text`,
|
||||
# commun à tous les badges), remplacé pour un bénévole par le « Texte du
|
||||
# badge » de sa fiche (`Volunteer.badge_text`) s'il en a un.
|
||||
FREE_TEXT = "FREE_TEXT"
|
||||
|
||||
|
||||
class BadgeFace(enum.StrEnum):
|
||||
"""Face du badge sur laquelle un champ est imprimé : le verso est
|
||||
personnalisable comme le recto (même champ possible sur les deux)."""
|
||||
|
||||
FRONT = "FRONT"
|
||||
BACK = "BACK"
|
||||
|
||||
|
||||
class BadgePhotoShape(enum.StrEnum):
|
||||
"""Forme de découpe du champ `PHOTO` (cadre rectangulaire ou ellipse
|
||||
inscrite dans le cadre)."""
|
||||
|
||||
RECT = "RECT"
|
||||
OVAL = "OVAL"
|
||||
|
||||
|
||||
class BadgeAlign(enum.StrEnum):
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Journal des requêtes HTTP — une ligne par requête, pour diagnostiquer
|
||||
après coup une erreur rencontrée par un utilisateur (voir CLAUDE.md § 8,
|
||||
RGPD : identifiants et codes seulement).
|
||||
|
||||
- Chaque requête reçoit un identifiant (`X-Request-ID`, repris s'il est
|
||||
fourni et bien formé, généré sinon), renvoyé dans l'en-tête de la réponse
|
||||
et préfixé à toutes les lignes de log émises pendant son traitement
|
||||
(`app/logging_config.py:request_id_var`).
|
||||
- La ligne d'accès donne la route **modèle** (`/editions/{edition_id}/…`) et
|
||||
ses paramètres d'identifiants, jamais l'URL brute : la chaîne de requête
|
||||
peut porter un jeton (`access_token` du formulaire public), et les
|
||||
paramètres de chemin nommés `*token*` (inscription en attente) sont omis.
|
||||
- Code d'erreur métier (`detail.code`) et compte connecté ajoutés quand ils
|
||||
existent ; détails d'une erreur de validation 422 (emplacement + type,
|
||||
jamais la valeur saisie).
|
||||
- Une exception non gérée est journalisée avec sa trace et renvoie un 500
|
||||
JSON `{detail: {code: "error.internal", request_id}}` plutôt qu'un texte
|
||||
brut.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exception_handlers import (
|
||||
http_exception_handler,
|
||||
request_validation_exception_handler,
|
||||
)
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.logging_config import request_id_var
|
||||
|
||||
logger = logging.getLogger("app.request")
|
||||
|
||||
REQUEST_ID_HEADER = "X-Request-ID"
|
||||
_INCOMING_ID = re.compile(r"^[A-Za-z0-9-]{8,64}$")
|
||||
# Sondes de santé (Docker, Caddy) : pas une ligne INFO toutes les 10 s.
|
||||
_QUIET_PATHS = {"/health"}
|
||||
|
||||
|
||||
def _route_label(request: Request) -> str:
|
||||
route = request.scope.get("route")
|
||||
template = getattr(route, "path", None) or "<non routé>"
|
||||
params = {
|
||||
key: value
|
||||
for key, value in (request.scope.get("path_params") or {}).items()
|
||||
if "token" not in key
|
||||
}
|
||||
if not params:
|
||||
return template
|
||||
return f"{template} " + " ".join(f"{k}={v}" for k, v in params.items())
|
||||
|
||||
|
||||
def install_request_logging(app: FastAPI) -> None:
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
incoming = request.headers.get(REQUEST_ID_HEADER, "")
|
||||
request_id = incoming if _INCOMING_ID.match(incoming) else uuid.uuid4().hex[:12]
|
||||
reset = request_id_var.set(request_id)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Exception non gérée sur %s %s", request.method, _route_label(request)
|
||||
)
|
||||
response = JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": {"code": "error.internal", "request_id": request_id}},
|
||||
)
|
||||
duration_ms = (time.monotonic() - started) * 1000
|
||||
response.headers[REQUEST_ID_HEADER] = request_id
|
||||
|
||||
status_code = response.status_code
|
||||
if status_code >= 500:
|
||||
level = logging.ERROR
|
||||
elif status_code >= 400:
|
||||
level = logging.WARNING
|
||||
elif request.url.path in _QUIET_PATHS or request.method == "OPTIONS":
|
||||
level = logging.DEBUG
|
||||
else:
|
||||
level = logging.INFO
|
||||
extras = []
|
||||
error_code = getattr(request.state, "error_code", None)
|
||||
if error_code:
|
||||
extras.append(f"code={error_code}")
|
||||
account_id = getattr(request.state, "account_id", None)
|
||||
if account_id:
|
||||
extras.append(f"compte={account_id}")
|
||||
logger.log(
|
||||
level,
|
||||
"%s %s → %d (%d ms)%s",
|
||||
request.method,
|
||||
_route_label(request),
|
||||
status_code,
|
||||
duration_ms,
|
||||
(" " + " ".join(extras)) if extras else "",
|
||||
)
|
||||
return response
|
||||
finally:
|
||||
request_id_var.reset(reset)
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def log_http_exception(request: Request, exc: HTTPException):
|
||||
detail = exc.detail
|
||||
if isinstance(detail, dict) and detail.get("code"):
|
||||
request.state.error_code = detail["code"]
|
||||
violations = detail.get("violations")
|
||||
if violations:
|
||||
# Codes de violation seulement (`SHIFT_OVERLAP`…) : les
|
||||
# paramètres peuvent porter des horaires, pas utiles ici.
|
||||
request.state.error_code += " violations=" + ",".join(
|
||||
sorted({str(v.get("code")) for v in violations if isinstance(v, dict)})
|
||||
)
|
||||
return await http_exception_handler(request, exc)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def log_validation_error(request: Request, exc: RequestValidationError):
|
||||
# Emplacement + type d'erreur, jamais `input` (la valeur saisie, qui
|
||||
# peut être un nom, un email…).
|
||||
request.state.error_code = "validation " + "; ".join(
|
||||
f"{'.'.join(str(p) for p in err.get('loc', ()))}:{err.get('type')}"
|
||||
for err in exc.errors()[:10]
|
||||
)
|
||||
return await request_validation_exception_handler(request, exc)
|
||||
+3
-163
@@ -9,9 +9,8 @@ Déterministe (graine fixe). Aucune donnée nominative réelle.
|
||||
import datetime as dt
|
||||
import random
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth.passwords import hash_password
|
||||
@@ -22,7 +21,6 @@ from app.models import (
|
||||
Artist,
|
||||
Assignment,
|
||||
AssignmentSource,
|
||||
Availability,
|
||||
DayPart,
|
||||
Edition,
|
||||
FeePaymentStatus,
|
||||
@@ -35,14 +33,7 @@ from app.models import (
|
||||
VolunteerCompanionWish,
|
||||
VolunteerPositionWish,
|
||||
)
|
||||
from app.validation import (
|
||||
AvailabilityWindow,
|
||||
DayPartWindow,
|
||||
Severity,
|
||||
ShiftInfo,
|
||||
ValidationInput,
|
||||
validate,
|
||||
)
|
||||
from app.validation import Severity, validate
|
||||
from app.validation.loader import build_validation_input
|
||||
|
||||
SEED = 20260709
|
||||
@@ -227,7 +218,6 @@ class Summary:
|
||||
companion_wishes: int = 0
|
||||
fee_paid_classique: int = 0
|
||||
fee_paid_mecenat: int = 0
|
||||
warnings: Counter = field(default_factory=Counter)
|
||||
blocking: int = 0
|
||||
|
||||
def __str__(self) -> str:
|
||||
@@ -245,10 +235,7 @@ class Summary:
|
||||
f" Vœux de compagnon déclarés : {self.companion_wishes}",
|
||||
f" Cotisation payée (classique) : {self.fee_paid_classique}",
|
||||
f" Cotisation payée (mécénat) : {self.fee_paid_mecenat}",
|
||||
f" Avertissements actifs : {sum(self.warnings.values())}",
|
||||
]
|
||||
for code, count in sorted(self.warnings.items()):
|
||||
lines.append(f" {code} : {count}")
|
||||
lines.append(f" Violations bloquantes : {self.blocking}")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
@@ -309,9 +296,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
]
|
||||
session.add_all(day_parts)
|
||||
session.flush()
|
||||
dp_windows = tuple(
|
||||
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order) for dp in day_parts
|
||||
)
|
||||
|
||||
coordination = Account(
|
||||
edition_id=edition.id,
|
||||
@@ -428,27 +412,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
)
|
||||
)
|
||||
n_companion_wishes += 1
|
||||
# Disponibilités : chaque bénévole déclare, pour ~80 % des jours, une
|
||||
# large fenêtre couvrant la journée et la nuit (06:00 → 04:00 le lendemain).
|
||||
_FULL_DAY = (dt.time(6, 0), dt.time(4, 0))
|
||||
availabilities: dict[object, list[AvailabilityWindow]] = {}
|
||||
for volunteer in volunteers:
|
||||
windows: list[AvailabilityWindow] = []
|
||||
for day in dates:
|
||||
if rng.random() < 0.8:
|
||||
start, end = _FULL_DAY
|
||||
session.add(
|
||||
Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=day,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
)
|
||||
)
|
||||
windows.append(AvailabilityWindow(day, start, end))
|
||||
availabilities[volunteer.id] = windows
|
||||
|
||||
shifts: list[Shift] = []
|
||||
for position, (_n, category, _l, _r) in zip(positions, POSITIONS, strict=True):
|
||||
for day in dates:
|
||||
@@ -470,28 +433,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
|
||||
assigned: dict[object, list[Shift]] = {v.id: [] for v in volunteers}
|
||||
|
||||
# Trois bénévoles réservés aux scénarios d'avertissement : exclus de
|
||||
# l'affectation générale pour rester libres.
|
||||
reserved = volunteers[:3]
|
||||
reserved_ids = {v.id for v in reserved}
|
||||
assignable = [v for v in volunteers if v.id not in reserved_ids]
|
||||
|
||||
def _clean(volunteer: Volunteer, shift: Shift) -> bool:
|
||||
data = ValidationInput(
|
||||
candidate_shift=ShiftInfo(
|
||||
id=str(shift.id),
|
||||
starts_at=shift.starts_at,
|
||||
ends_at=shift.ends_at,
|
||||
),
|
||||
existing_shifts=tuple(
|
||||
ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at)
|
||||
for s in assigned[volunteer.id]
|
||||
),
|
||||
availabilities=tuple(availabilities[volunteer.id]),
|
||||
day_parts=dp_windows,
|
||||
)
|
||||
return not validate(data)
|
||||
|
||||
n_assignments = 0
|
||||
order = shifts[:]
|
||||
rng.shuffle(order)
|
||||
@@ -500,7 +441,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
target = max(0, shift.min_staff - 1)
|
||||
else:
|
||||
target = rng.randint(shift.min_staff, shift.ideal_staff)
|
||||
pool = assignable[:]
|
||||
pool = volunteers[:]
|
||||
rng.shuffle(pool)
|
||||
placed = 0
|
||||
for volunteer in pool:
|
||||
@@ -508,8 +449,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
break
|
||||
if any(_overlap(shift, s) for s in assigned[volunteer.id]):
|
||||
continue
|
||||
if not _clean(volunteer, shift):
|
||||
continue
|
||||
session.add(
|
||||
Assignment(
|
||||
edition_id=edition.id,
|
||||
@@ -522,9 +461,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
placed += 1
|
||||
n_assignments += 1
|
||||
|
||||
n_assignments += _add_demo_warnings(
|
||||
session, edition, shifts, reserved, assigned, availabilities
|
||||
)
|
||||
n_fee_classique, n_fee_mecenat = _add_demo_payments(session, volunteers, rng)
|
||||
session.flush()
|
||||
|
||||
@@ -558,74 +494,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
return summary
|
||||
|
||||
|
||||
def _clear_day(session, volunteer, day, availabilities) -> None:
|
||||
session.execute(
|
||||
delete(Availability)
|
||||
.where(Availability.volunteer_id == volunteer.id)
|
||||
.where(Availability.date == day)
|
||||
)
|
||||
availabilities[volunteer.id] = [
|
||||
w for w in availabilities[volunteer.id] if w.date != day
|
||||
]
|
||||
|
||||
|
||||
def _declare_window(
|
||||
session, edition, volunteer, day, start, end, availabilities
|
||||
) -> None:
|
||||
session.add(
|
||||
Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=day,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
)
|
||||
)
|
||||
availabilities[volunteer.id].append(AvailabilityWindow(day, start, end))
|
||||
|
||||
|
||||
def _add_demo_warnings(
|
||||
session, edition, shifts, reserved, assigned, availabilities
|
||||
) -> int:
|
||||
"""Quelques affectations qui déclenchent des avertissements (et rien
|
||||
d'autre) : hors disponibilité, repos insuffisant. `reserved` = trois
|
||||
bénévoles tenus à l'écart de l'affectation générale.
|
||||
"""
|
||||
a, b, tired = reserved
|
||||
added = 0
|
||||
|
||||
# OUTSIDE_AVAILABILITY : deux bénévoles affectés un jour où ils n'ont
|
||||
# déclaré aucune disponibilité.
|
||||
plain = sorted(shifts, key=lambda s: s.starts_at)
|
||||
for volunteer, shift in ((a, plain[3]), (b, plain[7])):
|
||||
_clear_day(session, volunteer, shift.starts_at.date(), availabilities)
|
||||
_add_active(session, edition, volunteer, shift, assigned)
|
||||
added += 1
|
||||
|
||||
# INSUFFICIENT_REST : deux créneaux espacés de moins de 8 h, sur deux
|
||||
# journées (pour ne pas déclencher le cumul), tous deux couverts par une
|
||||
# disponibilité.
|
||||
pair = _find_rest_pair(shifts)
|
||||
if pair is not None:
|
||||
first, second = pair
|
||||
for shift in (first, second):
|
||||
_clear_day(session, tired, shift.starts_at.date(), availabilities)
|
||||
_declare_window(
|
||||
session,
|
||||
edition,
|
||||
tired,
|
||||
shift.starts_at.date(),
|
||||
dt.time(6, 0),
|
||||
dt.time(4, 0),
|
||||
availabilities,
|
||||
)
|
||||
_add_active(session, edition, tired, first, assigned)
|
||||
_add_active(session, edition, tired, second, assigned)
|
||||
added += 2
|
||||
|
||||
return added
|
||||
|
||||
|
||||
def _add_demo_payments(
|
||||
session: Session, volunteers: list[Volunteer], rng: random.Random
|
||||
) -> tuple[int, int]:
|
||||
@@ -664,32 +532,6 @@ def _add_demo_payments(
|
||||
return len(classique), len(mecenat)
|
||||
|
||||
|
||||
def _find_rest_pair(shifts: list[Shift]) -> tuple[Shift, Shift] | None:
|
||||
plain = sorted(shifts, key=lambda s: s.starts_at)
|
||||
for i, first in enumerate(plain):
|
||||
for second in plain[i + 1 :]:
|
||||
gap = second.starts_at - first.ends_at
|
||||
if (
|
||||
first.ends_at <= second.starts_at
|
||||
and dt.timedelta(0) < gap < dt.timedelta(hours=8)
|
||||
and first.starts_at.date() != second.starts_at.date()
|
||||
):
|
||||
return first, second
|
||||
return None
|
||||
|
||||
|
||||
def _add_active(session, edition, volunteer, shift, assigned) -> None:
|
||||
session.add(
|
||||
Assignment(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
shift_id=shift.id,
|
||||
source=AssignmentSource.MANUAL,
|
||||
)
|
||||
)
|
||||
assigned[volunteer.id].append(shift)
|
||||
|
||||
|
||||
def _verify(session: Session, edition: Edition, summary: Summary) -> None:
|
||||
session.flush()
|
||||
assignments = session.exec(
|
||||
@@ -709,8 +551,6 @@ def _verify(session: Session, edition: Edition, summary: Summary) -> None:
|
||||
):
|
||||
if violation.severity is Severity.BLOCKING:
|
||||
summary.blocking += 1
|
||||
else:
|
||||
summary.warnings[violation.code] += 1
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
+158
-33
@@ -12,12 +12,14 @@ bénévole, une feuille identifie un poste pour la journée.
|
||||
import datetime as dt
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
from PIL import Image as PILImage
|
||||
from PIL import ImageOps, UnidentifiedImageError
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.platypus import (
|
||||
Image,
|
||||
PageBreak,
|
||||
@@ -28,7 +30,12 @@ from reportlab.platypus import (
|
||||
TableStyle,
|
||||
)
|
||||
|
||||
_PHOTO_BOX_MM = 14 * mm
|
||||
# Mise en page compacte (une feuille = une page autant que possible) :
|
||||
# vignette de 10 mm, deux bénévoles par ligne.
|
||||
_PHOTO_BOX_MM = 10 * mm
|
||||
# Côté de la vignette en pixels : 10 mm à 300 dpi.
|
||||
_PHOTO_BOX_PX = round(10 / 25.4 * 300)
|
||||
_PER_ROW = 2
|
||||
|
||||
_WEEKDAYS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
|
||||
_MONTHS_FR = [
|
||||
@@ -90,69 +97,187 @@ def _styles() -> dict[str, ParagraphStyle]:
|
||||
base = getSampleStyleSheet()
|
||||
return {
|
||||
"title": ParagraphStyle(
|
||||
"SheetTitle", parent=base["Title"], alignment=0, fontSize=22, spaceAfter=2
|
||||
"SheetTitle", parent=base["Title"], alignment=0,
|
||||
fontSize=18, leading=21, spaceBefore=0, spaceAfter=1,
|
||||
),
|
||||
"subtitle": ParagraphStyle(
|
||||
"SheetSubtitle", parent=base["Normal"],
|
||||
fontSize=12, textColor=colors.grey, spaceAfter=4,
|
||||
fontSize=10.5, leading=13, textColor=colors.grey, spaceAfter=2,
|
||||
),
|
||||
"referent": ParagraphStyle(
|
||||
"SheetReferent", parent=base["Normal"],
|
||||
fontSize=11.5, fontName="Helvetica-Bold", spaceAfter=6,
|
||||
fontSize=10.5, leading=13, fontName="Helvetica-Bold", spaceAfter=3,
|
||||
),
|
||||
"artists": ParagraphStyle(
|
||||
"SheetArtists", parent=base["Normal"],
|
||||
fontSize=9.5, textColor=colors.HexColor("#4a4a4a"), spaceAfter=16,
|
||||
fontSize=8.5, leading=10.5, textColor=colors.HexColor("#4a4a4a"), spaceAfter=4,
|
||||
),
|
||||
"shiftHeader": ParagraphStyle(
|
||||
"ShiftHeader", parent=base["Heading3"], fontSize=13, spaceBefore=12, spaceAfter=4
|
||||
"ShiftHeader", parent=base["Heading3"],
|
||||
fontSize=11, leading=13, spaceBefore=7, spaceAfter=3,
|
||||
),
|
||||
"shiftHeaderWarning": ParagraphStyle(
|
||||
"ShiftHeaderWarning", parent=base["Heading3"],
|
||||
fontSize=13, spaceBefore=12, spaceAfter=4, textColor=colors.HexColor("#b23a1f"),
|
||||
fontSize=11, leading=13, spaceBefore=7, spaceAfter=3,
|
||||
textColor=colors.HexColor("#b23a1f"),
|
||||
),
|
||||
"volunteerName": ParagraphStyle(
|
||||
"SheetVolunteerName", parent=base["Normal"],
|
||||
fontName="Helvetica-Bold", fontSize=9.5, leading=11.5,
|
||||
),
|
||||
"volunteerPhone": ParagraphStyle(
|
||||
"SheetVolunteerPhone", parent=base["Normal"], fontSize=9, leading=11,
|
||||
),
|
||||
"footerTitle": ParagraphStyle(
|
||||
"SheetFooterTitle", parent=base["Normal"],
|
||||
fontName="Helvetica-Bold", fontSize=8.5, leading=11,
|
||||
),
|
||||
"footer": ParagraphStyle(
|
||||
"SheetFooter", parent=base["Normal"], fontSize=8.5, leading=11,
|
||||
),
|
||||
"normal": base["Normal"],
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
@dataclass(frozen=True)
|
||||
class SheetFooter:
|
||||
"""Message imprimé en bas de chaque page (message de l'organisation,
|
||||
`Edition.volunteer_message`) : `title` en gras, puis `message` —
|
||||
retours à la ligne conservés."""
|
||||
|
||||
title: str
|
||||
message: str
|
||||
|
||||
|
||||
_MARGIN_X = 15 * mm
|
||||
_MARGIN_TOP = 12 * mm
|
||||
_MARGIN_BOTTOM = 12 * mm
|
||||
# Écart entre le bas du contenu et le filet du pied de page.
|
||||
_FOOTER_GAP = 5 * mm
|
||||
|
||||
|
||||
def _footer_paragraphs(footer: SheetFooter, styles: dict) -> list[Paragraph]:
|
||||
message = escape(footer.message).replace("\n", "<br/>")
|
||||
return [
|
||||
Paragraph(escape(footer.title), styles["footerTitle"]),
|
||||
Paragraph(message, styles["footer"]),
|
||||
]
|
||||
|
||||
|
||||
def _thumbnail(photo: bytes) -> tuple[bytes, int, int] | None:
|
||||
"""Photo réduite à la taille de la vignette (orientation EXIF appliquée),
|
||||
en JPEG : `(octets, largeur, hauteur)`, `None` si illisible. La photo
|
||||
d'origine (souvent plusieurs Mo, prise au téléphone) intégrée telle
|
||||
quelle pour chaque bénévole de chaque créneau faisait tourner la
|
||||
génération plusieurs minutes et produisait un PDF énorme — jusqu'à
|
||||
l'erreur (bug corrigé une fois)."""
|
||||
try:
|
||||
with PILImage.open(io.BytesIO(photo)) as img:
|
||||
# JPEG : décodage directement à taille réduite (bien plus rapide).
|
||||
img.draft("RGB", (_PHOTO_BOX_PX, _PHOTO_BOX_PX))
|
||||
thumb = ImageOps.exif_transpose(img).convert("RGB")
|
||||
thumb.thumbnail((_PHOTO_BOX_PX, _PHOTO_BOX_PX))
|
||||
except (UnidentifiedImageError, OSError, ValueError):
|
||||
return None
|
||||
out = io.BytesIO()
|
||||
thumb.save(out, format="JPEG", quality=85)
|
||||
return out.getvalue(), thumb.width, thumb.height
|
||||
|
||||
|
||||
def _photo_cell(photo: bytes | None, cache: dict[bytes, tuple[bytes, int, int] | None]):
|
||||
"""Vignette carrée (14 mm de côté max), proportions préservées. Chaque
|
||||
photo n'est réduite qu'une fois par PDF (`cache`), même si le bénévole
|
||||
figure sur plusieurs créneaux."""
|
||||
if not photo:
|
||||
return ""
|
||||
if photo not in cache:
|
||||
cache[photo] = _thumbnail(photo)
|
||||
thumb = cache[photo]
|
||||
if thumb is None:
|
||||
return ""
|
||||
data, width_px, height_px = thumb
|
||||
scale = _PHOTO_BOX_MM / max(width_px, height_px)
|
||||
return Image(io.BytesIO(photo), width=width_px * scale, height=height_px * scale)
|
||||
return Image(io.BytesIO(data), width=width_px * scale, height=height_px * scale)
|
||||
|
||||
|
||||
def _shift_table(shift: SheetShift) -> Table:
|
||||
rows = [["", "Bénévole", "Téléphone"]] + [
|
||||
[_photo_thumbnail(v.photo) if v.photo else "", v.name, v.phone or "—"]
|
||||
def _shift_table(
|
||||
shift: SheetShift,
|
||||
cache: dict[bytes, tuple[bytes, int, int] | None],
|
||||
styles: dict[str, ParagraphStyle],
|
||||
width: float,
|
||||
) -> Table:
|
||||
"""Bénévoles du créneau, deux par ligne : vignette, puis nom (gras) et
|
||||
téléphone l'un sous l'autre. Deux fois moins de lignes qu'une liste à
|
||||
une personne par ligne (avant l'étape 65, souvent deux pages par
|
||||
feuille)."""
|
||||
photo_w = _PHOTO_BOX_MM + 3 * mm
|
||||
text_w = width / _PER_ROW - photo_w
|
||||
cells = [
|
||||
[
|
||||
_photo_cell(v.photo, cache),
|
||||
[
|
||||
Paragraph(escape(v.name), styles["volunteerName"]),
|
||||
Paragraph(escape(v.phone or "—"), styles["volunteerPhone"]),
|
||||
],
|
||||
]
|
||||
for v in shift.volunteers
|
||||
]
|
||||
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),
|
||||
]
|
||||
)
|
||||
)
|
||||
rows = []
|
||||
for i in range(0, len(cells), _PER_ROW):
|
||||
chunk = cells[i : i + _PER_ROW]
|
||||
chunk += [["", ""]] * (_PER_ROW - len(chunk))
|
||||
rows.append([cell for pair in chunk for cell in pair])
|
||||
table = Table(rows, colWidths=[photo_w, text_w] * _PER_ROW)
|
||||
style = [
|
||||
("TOPPADDING", (0, 0), (-1, -1), 2),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 2),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 3),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 3),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("LINEBELOW", (0, 0), (-1, -1), 0.25, colors.lightgrey),
|
||||
("LINEABOVE", (0, 0), (-1, 0), 0.25, colors.lightgrey),
|
||||
]
|
||||
# Filet vertical entre les deux bénévoles d'une même ligne.
|
||||
for col in range(2, 2 * _PER_ROW, 2):
|
||||
style.append(("LINEBEFORE", (col, 0), (col, -1), 0.25, colors.lightgrey))
|
||||
table.setStyle(TableStyle(style))
|
||||
return table
|
||||
|
||||
|
||||
def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes:
|
||||
def generate_position_sheets_pdf(
|
||||
sheets: list[PositionDaySheet], footer: SheetFooter | None = None
|
||||
) -> bytes:
|
||||
"""`footer` : imprimé en bas de **chaque** page (y compris la suite
|
||||
d'une feuille sur une seconde page) ; la marge du bas s'agrandit de sa
|
||||
hauteur pour que le contenu ne passe jamais dessous."""
|
||||
styles = _styles()
|
||||
buffer = io.BytesIO()
|
||||
width = A4[0] - 2 * _MARGIN_X
|
||||
paragraphs = _footer_paragraphs(footer, styles) if footer else []
|
||||
footer_height = sum(p.wrap(width, A4[1])[1] for p in paragraphs)
|
||||
doc = SimpleDocTemplate(
|
||||
buffer, pagesize=A4,
|
||||
leftMargin=18 * mm, rightMargin=18 * mm, topMargin=16 * mm, bottomMargin=16 * mm,
|
||||
leftMargin=_MARGIN_X, rightMargin=_MARGIN_X, topMargin=_MARGIN_TOP,
|
||||
bottomMargin=_MARGIN_BOTTOM + (footer_height + _FOOTER_GAP if paragraphs else 0),
|
||||
)
|
||||
|
||||
def draw_footer(canvas, _doc) -> None:
|
||||
if not paragraphs:
|
||||
return
|
||||
canvas.saveState()
|
||||
top = _MARGIN_BOTTOM + footer_height
|
||||
canvas.setStrokeColor(colors.black)
|
||||
canvas.setLineWidth(0.75)
|
||||
canvas.line(_MARGIN_X, top + 2 * mm, _MARGIN_X + width, top + 2 * mm)
|
||||
y = top
|
||||
for p in paragraphs:
|
||||
_, h = p.wrap(width, A4[1])
|
||||
y -= h
|
||||
p.drawOn(canvas, _MARGIN_X, y)
|
||||
canvas.restoreState()
|
||||
|
||||
story = []
|
||||
thumbnails: dict[bytes, tuple[bytes, int, int] | None] = {}
|
||||
for i, sheet in enumerate(sheets):
|
||||
if i > 0:
|
||||
story.append(PageBreak())
|
||||
@@ -190,10 +315,10 @@ def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes:
|
||||
style = styles["shiftHeaderWarning"] if shift.understaffed else styles["shiftHeader"]
|
||||
story.append(Paragraph(header, style))
|
||||
if shift.volunteers:
|
||||
story.append(_shift_table(shift))
|
||||
story.append(_shift_table(shift, thumbnails, styles, width))
|
||||
else:
|
||||
story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"]))
|
||||
story.append(Spacer(1, 0))
|
||||
|
||||
doc.build(story)
|
||||
doc.build(story, onFirstPage=draw_footer, onLaterPages=draw_footer)
|
||||
return buffer.getvalue()
|
||||
|
||||
@@ -5,18 +5,16 @@ effectif idéal, une liste ordonnée de bénévoles candidats. C'est la
|
||||
coordination qui affecte (dans l'app, via l'écran habituel — le solveur se
|
||||
contente de faire remonter ces candidats en tête de liste).
|
||||
|
||||
Contrainte dure : aucun chevauchement de créneaux pour un même bénévole (règle
|
||||
`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (disponibilité, repos,
|
||||
chaînage entre créneaux consécutifs, vœu de poste, vœu de compagnon, vœu
|
||||
d'artiste, équité de charge) est un terme de l'objectif, pas une contrainte —
|
||||
comme le moteur de validation, qui reste seul juge au moment où la
|
||||
coordination confirme une affectation.
|
||||
Pas de plafond de cumul journalier côté solveur : `DAILY_HOURS_EXCEEDED`
|
||||
reste vérifiée par la validation, à la confirmation.
|
||||
Contrainte dure : jamais deux suggestions qui se chevauchent pour un même
|
||||
bénévole dans un même calcul. Un bénévole déjà affecté à un autre poste au
|
||||
même moment reste proposable (la validation ne fait qu'avertir,
|
||||
`SHIFT_OVERLAP`) mais avec un fort malus. Tout le reste (chaînage entre
|
||||
créneaux consécutifs, vœu de poste, vœu de compagnon, vœu d'artiste, équité de
|
||||
charge) est un terme de l'objectif, pas une contrainte. Aucune limite d'heures
|
||||
par journée ni de temps de repos : ni le solveur ni la validation n'en
|
||||
tiennent compte.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from ortools.sat.python import cp_model
|
||||
|
||||
from app.solver.models import (
|
||||
@@ -26,22 +24,16 @@ from app.solver.models import (
|
||||
SolverSolution,
|
||||
SolverVolunteer,
|
||||
)
|
||||
from app.validation.engine import uncovered_minutes
|
||||
from app.validation.models import ShiftInfo
|
||||
|
||||
_EIGHT_HOURS_MIN = 8 * 60
|
||||
|
||||
# Poids de l'objectif (unités arbitraires, cf. docstring de `_score`).
|
||||
# L'écart à un effectif minimal non atteint prime sur tout le reste : le
|
||||
# solveur doit toujours proposer quelqu'un pour un créneau sous-staffé, quitte
|
||||
# à ce que la disponibilité soit imparfaite (avertissement, pas un blocage —
|
||||
# la validation reste seule juge à la confirmation). Au-delà du minimum, un
|
||||
# candidat mal assorti peut légitimement ne pas être suggéré du tout.
|
||||
# à ce que le candidat soit mal assorti. Au-delà du minimum, un candidat mal
|
||||
# assorti peut légitimement ne pas être suggéré du tout.
|
||||
_URGENT_BASE = 1000 # créneau encore sous son effectif minimal
|
||||
_NORMAL_BASE = 20 # créneau entre effectif minimal et idéal
|
||||
_WISH_BONUS = 30
|
||||
_AVAIL_PENALTY_PER_HALF_HOUR = 5
|
||||
_REST_PENALTY = 60
|
||||
_FAIRNESS_PENALTY_PER_HOUR = 2
|
||||
# Le créneau lui ferait manquer le passage d'un artiste qu'il veut voir.
|
||||
_ARTIST_CONFLICT_PENALTY = 80
|
||||
@@ -52,28 +44,17 @@ _CHAIN_PENALTY = 40
|
||||
# Par compagnon souhaité déjà affecté au créneau (0 à N vœux par bénévole,
|
||||
# rendus réciproques par le loader — voir `SolverVolunteer.wished_companion_ids`).
|
||||
_COMPANION_BONUS_PER_MATCH = 25
|
||||
# Le bénévole est déjà affecté (affectation décidée) à un créneau qui
|
||||
# chevauche celui-ci. Supérieur à tous les bonus cumulables et à la base
|
||||
# normale : il n'est proposé que pour un créneau sous son minimum, et
|
||||
# seulement faute de mieux.
|
||||
_ALREADY_BUSY_PENALTY = 300
|
||||
|
||||
|
||||
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
|
||||
return a.starts_at < b.ends_at and b.starts_at < a.ends_at
|
||||
|
||||
|
||||
def _has_insufficient_rest(volunteer: SolverVolunteer, shift: SolverShift) -> bool:
|
||||
candidate = shift.shift_info
|
||||
for other in volunteer.existing_shifts:
|
||||
if _overlaps(candidate, other):
|
||||
continue
|
||||
if other.ends_at <= candidate.starts_at:
|
||||
gap = candidate.starts_at - other.ends_at
|
||||
elif candidate.ends_at <= other.starts_at:
|
||||
gap = other.starts_at - candidate.ends_at
|
||||
else:
|
||||
continue
|
||||
if gap < dt.timedelta(minutes=_EIGHT_HOURS_MIN):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _chained_from_predecessor(volunteer: SolverVolunteer, shift: SolverShift) -> bool:
|
||||
"""Le bénévole a déjà (affectation décidée) l'un des créneaux qui
|
||||
précèdent immédiatement celui-ci, le même jour — n'importe quel poste :
|
||||
@@ -88,20 +69,14 @@ def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int:
|
||||
"""Score d'affinité (bénévole, créneau) : plus haut = meilleur candidat.
|
||||
|
||||
Purement additif (aucune interaction entre deux affectations proposées),
|
||||
ce qui garde le modèle CP-SAT linéaire simple. Repos, chaînage et
|
||||
compagnons ne sont vérifiés que contre les affectations déjà décidées,
|
||||
pas entre deux suggestions du même calcul : la validation finale (à la
|
||||
confirmation) reste seule juge en cas de cas limite entre deux
|
||||
suggestions (et deux compagnons suggérés ensemble dans le même calcul ne
|
||||
se renforcent pas mutuellement, faute d'interaction entre variables).
|
||||
ce qui garde le modèle CP-SAT linéaire simple. Chaînage et compagnons ne
|
||||
sont vérifiés que contre les affectations déjà décidées, pas entre deux
|
||||
suggestions du même calcul (deux compagnons suggérés ensemble ne se
|
||||
renforcent pas mutuellement, faute d'interaction entre variables).
|
||||
"""
|
||||
score = _URGENT_BASE if shift.already_assigned < shift.min_staff else _NORMAL_BASE
|
||||
if shift.position_id in volunteer.wished_position_ids:
|
||||
score += _WISH_BONUS
|
||||
missed = uncovered_minutes(shift.shift_info, volunteer.availability)
|
||||
score -= (missed // 30) * _AVAIL_PENALTY_PER_HALF_HOUR
|
||||
if _has_insufficient_rest(volunteer, shift):
|
||||
score -= _REST_PENALTY
|
||||
if _chained_from_predecessor(volunteer, shift):
|
||||
score -= _CHAIN_PENALTY
|
||||
companions_present = len(volunteer.wished_companion_ids & shift.assigned_volunteer_ids)
|
||||
@@ -110,6 +85,8 @@ def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int:
|
||||
_overlaps(shift.shift_info, passage) for passage in volunteer.wished_artist_windows
|
||||
):
|
||||
score -= _ARTIST_CONFLICT_PENALTY
|
||||
if any(_overlaps(shift.shift_info, other) for other in volunteer.existing_shifts):
|
||||
score -= _ALREADY_BUSY_PENALTY
|
||||
score -= (_FAIRNESS_PENALTY_PER_HOUR * volunteer.existing_minutes) // 60
|
||||
return score
|
||||
|
||||
@@ -130,7 +107,8 @@ def solve(
|
||||
if gap <= 0:
|
||||
continue
|
||||
for vi, volunteer in enumerate(data.volunteers):
|
||||
if any(_overlaps(shift.shift_info, other) for other in volunteer.existing_shifts):
|
||||
# Déjà affecté à ce créneau : rien à suggérer.
|
||||
if any(other.id == shift.id for other in volunteer.existing_shifts):
|
||||
continue
|
||||
key = (vi, si)
|
||||
variables[key] = model.NewBoolVar(f"x_{vi}_{si}")
|
||||
@@ -147,8 +125,8 @@ def solve(
|
||||
if shift_vars and gap > 0:
|
||||
model.Add(sum(shift_vars) <= gap)
|
||||
|
||||
# Aucun chevauchement entre deux créneaux nouvellement suggérés au même
|
||||
# bénévole (règle `SHIFT_OVERLAP`, bloquante).
|
||||
# Jamais deux créneaux qui se chevauchent suggérés au même bénévole dans
|
||||
# un même calcul (contre les affectations décidées : malus, cf. `_score`).
|
||||
for vi in range(len(data.volunteers)):
|
||||
my_shifts = [si for si in range(len(data.shifts)) if (vi, si) in variables]
|
||||
for a in range(len(my_shifts)):
|
||||
|
||||
@@ -9,7 +9,6 @@ from sqlmodel import Session, select
|
||||
from app.models import (
|
||||
Artist,
|
||||
Assignment,
|
||||
Availability,
|
||||
DayPart,
|
||||
Edition,
|
||||
Shift,
|
||||
@@ -20,7 +19,7 @@ from app.models import (
|
||||
)
|
||||
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
|
||||
from app.validation.engine import day_boundary, festival_day
|
||||
from app.validation.models import AvailabilityWindow, ShiftInfo
|
||||
from app.validation.models import ShiftInfo
|
||||
|
||||
|
||||
def _artist_window(artist: Artist) -> ShiftInfo:
|
||||
@@ -93,14 +92,6 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
||||
if assigned_count.get(s.id, 0) < s.ideal_staff
|
||||
)
|
||||
|
||||
availabilities: dict = defaultdict(list)
|
||||
for a in session.exec(
|
||||
select(Availability).where(Availability.edition_id == edition.id)
|
||||
):
|
||||
availabilities[a.volunteer_id].append(
|
||||
AvailabilityWindow(date=a.date, start_time=a.start_time, end_time=a.end_time)
|
||||
)
|
||||
|
||||
wishes: dict = defaultdict(set)
|
||||
for w in session.exec(
|
||||
select(VolunteerPositionWish).join(
|
||||
@@ -141,7 +132,6 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
||||
solver_volunteers = tuple(
|
||||
SolverVolunteer(
|
||||
id=str(v.id),
|
||||
availability=tuple(availabilities.get(v.id, ())),
|
||||
wished_position_ids=frozenset(wishes.get(v.id, ())),
|
||||
wished_companion_ids=frozenset(companion_wishes.get(v.id, ())),
|
||||
wished_artist_windows=tuple(artist_wishes.get(v.id, ())),
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
Le solveur ne connaît que ces structures simples (jamais les modèles
|
||||
SQLModel), comme le moteur de validation dont il réutilise les briques
|
||||
(`app.validation.models`, `app.validation.engine.uncovered_minutes`).
|
||||
(`app.validation.models`).
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.validation.models import AvailabilityWindow, ShiftInfo
|
||||
from app.validation.models import ShiftInfo
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -39,7 +39,6 @@ class SolverShift:
|
||||
@dataclass(frozen=True)
|
||||
class SolverVolunteer:
|
||||
id: str
|
||||
availability: tuple[AvailabilityWindow, ...] = ()
|
||||
wished_position_ids: frozenset[str] = frozenset()
|
||||
# Bénévoles (0 à N) avec qui il voudrait être affecté. Le vœu est saisi à
|
||||
# sens unique (`VolunteerCompanionWish`) mais rendu réciproque ici par le
|
||||
@@ -50,7 +49,7 @@ class SolverVolunteer:
|
||||
# lui ferait manquer le passage.
|
||||
wished_artist_windows: tuple[ShiftInfo, ...] = ()
|
||||
# Créneaux déjà occupés (affectations existantes, décisions figées) :
|
||||
# comptent pour chevauchement, repos, chaînage.
|
||||
# comptent pour chevauchement et chaînage.
|
||||
existing_shifts: tuple[ShiftInfo, ...] = ()
|
||||
# Total déjà affecté sur l'édition (minutes), pour répartir la charge.
|
||||
existing_minutes: int = 0
|
||||
|
||||
@@ -9,6 +9,7 @@ l'inscription au formulaire public n'échoue jamais pour autant.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
@@ -24,9 +25,17 @@ class SumUpCheckout:
|
||||
id: str
|
||||
status: str # PENDING / PAID / FAILED / EXPIRED (voir FeePaymentStatus)
|
||||
hosted_checkout_url: str | None
|
||||
# Transaction qui a réglé le checkout (renseignés par SumUp une fois
|
||||
# payé, `None` avant) : `transaction_code` est le code court affiché dans
|
||||
# le tableau de bord SumUp et sur ses reçus (ex. `TEENSK4W2K`) — c'est
|
||||
# lui que la coordination recherche pour rapprocher un paiement d'un
|
||||
# bénévole ; `transaction_id` est l'identifiant technique (API).
|
||||
transaction_code: str | None = None
|
||||
transaction_id: str | None = None
|
||||
|
||||
|
||||
def _call(method: str, path: str, api_key: str, json: dict | None = None) -> dict | None:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
resp = httpx.request(
|
||||
method,
|
||||
@@ -36,19 +45,71 @@ def _call(method: str, path: str, api_key: str, json: dict | None = None) -> dic
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.HTTPError as exc:
|
||||
data = resp.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
# Le corps d'erreur SumUp (`error_code`/`message`, voir leur doc) dit
|
||||
# pourquoi l'appel est refusé (identifiants invalides, montant,
|
||||
# référence déjà utilisée…) — il ne contient que des codes, pas de
|
||||
# données du payeur.
|
||||
logger.warning(
|
||||
"Échec de l'appel API SumUp (%s %s) : HTTP %d %s",
|
||||
method,
|
||||
path,
|
||||
exc.response.status_code,
|
||||
_error_summary(exc.response),
|
||||
)
|
||||
return None
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
logger.warning(
|
||||
"Échec de l'appel API SumUp (%s %s) : %s", method, path, exc.__class__.__name__
|
||||
)
|
||||
return None
|
||||
logger.debug(
|
||||
"API SumUp %s %s : OK en %d ms",
|
||||
method,
|
||||
path,
|
||||
(time.monotonic() - started) * 1000,
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def _error_summary(resp: httpx.Response) -> str:
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
return ""
|
||||
if isinstance(body, list): # SumUp renvoie parfois une liste d'erreurs
|
||||
body = body[0] if body else {}
|
||||
if not isinstance(body, dict):
|
||||
return ""
|
||||
return " ".join(
|
||||
str(body[key])[:200] for key in ("error_code", "message", "param") if body.get(key)
|
||||
)
|
||||
|
||||
|
||||
def _to_checkout(data: dict) -> SumUpCheckout:
|
||||
transaction_code = data.get("transaction_code")
|
||||
transaction_id = data.get("transaction_id")
|
||||
if not transaction_code:
|
||||
# Selon la version de l'API, seuls les essais de paiement
|
||||
# (`transactions`) portent le code : on retient celui qui a abouti.
|
||||
successful = next(
|
||||
(
|
||||
tx
|
||||
for tx in data.get("transactions") or []
|
||||
if isinstance(tx, dict) and tx.get("status") == "SUCCESSFUL"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if successful is not None:
|
||||
transaction_code = successful.get("transaction_code")
|
||||
transaction_id = transaction_id or successful.get("id")
|
||||
return SumUpCheckout(
|
||||
id=data["id"],
|
||||
status=data["status"],
|
||||
hosted_checkout_url=data.get("hosted_checkout_url"),
|
||||
transaction_code=transaction_code,
|
||||
transaction_id=transaction_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -65,7 +126,13 @@ def create_checkout(
|
||||
) -> SumUpCheckout | None:
|
||||
"""Crée un checkout hébergé (`hosted_checkout.enabled=true`) : la page de
|
||||
paiement SumUp est à `hosted_checkout_url`. `None` en cas d'échec (réseau,
|
||||
identifiants invalides…)."""
|
||||
identifiants invalides…).
|
||||
|
||||
L'API de création n'accepte pas de métadonnées libres : `description`
|
||||
(« affichée dans les outils SumUp ») et `checkout_reference` sont les
|
||||
seuls champs du marchand repris sur la transaction — c'est donc par eux
|
||||
que passe le nom du bénévole (voir `app/api/forms.py:
|
||||
_checkout_description`)."""
|
||||
data = _call(
|
||||
"POST",
|
||||
"/v0.1/checkouts",
|
||||
|
||||
@@ -5,7 +5,6 @@ Voir CLAUDE.md § 3 pour les règles et la sémantique.
|
||||
|
||||
from app.validation.engine import has_blocking, validate
|
||||
from app.validation.models import (
|
||||
AvailabilityWindow,
|
||||
DayPartWindow,
|
||||
Severity,
|
||||
ShiftInfo,
|
||||
@@ -15,7 +14,6 @@ from app.validation.models import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AvailabilityWindow",
|
||||
"DayPartWindow",
|
||||
"Severity",
|
||||
"ShiftInfo",
|
||||
|
||||
@@ -7,7 +7,6 @@ sortie. Règles et sémantique figées : voir CLAUDE.md § 3.
|
||||
import datetime as dt
|
||||
|
||||
from app.validation.models import (
|
||||
AvailabilityWindow,
|
||||
Severity,
|
||||
ShiftInfo,
|
||||
ValidationInput,
|
||||
@@ -15,15 +14,10 @@ from app.validation.models import (
|
||||
ViolationCode,
|
||||
)
|
||||
|
||||
_EIGHT_HOURS = dt.timedelta(hours=8)
|
||||
|
||||
|
||||
def validate(data: ValidationInput) -> list[Violation]:
|
||||
violations: list[Violation] = []
|
||||
violations.extend(_check_overlap(data))
|
||||
violations.extend(_check_outside_availability(data))
|
||||
violations.extend(_check_daily_hours(data))
|
||||
violations.extend(_check_insufficient_rest(data))
|
||||
return violations
|
||||
|
||||
|
||||
@@ -32,7 +26,7 @@ def has_blocking(violations: list[Violation]) -> bool:
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SHIFT_OVERLAP (bloquante) #
|
||||
# SHIFT_OVERLAP (avertissement) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
|
||||
# Bornes strictes : deux créneaux jointifs (fin == début) ne se chevauchent
|
||||
@@ -55,66 +49,53 @@ def _check_overlap(data: ValidationInput) -> list[Violation]:
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# OUTSIDE_AVAILABILITY (avertissement) #
|
||||
# « Journée de festival » — plus utilisée par la validation elle-même, mais #
|
||||
# partagée par le solveur, les feuilles de poste et les statistiques. #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _window_interval(w: AvailabilityWindow) -> tuple[dt.datetime, dt.datetime]:
|
||||
start = dt.datetime.combine(w.date, w.start_time)
|
||||
end_day = w.date if w.end_time > w.start_time else w.date + dt.timedelta(days=1)
|
||||
return start, dt.datetime.combine(end_day, w.end_time)
|
||||
_MINUTES_PER_DAY = 24 * 60
|
||||
|
||||
|
||||
def uncovered_minutes(
|
||||
shift: ShiftInfo, windows: tuple[AvailabilityWindow, ...]
|
||||
) -> int:
|
||||
"""Minutes du créneau qui ne tombent dans aucune fenêtre de disponibilité.
|
||||
|
||||
Le bénévole peut déclarer plusieurs fenêtres ; on regarde leur union.
|
||||
"""
|
||||
cursor = shift.starts_at
|
||||
intervals = sorted(
|
||||
(iv for iv in map(_window_interval, windows) if iv[1] > iv[0]),
|
||||
key=lambda iv: iv[0],
|
||||
)
|
||||
gap = dt.timedelta()
|
||||
for start, end in intervals:
|
||||
if end <= cursor:
|
||||
continue
|
||||
if start > shift.ends_at:
|
||||
break
|
||||
if start > cursor:
|
||||
gap += min(start, shift.ends_at) - cursor
|
||||
cursor = max(cursor, end)
|
||||
if cursor >= shift.ends_at:
|
||||
return int(gap.total_seconds() // 60)
|
||||
if cursor < shift.ends_at:
|
||||
gap += shift.ends_at - cursor
|
||||
return int(gap.total_seconds() // 60)
|
||||
|
||||
|
||||
def _check_outside_availability(data: ValidationInput) -> list[Violation]:
|
||||
missing = uncovered_minutes(data.candidate_shift, data.availabilities)
|
||||
if missing == 0:
|
||||
return []
|
||||
return [
|
||||
Violation.of(
|
||||
ViolationCode.OUTSIDE_AVAILABILITY,
|
||||
date=data.candidate_shift.starts_at.date().isoformat(),
|
||||
uncovered_minutes=missing,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# DAILY_HOURS_EXCEEDED (avertissement) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def day_boundary(day_parts) -> dt.time:
|
||||
"""Début d'une « journée de festival » : début du premier créneau type
|
||||
(`DayPart`, par `order`), minuit à défaut — voir CLAUDE.md § 3. Accepte
|
||||
aussi bien les `DayPartInfo` de ce module que les lignes `DayPart`."""
|
||||
"""Début d'une « journée de festival » : début du plus long creux que
|
||||
laissent les créneaux types (`DayPart`) sur le cadran de 24 h — l'heure à
|
||||
laquelle le dernier de la journée se termine —, minuit à défaut — voir
|
||||
CLAUDE.md § 3. Avec Matin 08 h–13 h, Après-midi 13 h–19 h, Soirée
|
||||
19 h–02 h, le creux 02 h–08 h est le plus long : la journée
|
||||
commence à 02 h, la soirée qui passe minuit reste sur le jour où elle a
|
||||
commencé. Indépendant de `order`, qui n'est qu'un ordre d'affichage et
|
||||
ne suit pas les heures quand on les modifie (un créneau type d'ordre 0
|
||||
déplacé à 21 h faisait commencer chaque journée à 21 h). Sans creux (les
|
||||
créneaux types couvrent 24 h), début du premier par `order`. Accepte
|
||||
aussi bien les `DayPartInfo` de ce module que les lignes `DayPart`.
|
||||
Miroir front : `lib/planning.ts:dayBoundaryFromDayParts`."""
|
||||
day_parts = list(day_parts)
|
||||
if not day_parts:
|
||||
return dt.time(0, 0)
|
||||
return min(day_parts, key=lambda part: part.order).start_time
|
||||
covered = [False] * _MINUTES_PER_DAY
|
||||
for part in day_parts:
|
||||
start = part.start_time.hour * 60 + part.start_time.minute
|
||||
end = part.end_time.hour * 60 + part.end_time.minute
|
||||
length = (end - start) % _MINUTES_PER_DAY or _MINUTES_PER_DAY
|
||||
for offset in range(length):
|
||||
covered[(start + offset) % _MINUTES_PER_DAY] = True
|
||||
if all(covered):
|
||||
return min(day_parts, key=lambda part: part.order).start_time
|
||||
# Parcours circulaire à partir d'une minute couverte : chaque creux est
|
||||
# vu d'un seul tenant, même s'il enjambe minuit.
|
||||
origin = covered.index(True)
|
||||
best_start, best_length = 0, 0
|
||||
run_start, run_length = 0, 0
|
||||
for offset in range(1, _MINUTES_PER_DAY + 1):
|
||||
minute = (origin + offset) % _MINUTES_PER_DAY
|
||||
if covered[minute]:
|
||||
run_length = 0
|
||||
continue
|
||||
if run_length == 0:
|
||||
run_start = minute
|
||||
run_length += 1
|
||||
if run_length > best_length:
|
||||
best_start, best_length = run_start, run_length
|
||||
return dt.time(best_start // 60, best_start % 60)
|
||||
|
||||
|
||||
def festival_day(moment: dt.datetime, boundary: dt.time) -> dt.date:
|
||||
@@ -125,53 +106,3 @@ def festival_day(moment: dt.datetime, boundary: dt.time) -> dt.date:
|
||||
if moment.time() >= boundary:
|
||||
return moment.date()
|
||||
return moment.date() - dt.timedelta(days=1)
|
||||
|
||||
|
||||
def _check_daily_hours(data: ValidationInput) -> list[Violation]:
|
||||
boundary = day_boundary(data.day_parts)
|
||||
candidate = data.candidate_shift
|
||||
key = festival_day(candidate.starts_at, boundary)
|
||||
|
||||
total = candidate.ends_at - candidate.starts_at
|
||||
for other in data.existing_shifts:
|
||||
if festival_day(other.starts_at, boundary) == key:
|
||||
total += other.ends_at - other.starts_at
|
||||
|
||||
if total > _EIGHT_HOURS:
|
||||
return [
|
||||
Violation.of(
|
||||
ViolationCode.DAILY_HOURS_EXCEEDED,
|
||||
date=key.isoformat(),
|
||||
total_minutes=int(total.total_seconds() // 60),
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# INSUFFICIENT_REST (avertissement) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _check_insufficient_rest(data: ValidationInput) -> list[Violation]:
|
||||
candidate = data.candidate_shift
|
||||
out: list[Violation] = []
|
||||
for other in data.existing_shifts:
|
||||
if _overlaps(candidate, other):
|
||||
continue
|
||||
if other.ends_at <= candidate.starts_at:
|
||||
gap = candidate.starts_at - other.ends_at
|
||||
direction = "BEFORE"
|
||||
elif candidate.ends_at <= other.starts_at:
|
||||
gap = other.starts_at - candidate.ends_at
|
||||
direction = "AFTER"
|
||||
else: # pragma: no cover - inatteignable : non-chevauchement ⇒ avant ou après
|
||||
continue
|
||||
if gap < _EIGHT_HOURS:
|
||||
out.append(
|
||||
Violation.of(
|
||||
ViolationCode.INSUFFICIENT_REST,
|
||||
other_shift_id=other.id,
|
||||
direction=direction,
|
||||
gap_minutes=int(gap.total_seconds() // 60),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
@@ -8,15 +8,11 @@ from sqlmodel import Session, select
|
||||
|
||||
from app.models import (
|
||||
Assignment,
|
||||
Availability,
|
||||
DayPart,
|
||||
Edition,
|
||||
Shift,
|
||||
Volunteer,
|
||||
)
|
||||
from app.validation.models import (
|
||||
AvailabilityWindow,
|
||||
DayPartWindow,
|
||||
ShiftInfo,
|
||||
ValidationInput,
|
||||
)
|
||||
@@ -30,10 +26,6 @@ def build_validation_input(
|
||||
shift: Shift,
|
||||
exclude_assignment_id: uuid.UUID | None = None,
|
||||
) -> ValidationInput:
|
||||
day_parts = list(
|
||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||
)
|
||||
|
||||
# Toute affectation existante du bénévole compte (une affectation = une
|
||||
# décision, toujours active).
|
||||
stmt = (
|
||||
@@ -49,17 +41,6 @@ def build_validation_input(
|
||||
for s in session.exec(stmt)
|
||||
)
|
||||
|
||||
declarations = tuple(
|
||||
AvailabilityWindow(
|
||||
date=a.date, start_time=a.start_time, end_time=a.end_time
|
||||
)
|
||||
for a in session.exec(
|
||||
select(Availability).where(
|
||||
Availability.volunteer_id == volunteer.id
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return ValidationInput(
|
||||
candidate_shift=ShiftInfo(
|
||||
id=str(shift.id),
|
||||
@@ -67,9 +48,4 @@ def build_validation_input(
|
||||
ends_at=shift.ends_at,
|
||||
),
|
||||
existing_shifts=existing_shifts,
|
||||
availabilities=declarations,
|
||||
day_parts=tuple(
|
||||
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order)
|
||||
for dp in day_parts
|
||||
),
|
||||
)
|
||||
|
||||
@@ -18,16 +18,12 @@ class Severity(StrEnum):
|
||||
|
||||
class ViolationCode(StrEnum):
|
||||
SHIFT_OVERLAP = "SHIFT_OVERLAP"
|
||||
OUTSIDE_AVAILABILITY = "OUTSIDE_AVAILABILITY"
|
||||
DAILY_HOURS_EXCEEDED = "DAILY_HOURS_EXCEEDED"
|
||||
INSUFFICIENT_REST = "INSUFFICIENT_REST"
|
||||
|
||||
|
||||
SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
|
||||
ViolationCode.SHIFT_OVERLAP: Severity.BLOCKING,
|
||||
ViolationCode.OUTSIDE_AVAILABILITY: Severity.WARNING,
|
||||
ViolationCode.DAILY_HOURS_EXCEEDED: Severity.WARNING,
|
||||
ViolationCode.INSUFFICIENT_REST: Severity.WARNING,
|
||||
# Avertissement seulement : un bénévole peut tenir deux postes à la fois
|
||||
# (le solveur l'évite par un malus, cf. `app.solver.engine`).
|
||||
ViolationCode.SHIFT_OVERLAP: Severity.WARNING,
|
||||
}
|
||||
|
||||
|
||||
@@ -53,8 +49,8 @@ class ShiftInfo:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DayPartWindow:
|
||||
"""Créneau type. Ne sert plus qu'à fixer la borne de « journée » pour le
|
||||
cumul des 8 h (début du premier créneau type)."""
|
||||
"""Créneau type. Ne sert plus qu'à fixer la borne de « journée de
|
||||
festival » (`engine.day_boundary`)."""
|
||||
|
||||
code: str
|
||||
start_time: dt.time
|
||||
@@ -63,18 +59,6 @@ class DayPartWindow:
|
||||
order: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AvailabilityWindow:
|
||||
"""« Le <date>, je peux de <start_time> à <end_time>. »
|
||||
|
||||
`end_time <= start_time` ⇒ la fenêtre se termine le lendemain.
|
||||
"""
|
||||
|
||||
date: dt.date
|
||||
start_time: dt.time
|
||||
end_time: dt.time
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidationInput:
|
||||
"""Tout ce qu'il faut pour valider une affectation candidate.
|
||||
@@ -85,5 +69,3 @@ class ValidationInput:
|
||||
|
||||
candidate_shift: ShiftInfo
|
||||
existing_shifts: tuple[ShiftInfo, ...] = ()
|
||||
availabilities: tuple[AvailabilityWindow, ...] = ()
|
||||
day_parts: tuple[DayPartWindow, ...] = ()
|
||||
|
||||
@@ -4,7 +4,6 @@ import datetime as dt
|
||||
|
||||
from app.models import (
|
||||
Artist,
|
||||
Availability,
|
||||
Position,
|
||||
PositionCategory,
|
||||
Shift,
|
||||
@@ -74,34 +73,5 @@ def make_volunteer(
|
||||
return volunteer
|
||||
|
||||
|
||||
def make_availability(
|
||||
session, edition, volunteer, *, date, start_time, end_time
|
||||
):
|
||||
"""Fenêtre de disponibilité libre. `end_time <= start_time` ⇒ passe minuit."""
|
||||
row = Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=date,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def declare_available_all_day(session, edition, volunteer, date):
|
||||
"""Disponible de 00:00 à 23:59 le jour donné (couvre tout un créneau
|
||||
diurne ; pour la nuit, déclarer une fenêtre dédiée)."""
|
||||
make_availability(
|
||||
session,
|
||||
edition,
|
||||
volunteer,
|
||||
date=date,
|
||||
start_time=dt.time(0, 0),
|
||||
end_time=dt.time(23, 59),
|
||||
)
|
||||
|
||||
|
||||
def dt_at(day: int, hour: int, minute: int = 0) -> dt.datetime:
|
||||
return dt.datetime(2026, 7, day, hour, minute)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import datetime as dt
|
||||
|
||||
from tests.factories import (
|
||||
declare_available_all_day,
|
||||
dt_at,
|
||||
make_position,
|
||||
make_shift,
|
||||
@@ -13,16 +12,12 @@ def _url(edition, suffix=""):
|
||||
return f"/editions/{edition.id}/assignments{suffix}"
|
||||
|
||||
|
||||
def _setup(session, edition, *, available=True):
|
||||
def _setup(session, edition):
|
||||
position = make_position(session, edition)
|
||||
shift = make_shift(
|
||||
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18)
|
||||
)
|
||||
volunteer = make_volunteer(session, edition, email="v@ex.fr")
|
||||
if available:
|
||||
declare_available_all_day(
|
||||
session, edition, volunteer, dt.date(2026, 7, 10)
|
||||
)
|
||||
return volunteer, shift
|
||||
|
||||
|
||||
@@ -39,19 +34,28 @@ def test_create_assignment_clean(client, edition, session, day_parts):
|
||||
assert body["assignment"]["locked"] is False
|
||||
|
||||
|
||||
def test_create_assignment_returns_warnings(client, edition, session, day_parts):
|
||||
# Bénévole non déclaré disponible sur le créneau.
|
||||
volunteer, shift = _setup(session, edition, available=False)
|
||||
resp = client.post(
|
||||
_url(edition),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||
def test_long_day_without_rest_raises_no_warning(client, edition, session, day_parts):
|
||||
# Plus de limite d'heures par jour ni de repos minimal : 14h–18h puis
|
||||
# 18h–04h (14 h d'affilée) passe sans avertissement.
|
||||
volunteer, shift = _setup(session, edition)
|
||||
position = make_position(session, edition, name="Bar")
|
||||
following = make_shift(
|
||||
session, edition, position, start=dt_at(10, 18), end=dt_at(11, 4)
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
codes = {w["code"] for w in resp.json()["warnings"]}
|
||||
assert "OUTSIDE_AVAILABILITY" in codes
|
||||
for s in (shift, following):
|
||||
resp = client.post(
|
||||
_url(edition),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(s.id)},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["warnings"] == []
|
||||
|
||||
|
||||
def test_create_assignment_blocked_by_overlap(client, edition, session, day_parts):
|
||||
def test_create_assignment_overlap_is_allowed_with_warning(
|
||||
client, edition, session, day_parts
|
||||
):
|
||||
# Un bénévole peut tenir deux postes en même temps : l'affectation est
|
||||
# enregistrée, le chevauchement remonte en avertissement.
|
||||
volunteer, shift = _setup(session, edition)
|
||||
assert client.post(
|
||||
_url(edition),
|
||||
@@ -69,15 +73,14 @@ def test_create_assignment_blocked_by_overlap(client, edition, session, day_part
|
||||
"shift_id": str(overlapping.id),
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
detail = resp.json()["detail"]
|
||||
assert detail["code"] == "error.validation_failed"
|
||||
assert any(v["code"] == "SHIFT_OVERLAP" for v in detail["violations"])
|
||||
# Rien n'a été enregistré pour ce créneau.
|
||||
assert resp.status_code == 201
|
||||
warnings = resp.json()["warnings"]
|
||||
assert [w["code"] for w in warnings] == ["SHIFT_OVERLAP"]
|
||||
assert warnings[0]["severity"] == "WARNING"
|
||||
listed = client.get(
|
||||
_url(edition), params={"shift_id": str(overlapping.id)}
|
||||
).json()
|
||||
assert listed == []
|
||||
assert len(listed) == 1
|
||||
|
||||
|
||||
def test_create_duplicate_assignment_conflicts(client, edition, session, day_parts):
|
||||
@@ -110,7 +113,7 @@ def test_assignment_wrong_edition_is_422(client, edition, session, day_parts):
|
||||
|
||||
|
||||
def test_validate_endpoint_does_not_persist(client, edition, session, day_parts):
|
||||
volunteer, shift = _setup(session, edition, available=False)
|
||||
volunteer, shift = _setup(session, edition)
|
||||
resp = client.post(
|
||||
_url(edition, "/validate"),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||
@@ -118,14 +121,12 @@ def test_validate_endpoint_does_not_persist(client, edition, session, day_parts)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["blocking"] is False
|
||||
assert any(v["code"] == "OUTSIDE_AVAILABILITY" for v in body["violations"])
|
||||
assert body["violations"] == []
|
||||
assert client.get(_url(edition)).json() == []
|
||||
|
||||
|
||||
def test_patch_locked_keeps_assignment_and_returns_warnings(
|
||||
client, edition, session, day_parts
|
||||
):
|
||||
volunteer, shift = _setup(session, edition, available=False)
|
||||
def test_patch_locked_keeps_assignment(client, edition, session, day_parts):
|
||||
volunteer, shift = _setup(session, edition)
|
||||
aid = client.post(
|
||||
_url(edition),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||
@@ -134,9 +135,7 @@ def test_patch_locked_keeps_assignment_and_returns_warnings(
|
||||
resp = client.patch(_url(edition, f"/{aid}"), json={"locked": True})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["assignment"]["locked"] is True
|
||||
assert any(
|
||||
w["code"] == "OUTSIDE_AVAILABILITY" for w in resp.json()["warnings"]
|
||||
)
|
||||
assert resp.json()["warnings"] == []
|
||||
|
||||
|
||||
def test_delete_assignment(client, edition, session, day_parts):
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Pointage présent / absent d'un bénévole sur son créneau
|
||||
(`PUT .../assignments/{id}/attendance`) : enregistré en base, par le
|
||||
référent du poste ou la coordination."""
|
||||
|
||||
from app.models import Assignment, Role
|
||||
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
||||
|
||||
|
||||
def _assignment(session, edition, lead=None):
|
||||
position = make_position(session, edition)
|
||||
if lead is not None:
|
||||
position.lead_account_id = lead.id
|
||||
session.add(position)
|
||||
session.commit()
|
||||
shift = make_shift(session, edition, position, start=dt_at(10, 18), end=dt_at(10, 22))
|
||||
volunteer = make_volunteer(session, edition)
|
||||
assignment = Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id)
|
||||
session.add(assignment)
|
||||
session.commit()
|
||||
session.refresh(assignment)
|
||||
return assignment
|
||||
|
||||
|
||||
def _url(edition, assignment):
|
||||
return f"/editions/{edition.id}/assignments/{assignment.id}/attendance"
|
||||
|
||||
|
||||
def test_lead_marks_attendance_and_it_is_kept(edition, session, make_account, as_account):
|
||||
lead = make_account(Role.LEAD)
|
||||
assignment = _assignment(session, edition, lead)
|
||||
client = as_account(lead)
|
||||
|
||||
resp = client.put(_url(edition, assignment), json={"attendance": "PRESENT"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["attendance"] == "PRESENT"
|
||||
assert resp.json()["attendance_marked_at"] is not None
|
||||
|
||||
# Relu ensuite (rechargement de l'écran) : le pointage est toujours là.
|
||||
(listed,) = client.get(f"/editions/{edition.id}/assignments").json()
|
||||
assert listed["attendance"] == "PRESENT"
|
||||
|
||||
|
||||
def test_attendance_can_be_switched_and_cleared(client, edition, session):
|
||||
assignment = _assignment(session, edition)
|
||||
assert client.put(_url(edition, assignment), json={"attendance": "ABSENT"}).json()[
|
||||
"attendance"
|
||||
] == "ABSENT"
|
||||
cleared = client.put(_url(edition, assignment), json={"attendance": None}).json()
|
||||
assert cleared["attendance"] is None
|
||||
assert cleared["attendance_marked_at"] is None
|
||||
|
||||
|
||||
def test_lead_of_another_position_cannot_mark(edition, session, make_account, as_account):
|
||||
assignment = _assignment(session, edition, make_account(Role.LEAD))
|
||||
resp = as_account(make_account(Role.LEAD)).put(
|
||||
_url(edition, assignment), json={"attendance": "PRESENT"}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_volunteer_cannot_mark(edition, session, make_account, as_account):
|
||||
assignment = _assignment(session, edition)
|
||||
resp = as_account(make_account(Role.VOLUNTEER)).put(
|
||||
_url(edition, assignment), json={"attendance": "PRESENT"}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_unknown_status_is_rejected(client, edition, session):
|
||||
assignment = _assignment(session, edition)
|
||||
resp = client.put(_url(edition, assignment), json={"attendance": "LATE"})
|
||||
assert resp.status_code == 422
|
||||
@@ -1,114 +0,0 @@
|
||||
from app.models import Role
|
||||
from tests.factories import make_volunteer
|
||||
|
||||
|
||||
def _url(edition, volunteer, suffix=""):
|
||||
return f"/editions/{edition.id}/volunteers/{volunteer.id}/availability{suffix}"
|
||||
|
||||
|
||||
def test_put_then_get_returns_windows_sorted(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-11", "start_time": "14:00", "end_time": "19:00"},
|
||||
{"date": "2026-07-10", "start_time": "08:00", "end_time": "13:00"},
|
||||
{"date": "2026-07-10", "start_time": "19:00", "end_time": "02:00"},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
got = [(w["date"], w["start_time"], w["end_time"]) for w in resp.json()]
|
||||
assert got == [
|
||||
("2026-07-10", "08:00:00", "13:00:00"),
|
||||
("2026-07-10", "19:00:00", "02:00:00"),
|
||||
("2026-07-11", "14:00:00", "19:00:00"),
|
||||
]
|
||||
assert client.get(_url(edition, v)).json() == resp.json()
|
||||
|
||||
|
||||
def test_put_replaces_the_whole_set_and_dedupes(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-10", "start_time": "08:00", "end_time": "12:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-11", "start_time": "09:00", "end_time": "17:00"},
|
||||
{"date": "2026-07-11", "start_time": "09:00", "end_time": "17:00"},
|
||||
]
|
||||
},
|
||||
)
|
||||
body = resp.json()
|
||||
assert len(body) == 1
|
||||
assert body[0]["date"] == "2026-07-11"
|
||||
|
||||
|
||||
def test_put_rejects_date_outside_edition(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-08-01", "start_time": "08:00", "end_time": "12:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "availability.date_outside_edition"
|
||||
|
||||
|
||||
def test_put_rejects_empty_window(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-10", "start_time": "09:00", "end_time": "09:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "availability.empty_window"
|
||||
|
||||
|
||||
def test_edition_wide_listing(client, edition, session):
|
||||
v1 = make_volunteer(session, edition, first_name="A", email="a@e.fr")
|
||||
v2 = make_volunteer(session, edition, first_name="B", email="b@e.fr")
|
||||
for v in (v1, v2):
|
||||
client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-10", "start_time": "08:00", "end_time": "13:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
body = client.get(f"/editions/{edition.id}/availability").json()
|
||||
assert {w["volunteer_id"] for w in body} == {str(v1.id), str(v2.id)}
|
||||
|
||||
|
||||
def test_lead_can_read_but_not_write(edition, session, make_account, as_account):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
api = as_account(lead)
|
||||
assert api.get(_url(edition, v)).status_code == 200
|
||||
resp = api.put(_url(edition, v), json={"windows": []})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_volunteer_reads_only_their_own(edition, session, make_account, as_account):
|
||||
me = make_volunteer(session, edition, email="me@e.fr")
|
||||
other = make_volunteer(session, edition, email="other@e.fr")
|
||||
account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="acc@e.fr")
|
||||
api = as_account(account)
|
||||
assert api.get(_url(edition, me)).status_code == 200
|
||||
assert api.get(_url(edition, other)).status_code == 403
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Messages aux bénévoles : celui de l'organisation (`Edition.
|
||||
volunteer_message`, coordination) et celui de chaque poste (`Position.
|
||||
message`, référent du poste ou coordination)."""
|
||||
|
||||
from app.models import Assignment, Role
|
||||
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
||||
|
||||
|
||||
def test_coordination_sets_the_organisation_message(
|
||||
client, edition, session, make_account, as_account
|
||||
):
|
||||
resp = client.patch(f"/editions/{edition.id}", json={"volunteer_message": " Repas à 18h "})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["volunteer_message"] == "Repas à 18h"
|
||||
assert resp.json()["volunteer_message_updated_at"] is not None
|
||||
|
||||
volunteer = make_volunteer(session, edition)
|
||||
reader = as_account(make_account(Role.VOLUNTEER, volunteer_id=volunteer.id))
|
||||
resp = reader.get(f"/editions/{edition.id}")
|
||||
assert resp.json()["volunteer_message"] == "Repas à 18h"
|
||||
|
||||
|
||||
def test_blank_message_clears_it(client, edition):
|
||||
client.patch(f"/editions/{edition.id}", json={"volunteer_message": "Bonjour"})
|
||||
resp = client.patch(f"/editions/{edition.id}", json={"volunteer_message": " "})
|
||||
assert resp.json()["volunteer_message"] is None
|
||||
assert resp.json()["volunteer_message_updated_at"] is None
|
||||
|
||||
|
||||
def test_unchanged_message_keeps_its_date(client, edition):
|
||||
first = client.patch(f"/editions/{edition.id}", json={"volunteer_message": "Bonjour"}).json()
|
||||
again = client.patch(
|
||||
f"/editions/{edition.id}", json={"volunteer_message": "Bonjour", "name": "X"}
|
||||
).json()
|
||||
assert again["volunteer_message_updated_at"] == first["volunteer_message_updated_at"]
|
||||
|
||||
|
||||
def test_volunteer_and_lead_cannot_set_the_organisation_message(edition, make_account, as_account):
|
||||
for role in (Role.VOLUNTEER, Role.LEAD):
|
||||
resp = as_account(make_account(role)).patch(
|
||||
f"/editions/{edition.id}", json={"volunteer_message": "Hack"}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_message_too_long_is_rejected(client, edition):
|
||||
resp = client.patch(f"/editions/{edition.id}", json={"volunteer_message": "x" * 2001})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_lead_sets_the_message_of_their_position(edition, session, make_account, as_account):
|
||||
lead = make_account(Role.LEAD)
|
||||
position = make_position(session, edition)
|
||||
position.lead_account_id = lead.id
|
||||
session.add(position)
|
||||
session.commit()
|
||||
|
||||
resp = as_account(lead).patch(
|
||||
f"/editions/{edition.id}/positions/{position.id}", json={"message": "Gilet fourni"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "Gilet fourni"
|
||||
assert resp.json()["message_updated_at"] is not None
|
||||
|
||||
|
||||
def test_lead_cannot_set_the_message_of_another_position(
|
||||
edition, session, make_account, as_account
|
||||
):
|
||||
position = make_position(session, edition)
|
||||
resp = as_account(make_account(Role.LEAD)).patch(
|
||||
f"/editions/{edition.id}/positions/{position.id}", json={"message": "Hack"}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_position_message_reaches_the_volunteer_planning(client, edition, session):
|
||||
volunteer = make_volunteer(session, edition)
|
||||
position = make_position(session, edition)
|
||||
shift = make_shift(session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12))
|
||||
session.add(Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id))
|
||||
session.commit()
|
||||
client.patch(
|
||||
f"/editions/{edition.id}/positions/{position.id}", json={"message": "Gilet fourni"}
|
||||
)
|
||||
|
||||
(entry,) = client.get(f"/editions/{edition.id}/volunteers/{volunteer.id}/planning").json()
|
||||
assert entry["position_message"] == "Gilet fourni"
|
||||
assert entry["position_message_updated_at"] is not None
|
||||
@@ -62,8 +62,9 @@ def test_magic_link_is_embedded_when_placeholder_present(
|
||||
_url(edition), json={"body": "Connecte-toi : {lien_connexion}"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "/connexion?token=" in plain_text(outbox[0])
|
||||
assert len(session.exec(select(MagicLinkToken)).all()) == 1
|
||||
assert "/connexion?token=pl." in plain_text(outbox[0])
|
||||
# Lien permanent : rien de stocké, pas de jeton à usage unique.
|
||||
assert session.exec(select(MagicLinkToken)).all() == []
|
||||
|
||||
|
||||
def test_no_magic_link_token_without_placeholder(
|
||||
@@ -91,3 +92,111 @@ def test_planning_notification_is_coordinator_only(
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
resp = as_account(lead).post(_url(edition), json={})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_shifts_placeholder_is_a_table_by_day_with_the_referent(
|
||||
client, edition, session, outbox, make_account
|
||||
):
|
||||
rita = make_volunteer(session, edition, first_name="Rita", last_name="Ref", email="r@e.fr")
|
||||
rita.phone = "06 11 22 33 44"
|
||||
session.add(rita)
|
||||
lead = make_account(Role.LEAD, volunteer_id=rita.id)
|
||||
bar = make_position(session, edition, name="Bar central")
|
||||
bar.lead_account_id = lead.id
|
||||
session.add(bar)
|
||||
parking = make_position(session, edition, name="Parking")
|
||||
s1 = make_shift(session, edition, bar, start=dt_at(10, 18), end=dt_at(10, 22))
|
||||
s2 = make_shift(session, edition, parking, start=dt_at(11, 9), end=dt_at(11, 12))
|
||||
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
||||
session.add_all(
|
||||
[
|
||||
Assignment(edition_id=edition.id, volunteer_id=ana.id, shift_id=s1.id),
|
||||
Assignment(edition_id=edition.id, volunteer_id=ana.id, shift_id=s2.id),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
client.post(
|
||||
_url(edition), json={"volunteer_ids": [str(ana.id)], "body": "Ton planning :\n\n{creneaux}"}
|
||||
)
|
||||
(message,) = outbox
|
||||
html = message.get_body(preferencelist=("html",)).get_content()
|
||||
# Vrai tableau : en-têtes, une ligne de jour, horaire, poste, référent.
|
||||
assert "<table" in html and "Référent" in html
|
||||
assert "18:00–22:00" in html and "Bar central" in html
|
||||
assert "Rita Ref · 06 11 22 33 44" in html
|
||||
assert "à désigner" in html # Parking n'a pas de référent
|
||||
assert "" not in html
|
||||
text = plain_text(message)
|
||||
assert "18:00–22:00 · Bar central · référent : Rita Ref · 06 11 22 33 44" in text
|
||||
assert "09:00–12:00 · Parking · référent : à désigner" in text
|
||||
|
||||
|
||||
|
||||
def _link(message) -> str:
|
||||
import re
|
||||
|
||||
return re.search(r"token=(\S+)", plain_text(message)).group(1)
|
||||
|
||||
|
||||
def _verify(client, edition, token):
|
||||
return client.post(f"/editions/{edition.id}/auth/verify", json={"token": token})
|
||||
|
||||
|
||||
def test_login_link_is_permanent_and_reusable(client, edition, session, make_account, outbox):
|
||||
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
||||
make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="ana@e.fr")
|
||||
client.post(_url(edition), json={"body": "{lien_connexion}"})
|
||||
client.post(_url(edition), json={"body": "{lien_connexion}"})
|
||||
first, second = (_link(m) for m in outbox)
|
||||
# Le même lien dans chaque email, utilisable plusieurs fois.
|
||||
assert first == second
|
||||
for _ in range(3):
|
||||
resp = _verify(client, edition, first)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["account"]["volunteer_id"] == str(ana.id)
|
||||
|
||||
|
||||
def test_reset_revokes_the_links_already_sent(client, edition, session, make_account, outbox):
|
||||
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
||||
account = make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="ana@e.fr")
|
||||
client.post(_url(edition), json={"body": "{lien_connexion}"})
|
||||
old = _link(outbox[0])
|
||||
|
||||
resp = client.post(f"/editions/{edition.id}/accounts/{account.id}/reset-login-link")
|
||||
assert resp.status_code == 200
|
||||
assert _verify(client, edition, old).status_code == 401
|
||||
|
||||
client.post(_url(edition), json={"body": "{lien_connexion}"})
|
||||
new = _link(outbox[1])
|
||||
assert new != old
|
||||
assert _verify(client, edition, new).status_code == 200
|
||||
|
||||
|
||||
def test_sending_the_link_gives_an_account_to_a_volunteer_without_one(
|
||||
client, edition, session, outbox
|
||||
):
|
||||
from app.models import Account
|
||||
|
||||
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
||||
client.post(_url(edition), json={"body": "{lien_connexion}"})
|
||||
(account,) = session.exec(select(Account).where(Account.volunteer_id == ana.id)).all()
|
||||
assert account.role == Role.VOLUNTEER
|
||||
assert _verify(client, edition, _link(outbox[0])).status_code == 200
|
||||
|
||||
|
||||
def test_shared_address_gets_the_login_page_only(client, edition, session, make_account, outbox):
|
||||
# Couple sur la même adresse : un seul compte possible pour cet email.
|
||||
ana = make_volunteer(session, edition, first_name="Ana", email="foyer@e.fr")
|
||||
make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="foyer@e.fr")
|
||||
bob = make_volunteer(session, edition, first_name="Bob", email="foyer@e.fr")
|
||||
client.post(_url(edition), json={"volunteer_ids": [str(bob.id)], "body": "{lien_connexion}"})
|
||||
assert "token=" not in plain_text(outbox[0])
|
||||
|
||||
|
||||
def test_forged_or_coordinator_links_are_refused(client, edition, coordinator):
|
||||
from app.auth.permanent_link import permanent_login_token
|
||||
|
||||
assert _verify(client, edition, permanent_login_token(coordinator)).status_code == 401
|
||||
assert _verify(client, edition, "pl.nimportequoi.abc").status_code == 401
|
||||
assert _verify(client, edition, f"pl.{coordinator.id.hex}.faux").status_code == 401
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Statistiques d'une édition (inscriptions, réponses, paiements, besoins)."""
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from app.models import (
|
||||
Assignment,
|
||||
FeePaymentStatus,
|
||||
PendingFormSubmission,
|
||||
Role,
|
||||
VolunteerArtist,
|
||||
VolunteerCompanionWish,
|
||||
VolunteerPositionWish,
|
||||
)
|
||||
from tests.factories import (
|
||||
dt_at,
|
||||
make_artist,
|
||||
make_position,
|
||||
make_shift,
|
||||
make_volunteer,
|
||||
)
|
||||
|
||||
|
||||
def _url(edition):
|
||||
return f"/editions/{edition.id}/stats"
|
||||
|
||||
|
||||
def _registered_on(session, volunteer, day):
|
||||
volunteer.created_at = dt.datetime(2026, 6, day, 10, 0)
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_is_coordinator_only(edition, make_account, as_account):
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
assert as_account(lead).get(_url(edition)).status_code == 403
|
||||
|
||||
|
||||
def test_empty_edition(client, edition):
|
||||
resp = client.get(_url(edition))
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["registrations"]["total"] == 0
|
||||
assert body["registrations"]["by_day"] == []
|
||||
assert body["payments"]["total_collected"] == 0
|
||||
assert body["needs"]["by_day"] == []
|
||||
|
||||
|
||||
def test_registrations_evolution_is_cumulative_with_gap_days_filled(client, edition, session):
|
||||
for day in (1, 1, 3):
|
||||
_registered_on(session, make_volunteer(session, edition), day)
|
||||
|
||||
by_day = client.get(_url(edition)).json()["registrations"]["by_day"]
|
||||
assert by_day == [
|
||||
{"date": "2026-06-01", "count": 2, "cumulative": 2},
|
||||
{"date": "2026-06-02", "count": 0, "cumulative": 2},
|
||||
{"date": "2026-06-03", "count": 1, "cumulative": 3},
|
||||
]
|
||||
|
||||
|
||||
def test_answers_count_volunteers_per_filled_field(client, edition, session):
|
||||
bar = make_position(session, edition, name="Bar")
|
||||
artist = make_artist(session, edition)
|
||||
full = make_volunteer(session, edition, first_name="Alex", email="alex@ex.fr")
|
||||
full.phone = "0600000000"
|
||||
full.comment = "Dispo le soir"
|
||||
full.companion_wish_note = "Sam"
|
||||
full.self_declared_available = True
|
||||
other = make_volunteer(session, edition, first_name="Sam")
|
||||
session.add_all([
|
||||
full,
|
||||
VolunteerPositionWish(volunteer_id=full.id, position_id=bar.id),
|
||||
VolunteerArtist(volunteer_id=full.id, artist_id=artist.id),
|
||||
VolunteerCompanionWish(volunteer_id=full.id, companion_id=other.id),
|
||||
])
|
||||
session.commit()
|
||||
|
||||
body = client.get(_url(edition)).json()
|
||||
answers = body["answers"]
|
||||
assert answers["total"] == 2
|
||||
for key in (
|
||||
"email", "phone", "comment", "companion_note", "available",
|
||||
"position_wishes", "artist_wishes", "companion_wishes",
|
||||
):
|
||||
assert answers[key] == 1, key
|
||||
assert answers["photo"] == 0
|
||||
assert body["artist_wishes"] == [
|
||||
{"id": str(artist.id), "name": "Chilla", "date": "2026-07-10", "count": 1}
|
||||
]
|
||||
|
||||
|
||||
def test_payments_split_online_on_site_and_unfinished(client, edition, session):
|
||||
donor = make_volunteer(session, edition, first_name="Alex")
|
||||
donor.willing_to_pay_fee = True
|
||||
donor.fee_payment_label = "Mécénat"
|
||||
donor.fee_amount = 50.0
|
||||
donor.fee_payment_status = FeePaymentStatus.PAID
|
||||
donor.mecenat_address = "1 rue du Festival"
|
||||
classic = make_volunteer(session, edition, first_name="Sam")
|
||||
classic.willing_to_pay_fee = True
|
||||
classic.fee_payment_label = "Cotisation classique"
|
||||
classic.fee_amount = 12.5
|
||||
classic.fee_payment_status = FeePaymentStatus.PAID
|
||||
on_site = make_volunteer(session, edition, first_name="Jo")
|
||||
on_site.willing_to_pay_fee = True
|
||||
make_volunteer(session, edition, first_name="Lou")
|
||||
session.add_all([
|
||||
donor,
|
||||
classic,
|
||||
on_site,
|
||||
# Formulaire rempli, paiement jamais abouti : pas de bénévole créé.
|
||||
PendingFormSubmission(
|
||||
edition_id=edition.id, payment_label="Mécénat", payment_amount=30.0,
|
||||
checkout_status=FeePaymentStatus.FAILED,
|
||||
),
|
||||
])
|
||||
session.commit()
|
||||
|
||||
payments = client.get(_url(edition)).json()["payments"]
|
||||
assert payments["online"] == 2
|
||||
assert payments["on_site"] == 1
|
||||
assert payments["none"] == 1
|
||||
assert payments["pending_unfinished"] == 1
|
||||
assert payments["total_collected"] == 62.5
|
||||
assert payments["mecenat_count"] == 1
|
||||
assert payments["mecenat_total"] == 50.0
|
||||
assert payments["by_option"] == [
|
||||
{"label": "Cotisation classique", "mecenat": False, "count": 1, "amount_total": 12.5},
|
||||
{"label": "Mécénat", "mecenat": True, "count": 1, "amount_total": 50.0},
|
||||
]
|
||||
|
||||
|
||||
def test_needs_are_grouped_by_festival_day(client, edition, session):
|
||||
# Début de journée = 06:00 (fixture) : la soirée du 9 déborde après minuit.
|
||||
bar = make_position(session, edition, name="Bar")
|
||||
late = make_shift(
|
||||
session, edition, bar, start=dt_at(9, 22), end=dt_at(10, 0), min_staff=1, ideal_staff=2,
|
||||
)
|
||||
make_shift(
|
||||
session, edition, bar, start=dt_at(10, 0), end=dt_at(10, 1), min_staff=1, ideal_staff=1,
|
||||
)
|
||||
alex = make_volunteer(session, edition, first_name="Alex")
|
||||
sam = make_volunteer(session, edition, first_name="Sam")
|
||||
session.add_all([
|
||||
Assignment(edition_id=edition.id, volunteer_id=alex.id, shift_id=late.id),
|
||||
VolunteerPositionWish(volunteer_id=alex.id, position_id=bar.id),
|
||||
VolunteerPositionWish(volunteer_id=sam.id, position_id=bar.id),
|
||||
])
|
||||
session.commit()
|
||||
|
||||
needs = client.get(_url(edition)).json()["needs"]
|
||||
assert needs["by_day"] == [
|
||||
{
|
||||
"date": "2026-07-09",
|
||||
"shifts": 2,
|
||||
"min_hours": 3.0,
|
||||
"ideal_hours": 5.0,
|
||||
"assigned_hours": 2.0,
|
||||
}
|
||||
]
|
||||
assert needs["by_position"] == [
|
||||
{
|
||||
"position_id": str(bar.id),
|
||||
"name": "Bar",
|
||||
"shifts": 2,
|
||||
"min_slots": 2,
|
||||
"ideal_slots": 3,
|
||||
"assigned": 1,
|
||||
"filled": 1,
|
||||
"wishes": 2,
|
||||
}
|
||||
]
|
||||
assert needs["volunteers_with_assignment"] == 1
|
||||
|
||||
|
||||
def test_filled_slots_never_count_overstaffing(client, edition, session):
|
||||
# 3 affectés sur un créneau qui n'en demande que 2 : 2 places pourvues,
|
||||
# pas 3 — sinon un sureffectif masquerait un trou ailleurs.
|
||||
bar = make_position(session, edition, name="Bar")
|
||||
full = make_shift(session, edition, bar, start=dt_at(9, 18), end=dt_at(9, 20), ideal_staff=2)
|
||||
make_shift(session, edition, bar, start=dt_at(9, 20), end=dt_at(9, 22), ideal_staff=2)
|
||||
for name in ("A", "B", "C"):
|
||||
v = make_volunteer(session, edition, first_name=name)
|
||||
session.add(Assignment(edition_id=edition.id, volunteer_id=v.id, shift_id=full.id))
|
||||
session.commit()
|
||||
|
||||
position = client.get(_url(edition)).json()["needs"]["by_position"][0]
|
||||
assert position["assigned"] == 3
|
||||
assert position["filled"] == 2
|
||||
assert position["ideal_slots"] == 4
|
||||
|
||||
|
||||
def test_attendance_splits_past_and_live_shifts_per_position(
|
||||
client, edition, session, monkeypatch
|
||||
):
|
||||
import app.api.stats as stats_api
|
||||
from app.models import AttendanceStatus
|
||||
|
||||
# Maintenant : le 10 à 20:00 — 14h–18h terminé, 18h–22h en cours,
|
||||
# 22h–02h à venir (ignoré).
|
||||
monkeypatch.setattr(stats_api, "local_now", lambda: dt_at(10, 20))
|
||||
bar = make_position(session, edition, name="Bar")
|
||||
parking = make_position(session, edition, name="Parking")
|
||||
past = make_shift(session, edition, bar, start=dt_at(10, 14), end=dt_at(10, 18))
|
||||
live = make_shift(session, edition, bar, start=dt_at(10, 18), end=dt_at(10, 22))
|
||||
later = make_shift(session, edition, parking, start=dt_at(10, 22), end=dt_at(11, 2))
|
||||
|
||||
def assign(shift, name, attendance=None):
|
||||
volunteer = make_volunteer(session, edition, first_name=name)
|
||||
session.add(
|
||||
Assignment(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
shift_id=shift.id,
|
||||
attendance=attendance,
|
||||
)
|
||||
)
|
||||
|
||||
assign(past, "A", AttendanceStatus.PRESENT)
|
||||
assign(past, "B", AttendanceStatus.ABSENT)
|
||||
assign(past, "C")
|
||||
assign(live, "D", AttendanceStatus.PRESENT)
|
||||
assign(live, "E")
|
||||
assign(later, "F", AttendanceStatus.PRESENT)
|
||||
session.commit()
|
||||
|
||||
att = client.get(_url(edition)).json()["attendance"]
|
||||
assert att["past"] == {"assigned": 3, "present": 1, "absent": 1, "unmarked": 1}
|
||||
assert att["live"] == {"assigned": 2, "present": 1, "absent": 0, "unmarked": 1}
|
||||
assert att["live_shifts"] == 1
|
||||
# Parking n'a encore aucun créneau commencé : absent du détail.
|
||||
(row,) = att["by_position"]
|
||||
assert row["name"] == "Bar"
|
||||
assert row["past"]["assigned"] == 3
|
||||
assert row["live"]["present"] == 1
|
||||
assert row["live_shifts"] == 1
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Email de test depuis l'écran « Messages » (`POST .../notifications/test`) :
|
||||
envoi synchrone, résultat réel du serveur SMTP renvoyé à la coordination."""
|
||||
|
||||
import smtplib
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Role
|
||||
|
||||
|
||||
def _url(edition):
|
||||
return f"/editions/{edition.id}/notifications/test"
|
||||
|
||||
|
||||
class _FakeSmtp:
|
||||
def __init__(self, error=None):
|
||||
self.sent = []
|
||||
self.error = error
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def send_message(self, message):
|
||||
if self.error:
|
||||
raise self.error
|
||||
self.sent.append(message)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def smtp_enabled(monkeypatch):
|
||||
monkeypatch.setenv("EMAIL_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_disabled_email_is_reported(client, edition):
|
||||
resp = client.post(_url(edition), json={"email": "moi@exemple.fr"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "disabled"
|
||||
|
||||
|
||||
def test_sends_the_test_email(client, edition, smtp_enabled, monkeypatch):
|
||||
fake = _FakeSmtp()
|
||||
monkeypatch.setattr("app.email.sender.open_smtp", lambda settings: fake)
|
||||
resp = client.post(_url(edition), json={"email": " moi@exemple.fr "})
|
||||
assert resp.json()["status"] == "sent"
|
||||
(message,) = fake.sent
|
||||
assert message["To"] == "moi@exemple.fr"
|
||||
|
||||
|
||||
def test_connection_failure_is_reported_with_the_server_message(
|
||||
client, edition, smtp_enabled, monkeypatch
|
||||
):
|
||||
def refuse(settings):
|
||||
raise smtplib.SMTPAuthenticationError(535, b"authentication failed")
|
||||
|
||||
monkeypatch.setattr("app.email.sender.open_smtp", refuse)
|
||||
body = client.post(_url(edition), json={"email": "moi@exemple.fr"}).json()
|
||||
assert body["status"] == "connection_failed"
|
||||
assert "535" in body["detail"]
|
||||
|
||||
|
||||
def test_rejected_message_never_echoes_the_address(client, edition, smtp_enabled, monkeypatch):
|
||||
refused = smtplib.SMTPRecipientsRefused({"moi@exemple.fr": (550, b"no such user")})
|
||||
monkeypatch.setattr("app.email.sender.open_smtp", lambda settings: _FakeSmtp(refused))
|
||||
body = client.post(_url(edition), json={"email": "moi@exemple.fr"}).json()
|
||||
assert body["status"] == "rejected"
|
||||
assert "550" in body["detail"]
|
||||
assert "moi@exemple.fr" not in body["detail"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("email", ["", "pas-un-email", "@exemple.fr", "moi@", "a b@c.fr"])
|
||||
def test_invalid_address_is_rejected(client, edition, email):
|
||||
resp = client.post(_url(edition), json={"email": email})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_is_coordinator_only(edition, make_account, as_account):
|
||||
lead = as_account(make_account(Role.LEAD))
|
||||
assert lead.post(_url(edition), json={"email": "moi@exemple.fr"}).status_code == 403
|
||||
|
||||
|
||||
def _send(client, edition, monkeypatch, **payload):
|
||||
captured = []
|
||||
monkeypatch.setattr(
|
||||
"app.api.notifications.deliver_test",
|
||||
lambda message: captured.append(message) or ("sent", None),
|
||||
)
|
||||
body = client.post(_url(edition), json={"email": "moi@exemple.fr", **payload}).json()
|
||||
return body, captured[0]
|
||||
|
||||
|
||||
def test_test_email_is_the_compiled_planning_email(client, edition, session, monkeypatch):
|
||||
from app.models import Assignment
|
||||
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
||||
from tests.mail import plain_text
|
||||
|
||||
make_volunteer(session, edition, first_name="Zoé", last_name="Zébu") # sans créneau
|
||||
alex = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
shift = make_shift(session, edition, make_position(session, edition, name="Bar"),
|
||||
start=dt_at(10, 18), end=dt_at(10, 22))
|
||||
session.add(Assignment(edition_id=edition.id, volunteer_id=alex.id, shift_id=shift.id))
|
||||
session.commit()
|
||||
|
||||
body, message = _send(
|
||||
client, edition, monkeypatch,
|
||||
subject="Planning de {prenom}",
|
||||
body="Salut {prenom} {nom} : {nb_creneaux} créneau(x), {nb_heures} h\n{creneaux}",
|
||||
)
|
||||
# Premier bénévole (par nom) qui a un créneau, pas Zoé.
|
||||
assert body["example_volunteer_name"] == "Alex Martin"
|
||||
assert message["Subject"] == "[Test] Planning de Alex"
|
||||
text = plain_text(message)
|
||||
assert "Salut Alex Martin : 1 créneau(x), 4.0 h" in text
|
||||
assert "Bar" in text
|
||||
assert "{" not in text
|
||||
|
||||
|
||||
def test_login_link_in_test_email_carries_no_token(client, edition, session, monkeypatch):
|
||||
from sqlmodel import select
|
||||
|
||||
from app.models import MagicLinkToken
|
||||
from tests.factories import make_volunteer
|
||||
from tests.mail import plain_text
|
||||
|
||||
make_volunteer(session, edition)
|
||||
_, message = _send(client, edition, monkeypatch, body="Connexion : {lien_connexion}")
|
||||
assert "token=" not in plain_text(message)
|
||||
assert session.exec(select(MagicLinkToken)).all() == []
|
||||
|
||||
|
||||
def test_chosen_volunteer_and_fictitious_example(client, edition, session, monkeypatch):
|
||||
from tests.factories import make_volunteer
|
||||
|
||||
body, message = _send(client, edition, monkeypatch, subject="Pour {prenom}")
|
||||
assert body["example_volunteer_id"] is None
|
||||
assert message["Subject"] == "[Test] Pour Camille"
|
||||
|
||||
sam = make_volunteer(session, edition, first_name="Sam", last_name="Nuit")
|
||||
body, _ = _send(client, edition, monkeypatch, volunteer_id=str(sam.id))
|
||||
assert body["example_volunteer_id"] == str(sam.id)
|
||||
@@ -6,11 +6,10 @@ from openpyxl import load_workbook
|
||||
from pypdf import PdfReader
|
||||
from sqlmodel import select
|
||||
|
||||
from app.models import Assignment, Availability, FeePaymentStatus, Role, Volunteer
|
||||
from app.models import Assignment, FeePaymentStatus, Role, Volunteer
|
||||
from tests.factories import (
|
||||
dt_at,
|
||||
make_artist,
|
||||
make_availability,
|
||||
make_position,
|
||||
make_shift,
|
||||
make_volunteer,
|
||||
@@ -208,14 +207,6 @@ def test_delete_is_a_full_cascade(client, edition, session):
|
||||
shift = make_shift(
|
||||
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
|
||||
)
|
||||
make_availability(
|
||||
session,
|
||||
edition,
|
||||
volunteer,
|
||||
date=dt.date(2026, 7, 10),
|
||||
start_time=dt.time(8, 0),
|
||||
end_time=dt.time(13, 0),
|
||||
)
|
||||
session.add(
|
||||
Assignment(
|
||||
edition_id=edition.id,
|
||||
@@ -230,7 +221,6 @@ def test_delete_is_a_full_cascade(client, edition, session):
|
||||
assert client.get(_url(edition, f"/{volunteer.id}")).status_code == 404
|
||||
assert session.exec(select(Volunteer)).all() == []
|
||||
assert session.exec(select(Assignment)).all() == []
|
||||
assert session.exec(select(Availability)).all() == []
|
||||
# Le créneau subsiste.
|
||||
from app.models import Shift
|
||||
|
||||
@@ -598,3 +588,80 @@ def test_mecenat_receipts_is_coordinator_only(
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
resp = as_account(lead).get(_url(edition, "/mecenat-receipts"))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_export_gives_the_sumup_transaction(client, edition, session):
|
||||
volunteer = make_volunteer(session, edition, first_name="Sam", last_name="B")
|
||||
_mark_mecenat_paid(session, volunteer)
|
||||
volunteer.fee_transaction_code = "TEENSK4W2K"
|
||||
volunteer.fee_transaction_id = "4ba8a7ba-0f61-4c8a-9b5c-2f3e0b4a8a11"
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
|
||||
resp = client.get(_url(edition, "/export"))
|
||||
rows = list(load_workbook(io.BytesIO(resp.content)).active.iter_rows(values_only=True))
|
||||
code = rows[0].index("Transaction SumUp (code)")
|
||||
ident = rows[0].index("Transaction SumUp (identifiant)")
|
||||
assert rows[1][code] == "TEENSK4W2K"
|
||||
assert rows[1][ident] == "4ba8a7ba-0f61-4c8a-9b5c-2f3e0b4a8a11"
|
||||
|
||||
fiche = client.get(_url(edition, f"/{volunteer.id}")).json()
|
||||
assert fiche["fee_transaction_code"] == "TEENSK4W2K"
|
||||
# L'identifiant du checkout, lui, reste interne.
|
||||
assert "fee_checkout_id" not in fiche
|
||||
|
||||
|
||||
def _png() -> bytes:
|
||||
from PIL import Image
|
||||
|
||||
out = io.BytesIO()
|
||||
Image.new("RGB", (4, 4), "red").save(out, format="PNG")
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def test_coordinator_adds_then_removes_a_volunteer_photo(client, edition, session):
|
||||
volunteer = make_volunteer(session, edition)
|
||||
url = _url(edition, f"/{volunteer.id}/photo")
|
||||
assert client.get(url).status_code == 404
|
||||
|
||||
resp = client.put(url, files={"file": ("photo.png", _png(), "image/png")})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["has_photo"] is True
|
||||
photo = client.get(url)
|
||||
assert photo.status_code == 200
|
||||
assert photo.headers["content-type"] == "image/png"
|
||||
|
||||
resp = client.delete(url)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["has_photo"] is False
|
||||
assert client.get(url).status_code == 404
|
||||
|
||||
|
||||
def test_adding_a_fake_photo_is_rejected(client, edition, session):
|
||||
volunteer = make_volunteer(session, edition)
|
||||
resp = client.put(
|
||||
_url(edition, f"/{volunteer.id}/photo"),
|
||||
files={"file": ("photo.png", b"pas une image", "image/png")},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "form.photo_type_not_supported"
|
||||
|
||||
|
||||
def test_only_the_coordination_can_change_a_photo(
|
||||
session, edition, make_account, as_account
|
||||
):
|
||||
volunteer = make_volunteer(session, edition)
|
||||
lead = as_account(make_account(Role.LEAD, email="lead@e.fr"))
|
||||
url = _url(edition, f"/{volunteer.id}/photo")
|
||||
|
||||
assert lead.put(url, files={"file": ("p.png", _png(), "image/png")}).status_code == 403
|
||||
assert lead.delete(url).status_code == 403
|
||||
|
||||
|
||||
def test_manual_add_requires_no_field(client, edition):
|
||||
resp = client.post(_url(edition), json={})
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["first_name"] == ""
|
||||
assert body["last_name"] == ""
|
||||
assert client.get(_url(edition)).status_code == 200
|
||||
|
||||
@@ -207,3 +207,236 @@ def test_generating_produces_a_pdf(client, edition, session):
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "application/pdf"
|
||||
assert resp.content.startswith(b"%PDF")
|
||||
|
||||
|
||||
def test_generating_with_photo_and_free_text_fields(client, edition, session):
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
{
|
||||
"field_type": "PHOTO", "x_mm": 60, "y_mm": 5,
|
||||
"width_mm": 20, "height_mm": 25, "font_size": 8,
|
||||
},
|
||||
{
|
||||
"field_type": "FREE_TEXT", "x_mm": 5, "y_mm": 40,
|
||||
"width_mm": 50, "height_mm": 8, "font_size": 9,
|
||||
"text": "Staff 2026",
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
by_type = {f["field_type"]: f for f in resp.json()}
|
||||
assert set(by_type) == {"PHOTO", "FREE_TEXT"}
|
||||
assert by_type["FREE_TEXT"]["text"] == "Staff 2026"
|
||||
|
||||
with_photo = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
with_photo.photo = _PNG
|
||||
with_photo.photo_content_type = "image/png"
|
||||
without_photo = make_volunteer(session, edition, first_name="Sam", last_name="Nuit")
|
||||
session.add(with_photo)
|
||||
session.commit()
|
||||
resp = client.patch(
|
||||
f"/editions/{edition.id}/volunteers/{without_photo.id}",
|
||||
json={"badge_text": "Accès backstage"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["badge_text"] == "Accès backstage"
|
||||
|
||||
resp = client.post(f"{_badges_url(edition)}/generate", json={})
|
||||
assert resp.status_code == 200
|
||||
assert resp.content.startswith(b"%PDF")
|
||||
|
||||
|
||||
def _field(**kw):
|
||||
return {"x_mm": 0, "y_mm": 0, "width_mm": 10, "height_mm": 10, **kw}
|
||||
|
||||
|
||||
def test_new_field_attributes_default_and_round_trip(client, edition):
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
_field(field_type="FULL_NAME"),
|
||||
_field(
|
||||
field_type="PHOTO", face="BACK", photo_shape="OVAL",
|
||||
bg_color="#FFD23F", auto_fit=True,
|
||||
),
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
by_face = {f["face"]: f for f in resp.json()}
|
||||
front, back = by_face["FRONT"], by_face["BACK"]
|
||||
assert front["field_type"] == "FULL_NAME"
|
||||
assert front["bg_color"] is None
|
||||
assert front["auto_fit"] is False
|
||||
assert front["photo_shape"] == "RECT"
|
||||
assert back["field_type"] == "PHOTO"
|
||||
assert back["photo_shape"] == "OVAL"
|
||||
assert back["bg_color"] == "#FFD23F"
|
||||
assert back["auto_fit"] is True
|
||||
|
||||
again = client.get(f"{_badges_url(edition)}/template").json()["fields"]
|
||||
assert {(f["face"], f["field_type"]) for f in again} == {
|
||||
("FRONT", "FULL_NAME"), ("BACK", "PHOTO"),
|
||||
}
|
||||
|
||||
|
||||
def test_same_field_type_on_front_and_back_is_allowed(client, edition):
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
_field(field_type="FREE_TEXT", face="FRONT", text="Recto"),
|
||||
_field(field_type="FREE_TEXT", face="BACK", text="Verso"),
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert {(f["face"], f["text"]) for f in resp.json()} == {
|
||||
("FRONT", "Recto"), ("BACK", "Verso"),
|
||||
}
|
||||
|
||||
|
||||
def test_same_field_type_twice_on_the_back_is_rejected(client, edition):
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
_field(field_type="ROLE", face="BACK"),
|
||||
_field(field_type="ROLE", face="BACK"),
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "badge.duplicate_field_type"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("color", ["red", "#FFF", "#GGGGGG", "FFD23F", "#FFD23F0"])
|
||||
def test_invalid_background_color_is_rejected(client, edition, color):
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={"fields": [_field(field_type="ROLE", bg_color=color)]},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "badge.invalid_color"
|
||||
|
||||
|
||||
def test_generating_with_back_fields(client, edition, session, monkeypatch):
|
||||
import app.api.badges as badges_api
|
||||
|
||||
captured = {}
|
||||
original = badges_api.generate_badge_sheets_pdf
|
||||
|
||||
def spy(badges, *args, **kwargs):
|
||||
captured["badges"] = badges
|
||||
return original(badges, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
_field(field_type="FULL_NAME", width_mm=60, font_size=12),
|
||||
_field(field_type="ROLE", face="BACK", bg_color="#FFD23F", auto_fit=True),
|
||||
_field(field_type="PHOTO", face="BACK", photo_shape="OVAL"),
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
|
||||
resp = client.post(f"{_badges_url(edition)}/generate", json={})
|
||||
assert resp.status_code == 200
|
||||
assert resp.content.startswith(b"%PDF")
|
||||
|
||||
(badge,) = captured["badges"]
|
||||
assert [f.text for f in badge.fields] == ["Alex\nMartin"]
|
||||
back = {f.is_photo: f for f in badge.back_fields}
|
||||
assert back[False].text == "Bénévole"
|
||||
assert back[False].valign == "MIDDLE"
|
||||
assert back[False].bg_color == "#FFD23F"
|
||||
assert back[False].auto_fit is True
|
||||
assert back[True].photo_shape == "OVAL"
|
||||
assert back[True].text == "" # pas de photo : rien d'imprimé
|
||||
|
||||
|
||||
def test_generating_prints_as_many_badges_as_requested(client, edition, session, monkeypatch):
|
||||
import app.api.badges as badges_api
|
||||
|
||||
captured = {}
|
||||
original = badges_api.generate_badge_sheets_pdf
|
||||
|
||||
def spy(badges, *args, **kwargs):
|
||||
captured["badges"] = badges
|
||||
return original(badges, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
|
||||
client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={"fields": [_field(field_type="FULL_NAME", width_mm=60, font_size=12)]},
|
||||
)
|
||||
many = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
make_volunteer(session, edition, first_name="Sam", last_name="Nuit")
|
||||
resp = client.patch(f"/editions/{edition.id}/volunteers/{many.id}", json={"badge_count": 3})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["badge_count"] == 3
|
||||
|
||||
resp = client.post(f"{_badges_url(edition)}/generate", json={})
|
||||
assert resp.status_code == 200
|
||||
|
||||
names = [badge.fields[0].text for badge in captured["badges"]]
|
||||
assert names == ["Alex\nMartin"] * 3 + ["Sam\nNuit"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [0, -1, 101])
|
||||
def test_badge_count_out_of_range_is_rejected(client, edition, session, count):
|
||||
volunteer = make_volunteer(session, edition)
|
||||
resp = client.patch(
|
||||
f"/editions/{edition.id}/volunteers/{volunteer.id}", json={"badge_count": count}
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_fields_are_drawn_in_stacking_order(client, edition, session, monkeypatch):
|
||||
import app.api.badges as badges_api
|
||||
|
||||
captured = {}
|
||||
original = badges_api.generate_badge_sheets_pdf
|
||||
|
||||
def spy(badges, *args, **kwargs):
|
||||
captured["badges"] = badges
|
||||
return original(badges, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
_field(field_type="FREE_TEXT", text="Staff", z_order=2),
|
||||
_field(field_type="FULL_NAME", z_order=0),
|
||||
_field(field_type="PHOTO", z_order=1),
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert [f["field_type"] for f in resp.json()] == ["FULL_NAME", "PHOTO", "FREE_TEXT"]
|
||||
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
|
||||
assert client.post(f"{_badges_url(edition)}/generate", json={}).status_code == 200
|
||||
(badge,) = captured["badges"]
|
||||
assert [(f.is_photo, f.text) for f in badge.fields] == [
|
||||
(False, "Alex\nMartin"),
|
||||
(True, ""),
|
||||
(False, "Staff"),
|
||||
]
|
||||
|
||||
|
||||
def test_photo_is_drawn_below_text_at_equal_stacking_order(client, edition):
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={"fields": [_field(field_type="FULL_NAME"), _field(field_type="PHOTO")]},
|
||||
)
|
||||
assert [f["field_type"] for f in resp.json()] == ["PHOTO", "FULL_NAME"]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from app.api.badges import _field_text, _position_line
|
||||
from app.api.badges import _field_text, _positions_table
|
||||
from app.models import (
|
||||
BadgeField,
|
||||
BadgeFieldType,
|
||||
@@ -29,65 +29,45 @@ def _shift(start: dt.datetime, end: dt.datetime) -> Shift:
|
||||
)
|
||||
|
||||
|
||||
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"
|
||||
_BOUNDARY = dt.time(2, 0) # journée de festival de 02:00 à 02: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 = (
|
||||
def test_positions_table_groups_shifts_under_their_day():
|
||||
accueil = (
|
||||
_position(name="Accueil"),
|
||||
_shift(dt.datetime(2026, 7, 9, 9, 0), dt.datetime(2026, 7, 9, 13, 0)),
|
||||
None,
|
||||
_shift(dt.datetime(2026, 7, 11, 9, 0), dt.datetime(2026, 7, 11, 13, 0)),
|
||||
)
|
||||
evening = (
|
||||
bar = (
|
||||
_position(name="Bar central"),
|
||||
_shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0)),
|
||||
None,
|
||||
_shift(dt.datetime(2026, 7, 11, 19, 0), dt.datetime(2026, 7, 11, 22, 0)),
|
||||
)
|
||||
demontage = (
|
||||
_position(name="Démontage"),
|
||||
_shift(dt.datetime(2026, 7, 12, 14, 0), dt.datetime(2026, 7, 12, 18, 0)),
|
||||
)
|
||||
|
||||
text = _field_text(
|
||||
field, volunteer,
|
||||
assignments_by_volunteer={volunteer.id: [morning, evening]},
|
||||
lead_volunteer_ids=set(),
|
||||
table = _positions_table([accueil, bar, demontage], _BOUNDARY)
|
||||
|
||||
assert [(s.title, s.rows) for s in table.sections] == [
|
||||
("Samedi 11", (("09:00–13:00", "Accueil"), ("19:00–22:00", "Bar central"))),
|
||||
("Dimanche 12", (("14:00–18:00", "Démontage"),)),
|
||||
]
|
||||
|
||||
|
||||
def test_positions_table_keeps_a_night_shift_on_the_day_it_started():
|
||||
night = (
|
||||
_position(name="Bar central"),
|
||||
_shift(dt.datetime(2026, 7, 12, 0, 0), dt.datetime(2026, 7, 12, 1, 0)),
|
||||
)
|
||||
|
||||
assert text == "Accueil 09:00–13:00\nBar central 19:00–22:00"
|
||||
table = _positions_table([night], _BOUNDARY)
|
||||
|
||||
assert table.sections[0].title == "Samedi 11"
|
||||
assert table.sections[0].rows == (("00:00–01:00", "Bar central"),)
|
||||
|
||||
|
||||
def test_positions_table_is_empty_without_any_shift():
|
||||
assert _positions_table([], _BOUNDARY) is None
|
||||
|
||||
|
||||
def test_field_text_role_is_referent_only_for_lead_accounts():
|
||||
@@ -99,3 +79,47 @@ def test_field_text_role_is_referent_only_for_lead_accounts():
|
||||
|
||||
assert _field_text(field, volunteer, {}, lead_volunteer_ids=set()) == "Bénévole"
|
||||
assert _field_text(field, volunteer, {}, lead_volunteer_ids={volunteer.id}) == "Référent"
|
||||
|
||||
|
||||
def test_field_text_free_text_is_the_volunteer_badge_text():
|
||||
field = BadgeField(
|
||||
edition_id=uuid.uuid4(), field_type=BadgeFieldType.FREE_TEXT,
|
||||
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=8, color="#000000",
|
||||
)
|
||||
with_text = _volunteer(first_name="Alex", last_name="Martin", badge_text="Accès backstage")
|
||||
without = _volunteer(first_name="Sam", last_name="Nuit")
|
||||
|
||||
assert _field_text(field, with_text, {}, set()) == "Accès backstage"
|
||||
assert _field_text(field, without, {}, set()) == ""
|
||||
|
||||
|
||||
def test_field_text_free_text_falls_back_to_the_common_editor_text():
|
||||
field = BadgeField(
|
||||
edition_id=uuid.uuid4(), field_type=BadgeFieldType.FREE_TEXT,
|
||||
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=8, color="#000000",
|
||||
text="Staff 2026",
|
||||
)
|
||||
own = _volunteer(first_name="Alex", last_name="Martin", badge_text="Accès backstage")
|
||||
other = _volunteer(first_name="Sam", last_name="Nuit")
|
||||
|
||||
assert _field_text(field, own, {}, set()) == "Accès backstage"
|
||||
assert _field_text(field, other, {}, set()) == "Staff 2026"
|
||||
|
||||
|
||||
def test_field_text_full_name_is_first_name_then_last_name_on_two_lines():
|
||||
volunteer = _volunteer(first_name="Alex", last_name="Martin")
|
||||
field = BadgeField(
|
||||
edition_id=uuid.uuid4(), field_type=BadgeFieldType.FULL_NAME,
|
||||
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
|
||||
)
|
||||
assert _field_text(field, volunteer, {}, set()) == "Alex\nMartin"
|
||||
|
||||
|
||||
def test_field_text_photo_is_empty():
|
||||
volunteer = _volunteer(first_name="alex", last_name="martin")
|
||||
field = BadgeField(
|
||||
edition_id=uuid.uuid4(), field_type=BadgeFieldType.PHOTO,
|
||||
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
|
||||
)
|
||||
# Une image, pas un texte : rien n'est imprimé sans photo.
|
||||
assert _field_text(field, volunteer, {}, set()) == ""
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
"""Génération des planches de badges (module pur, sans base)."""
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||
|
||||
from app.badges.pdf import (
|
||||
ASSUMED_DPI,
|
||||
DEFAULT_BADGE_HEIGHT_MM,
|
||||
DEFAULT_BADGE_WIDTH_MM,
|
||||
BadgeFieldSpec,
|
||||
BadgeSpec,
|
||||
BadgeTable,
|
||||
BadgeTableSection,
|
||||
_cell_origin,
|
||||
_column_widths,
|
||||
_fit_photo,
|
||||
_grid_size,
|
||||
_spacing,
|
||||
generate_badge_sheets_pdf,
|
||||
image_size_mm,
|
||||
wrap_text,
|
||||
)
|
||||
|
||||
|
||||
@@ -97,3 +111,326 @@ def test_more_badges_than_one_sheet_produces_a_larger_pdf():
|
||||
[_badge() for _ in range(per_page + 1)], front_image=None, back_image=None
|
||||
)
|
||||
assert len(two_pages) > len(one_page)
|
||||
|
||||
|
||||
def _jpeg(width_px: int, height_px: int) -> bytes:
|
||||
out = io.BytesIO()
|
||||
Image.new("RGB", (width_px, height_px), "red").save(out, format="JPEG")
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def _photo_badge(image: bytes | None) -> BadgeSpec:
|
||||
return BadgeSpec(
|
||||
fields=(
|
||||
BadgeFieldSpec(
|
||||
text="", x_mm=60, y_mm=5, width_mm=20, height_mm=25,
|
||||
font_size=8, align="LEFT", bold=False, color="#000000",
|
||||
is_photo=True, image=image,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_fit_photo_crops_to_the_frame_ratio_at_print_resolution():
|
||||
# Photo paysage 4000×3000 dans un cadre portrait 20×25 mm : recadrée
|
||||
# (pas déformée) et réduite à 300 dpi.
|
||||
fitted = _fit_photo(_jpeg(4000, 3000), width_mm=20, height_mm=25)
|
||||
|
||||
with Image.open(io.BytesIO(fitted)) as img:
|
||||
assert img.size == (round(20 / 25.4 * ASSUMED_DPI), round(25 / 25.4 * ASSUMED_DPI))
|
||||
|
||||
|
||||
def test_fit_photo_returns_none_for_an_unreadable_image():
|
||||
assert _fit_photo(b"pas une image", width_mm=20, height_mm=25) is None
|
||||
|
||||
|
||||
def test_photo_field_embeds_the_volunteer_photo():
|
||||
without = generate_badge_sheets_pdf([_photo_badge(None)], front_image=None, back_image=None)
|
||||
with_photo = generate_badge_sheets_pdf(
|
||||
[_photo_badge(_jpeg(400, 500))], front_image=None, back_image=None
|
||||
)
|
||||
assert without.startswith(b"%PDF")
|
||||
assert b"/Subtype /Image" in with_photo
|
||||
assert b"/Subtype /Image" not in without
|
||||
|
||||
|
||||
def test_unreadable_photo_does_not_crash_generation():
|
||||
pdf = generate_badge_sheets_pdf(
|
||||
[_photo_badge(b"pas une image")], front_image=None, back_image=None
|
||||
)
|
||||
assert pdf.startswith(b"%PDF")
|
||||
|
||||
|
||||
def test_wrap_text_keeps_a_short_text_on_one_line():
|
||||
assert wrap_text("Alex Martin", "Helvetica", 12, 60 * mm) == ["Alex Martin"]
|
||||
|
||||
|
||||
def test_wrap_text_breaks_between_words_to_fit_the_width():
|
||||
# Assez large pour « Marie-Christine » seul, trop étroit pour le tout.
|
||||
width = 40 * mm
|
||||
lines = wrap_text("Marie-Christine de La Fontaine", "Helvetica-Bold", 12, width)
|
||||
|
||||
assert len(lines) > 1
|
||||
assert " ".join(lines) == "Marie-Christine de La Fontaine"
|
||||
assert all(stringWidth(line, "Helvetica-Bold", 12) <= width for line in lines)
|
||||
|
||||
|
||||
def test_wrap_text_keeps_explicit_line_breaks_and_wraps_each_line():
|
||||
text = "Bar central 19:00–22:00 (Référent : Sam Nuit)\nAccueil 09:00–13:00"
|
||||
width = 40 * mm
|
||||
lines = wrap_text(text, "Helvetica", 8, width)
|
||||
|
||||
assert lines[0].startswith("Bar central 19:00–22:00")
|
||||
assert "Accueil 09:00–13:00" in lines
|
||||
assert all(stringWidth(line, "Helvetica", 8) <= width for line in lines)
|
||||
|
||||
|
||||
def test_wrap_text_cuts_a_single_word_too_long_for_the_frame():
|
||||
width = 10 * mm
|
||||
lines = wrap_text("Anticonstitutionnellement", "Helvetica", 12, width)
|
||||
|
||||
assert "".join(lines) == "Anticonstitutionnellement"
|
||||
assert all(stringWidth(line, "Helvetica", 12) <= width for line in lines)
|
||||
|
||||
|
||||
def _table_badge(rows: int, height_mm: float = 30) -> BadgeSpec:
|
||||
return BadgeSpec(
|
||||
fields=(
|
||||
BadgeFieldSpec(
|
||||
text="", x_mm=5, y_mm=5, width_mm=60, height_mm=height_mm,
|
||||
font_size=7, align="LEFT", bold=False, color="#000000",
|
||||
table=BadgeTable(
|
||||
sections=(
|
||||
BadgeTableSection(
|
||||
title="Samedi 11",
|
||||
rows=tuple(
|
||||
("19:00–22:00", "Bar central très très long")
|
||||
for _ in range(rows)
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_column_widths_keep_short_columns_and_share_the_rest():
|
||||
widths = _column_widths([20, 30, 200, 100], available=200)
|
||||
|
||||
assert widths[:2] == [20, 30]
|
||||
assert widths[2] == widths[3] == 75
|
||||
assert sum(widths) == 200
|
||||
|
||||
|
||||
def test_column_widths_give_the_spare_room_to_the_last_column():
|
||||
assert _column_widths([20, 30, 40], available=150) == [20, 30, 100]
|
||||
|
||||
|
||||
def test_table_field_generates_a_valid_pdf():
|
||||
pdf = generate_badge_sheets_pdf([_table_badge(3)], front_image=None, back_image=None)
|
||||
assert pdf.startswith(b"%PDF")
|
||||
|
||||
|
||||
def test_table_rows_beyond_the_frame_are_not_drawn():
|
||||
# Cadre trop petit pour 50 lignes : celles qui débordent sont omises,
|
||||
# la planche fait la même taille qu'avec quelques lignes seulement.
|
||||
few = generate_badge_sheets_pdf([_table_badge(4, height_mm=12)], None, None)
|
||||
many = generate_badge_sheets_pdf([_table_badge(50, height_mm=12)], None, None)
|
||||
assert abs(len(many) - len(few)) < 50
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Éditeur v2 : verso, fond de zone, réduction automatique, photo #
|
||||
# --------------------------------------------------------------------------- #
|
||||
from app.badges import pdf as pdf_module # noqa: E402
|
||||
from app.badges.pdf import fit_text # noqa: E402
|
||||
|
||||
|
||||
def _text_field(**kw) -> BadgeFieldSpec:
|
||||
base = dict(
|
||||
text="Alex", x_mm=5, y_mm=5, width_mm=60, height_mm=10,
|
||||
font_size=12, align="LEFT", bold=False, color="#000000",
|
||||
)
|
||||
base.update(kw)
|
||||
return BadgeFieldSpec(**base)
|
||||
|
||||
|
||||
def test_cell_origin_mirrors_the_column_on_the_back_page():
|
||||
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||
w, h = DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM
|
||||
# Retournement bord long : colonne 0 au recto ⇒ dernière colonne au
|
||||
# verso, même rangée.
|
||||
assert _cell_origin(0, cols, w, h, mirrored=True) == _cell_origin(cols - 1, cols, w, h)
|
||||
assert _cell_origin(cols, cols, w, h, mirrored=True) == _cell_origin(
|
||||
2 * cols - 1, cols, w, h
|
||||
)
|
||||
|
||||
|
||||
def test_back_page_draws_each_volunteer_back_fields_at_the_mirrored_slot(monkeypatch):
|
||||
calls = []
|
||||
original = pdf_module._draw_fields
|
||||
|
||||
def spy(c, fields, x0, y0, badge_height_mm, photos):
|
||||
calls.append((fields, x0, y0))
|
||||
original(c, fields, x0, y0, badge_height_mm, photos)
|
||||
|
||||
monkeypatch.setattr(pdf_module, "_draw_fields", spy)
|
||||
back = (_text_field(text="Verso Alex"),)
|
||||
# Un seul badge : planche incomplète, le verso doit quand même être en
|
||||
# miroir (colonne de droite).
|
||||
generate_badge_sheets_pdf(
|
||||
[BadgeSpec(fields=(_text_field(),), back_fields=back)], None, None
|
||||
)
|
||||
|
||||
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||
w, h = DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM
|
||||
(front_fields, fx, fy), (back_fields, bx, by) = calls
|
||||
assert (fx, fy) == _cell_origin(0, cols, w, h)
|
||||
assert back_fields == back
|
||||
assert (bx, by) == _cell_origin(cols - 1, cols, w, h)
|
||||
|
||||
|
||||
def test_fit_text_without_auto_fit_drops_the_lines_that_overflow():
|
||||
text = "\n".join(["ligne"] * 10)
|
||||
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=False)
|
||||
assert size == 12
|
||||
assert 0 < len(lines) < 10
|
||||
|
||||
|
||||
def test_fit_text_with_auto_fit_shrinks_until_every_line_fits():
|
||||
text = "\n".join(["ligne"] * 4)
|
||||
assert len(fit_text(text, "Helvetica", 12, 60, 10, auto_fit=False)[1]) < 4
|
||||
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=True)
|
||||
assert 4 < size < 12
|
||||
assert (size * 2) == int(size * 2) # pas de 0,5 pt
|
||||
assert len(lines) == 4
|
||||
|
||||
|
||||
def test_fit_text_auto_fit_stops_at_the_minimum_size():
|
||||
text = "\n".join(["ligne"] * 200)
|
||||
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=True)
|
||||
assert size == 4.0
|
||||
assert len(lines) < 200
|
||||
|
||||
|
||||
def test_fit_text_auto_fit_keeps_the_size_when_the_text_already_fits():
|
||||
assert fit_text("Alex", "Helvetica", 12, 60, 10, auto_fit=True) == (12, ["Alex"])
|
||||
|
||||
|
||||
def _table_lines_drawn(monkeypatch, auto_fit: bool) -> int:
|
||||
drawn = []
|
||||
monkeypatch.setattr(
|
||||
pdf_module.canvas.Canvas, "drawString",
|
||||
lambda self, x, y, text, *a, **k: drawn.append(text),
|
||||
)
|
||||
badge = _table_badge(4, height_mm=20)
|
||||
field = badge.fields[0]
|
||||
badge = BadgeSpec(fields=(BadgeFieldSpec(**{**field.__dict__, "auto_fit": auto_fit}),))
|
||||
generate_badge_sheets_pdf([badge], None, None)
|
||||
return sum(1 for text in drawn if text == "19:00–22:00")
|
||||
|
||||
|
||||
def test_table_auto_fit_draws_every_row(monkeypatch):
|
||||
# En-tête + 4 lignes : la dernière ne tient pas à 7 pt dans 20 mm.
|
||||
assert _table_lines_drawn(monkeypatch, auto_fit=False) < 4
|
||||
assert _table_lines_drawn(monkeypatch, auto_fit=True) == 4
|
||||
|
||||
|
||||
def _filled_rects(monkeypatch) -> list:
|
||||
fills = []
|
||||
original = pdf_module.canvas.Canvas.rect
|
||||
|
||||
def spy(self, x, y, w, h, stroke=1, fill=0):
|
||||
if fill:
|
||||
fills.append((round(w / mm, 3), round(h / mm, 3)))
|
||||
return original(self, x, y, w, h, stroke=stroke, fill=fill)
|
||||
|
||||
monkeypatch.setattr(pdf_module.canvas.Canvas, "rect", spy)
|
||||
return fills
|
||||
|
||||
|
||||
def test_zone_background_fills_the_field_frame(monkeypatch):
|
||||
fills = _filled_rects(monkeypatch)
|
||||
generate_badge_sheets_pdf(
|
||||
[BadgeSpec(fields=(_text_field(bg_color="#FFD23F"),))], None, None
|
||||
)
|
||||
assert fills == [(60, 10)]
|
||||
|
||||
|
||||
def test_no_zone_background_by_default(monkeypatch):
|
||||
fills = _filled_rects(monkeypatch)
|
||||
generate_badge_sheets_pdf([BadgeSpec(fields=(_text_field(),))], None, None)
|
||||
assert fills == []
|
||||
|
||||
|
||||
def test_middle_aligned_text_is_lower_than_top_aligned_text(monkeypatch):
|
||||
baselines = []
|
||||
monkeypatch.setattr(
|
||||
pdf_module.canvas.Canvas, "drawString",
|
||||
lambda self, x, y, text, *a, **k: baselines.append(y),
|
||||
)
|
||||
for valign in ("TOP", "MIDDLE"):
|
||||
generate_badge_sheets_pdf(
|
||||
[BadgeSpec(fields=(_text_field(height_mm=30, valign=valign),))], None, None
|
||||
)
|
||||
top, middle = baselines
|
||||
assert middle < top
|
||||
|
||||
|
||||
def test_missing_photo_prints_nothing(monkeypatch):
|
||||
calls = []
|
||||
for name in ("drawImage", "drawPath", "drawCentredString", "drawString"):
|
||||
monkeypatch.setattr(
|
||||
pdf_module.canvas.Canvas, name,
|
||||
lambda self, *a, _name=name, **k: calls.append(_name),
|
||||
)
|
||||
for image in (None, b"pas une image"):
|
||||
generate_badge_sheets_pdf([_photo_badge(image)], None, None)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_oval_photo_and_missing_oval_photo_generate_a_valid_pdf():
|
||||
for image in (_jpeg(400, 500), None):
|
||||
field = _photo_badge(image).fields[0]
|
||||
badge = BadgeSpec(
|
||||
fields=(BadgeFieldSpec(**{**field.__dict__, "photo_shape": "OVAL"}),)
|
||||
)
|
||||
pdf = generate_badge_sheets_pdf([badge], None, None)
|
||||
assert pdf.startswith(b"%PDF")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"size", [(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM), (54, 85.6), (50, 50), (74, 105)]
|
||||
)
|
||||
def test_grid_is_centred_with_equal_gaps_and_back_falls_behind_front(size):
|
||||
from app.badges.pdf import _PAGE_HEIGHT_MM, _PAGE_WIDTH_MM
|
||||
|
||||
w, h = size
|
||||
cols, rows = _grid_size(w, h)
|
||||
xs = sorted({_cell_origin(i, cols, w, h)[0] for i in range(cols * rows)})
|
||||
ys = sorted({_cell_origin(i, cols, w, h)[1] for i in range(cols * rows)})
|
||||
left, right = xs[0], _PAGE_WIDTH_MM - (xs[-1] + w)
|
||||
bottom, top = ys[0], _PAGE_HEIGHT_MM - (ys[-1] + h)
|
||||
# Symétrique : même marge à gauche et à droite, en haut et en bas.
|
||||
assert left == pytest.approx(right)
|
||||
assert top == pytest.approx(bottom)
|
||||
# Écarts entre badges tous égaux, et égaux aux marges quand c'est possible.
|
||||
gaps_x = {round(b - a - w, 6) for a, b in zip(xs, xs[1:], strict=False)}
|
||||
gaps_y = {round(b - a - h, 6) for a, b in zip(ys, ys[1:], strict=False)}
|
||||
assert len(gaps_x) <= 1 and len(gaps_y) <= 1
|
||||
_, gap_x = _spacing(cols, _PAGE_WIDTH_MM, w)
|
||||
if gap_x >= 10 and cols > 1:
|
||||
assert left == pytest.approx(gap_x)
|
||||
# Verso : exactement derrière le recto une fois la feuille retournée
|
||||
# sur son bord long (miroir horizontal).
|
||||
for i in range(cols * rows):
|
||||
fx, fy = _cell_origin(i, cols, w, h)
|
||||
bx, by = _cell_origin(i, cols, w, h, mirrored=True)
|
||||
assert bx == pytest.approx(_PAGE_WIDTH_MM - fx - w)
|
||||
assert by == pytest.approx(fy)
|
||||
|
||||
|
||||
def test_cr80_gaps_are_even():
|
||||
# 2 × 85,6 mm sur 210 mm : 12,9 mm à gauche, entre les deux, à droite.
|
||||
assert _spacing(2, 210, 85.6) == pytest.approx((12.9333, 12.9333), abs=1e-3)
|
||||
|
||||
@@ -716,6 +716,56 @@ def test_paying_promotes_the_pending_submission_to_a_volunteer(
|
||||
assert fiche["willing_to_pay_fee"] is True
|
||||
|
||||
|
||||
def test_checkout_description_names_the_volunteer(raw_client, client, edition, monkeypatch):
|
||||
# Pas de champ de métadonnées libre à la création d'un checkout SumUp :
|
||||
# c'est la description (visible dans le tableau de bord SumUp) qui porte
|
||||
# le nom, pour rapprocher la transaction du bénévole.
|
||||
option_id = _configure_sumup(client, edition)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"app.api.forms.create_checkout",
|
||||
lambda **kw: calls.append(kw)
|
||||
or SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
|
||||
)
|
||||
_submit(
|
||||
raw_client, edition,
|
||||
first_name="alex", last_name="martin", hcaptcha_token="x",
|
||||
payment_option_id=option_id,
|
||||
)
|
||||
assert calls[0]["description"] == (
|
||||
f"Alex Martin — Cotisation classique — édition {edition.year}"
|
||||
)
|
||||
|
||||
|
||||
def test_promotion_records_the_sumup_transaction(raw_client, client, edition, monkeypatch):
|
||||
option_id = _configure_sumup(client, edition)
|
||||
monkeypatch.setattr(
|
||||
"app.api.forms.create_checkout",
|
||||
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
|
||||
)
|
||||
token = _submit(
|
||||
raw_client, edition,
|
||||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||
payment_option_id=option_id,
|
||||
).json()["pending_token"]
|
||||
monkeypatch.setattr(
|
||||
"app.api.forms.get_checkout",
|
||||
lambda **kw: SumUpCheckout(
|
||||
id="co_1",
|
||||
status="PAID",
|
||||
hosted_checkout_url=None,
|
||||
transaction_code="TEENSK4W2K",
|
||||
transaction_id="tx-uuid-1",
|
||||
),
|
||||
)
|
||||
|
||||
volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"]
|
||||
|
||||
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||
assert fiche["fee_transaction_code"] == "TEENSK4W2K"
|
||||
assert fiche["fee_transaction_id"] == "tx-uuid-1"
|
||||
|
||||
|
||||
def test_payment_confirmation_sends_an_email_on_promotion(
|
||||
raw_client, client, edition, monkeypatch, outbox
|
||||
):
|
||||
|
||||
@@ -5,7 +5,6 @@ from sqlalchemy import text
|
||||
from app.models import (
|
||||
Assignment,
|
||||
AssignmentSource,
|
||||
Availability,
|
||||
Edition,
|
||||
Position,
|
||||
PositionCategory,
|
||||
@@ -25,7 +24,6 @@ EXPECTED_TABLES = {
|
||||
"magic_link_token",
|
||||
"position",
|
||||
"shift",
|
||||
"availability",
|
||||
"assignment",
|
||||
"badge_template",
|
||||
"badge_field",
|
||||
@@ -101,31 +99,6 @@ def test_create_core_graph(session):
|
||||
assert volunteer.assignments[0].id == assignment.id
|
||||
|
||||
|
||||
def test_availability_window_round_trip(session):
|
||||
edition = _edition(session)
|
||||
volunteer = Volunteer(
|
||||
edition_id=edition.id, first_name="Sam", last_name="Nuit"
|
||||
)
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
session.refresh(volunteer)
|
||||
|
||||
avail = Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=dt.date(2026, 7, 9),
|
||||
start_time=dt.time(19, 0),
|
||||
end_time=dt.time(2, 0), # passe minuit
|
||||
)
|
||||
session.add(avail)
|
||||
session.commit()
|
||||
session.expire_all()
|
||||
|
||||
reloaded = session.get(Availability, avail.id)
|
||||
assert reloaded.start_time == dt.time(19, 0)
|
||||
assert reloaded.end_time == dt.time(2, 0)
|
||||
|
||||
|
||||
def test_volunteer_email_can_be_shared(session):
|
||||
# Un couple, une famille : même boîte mail, deux bénévoles distincts —
|
||||
# ce n'est plus une contrainte d'unicité en base (voir migration 0009).
|
||||
|
||||
@@ -174,3 +174,25 @@ def test_a_night_that_passes_midnight_stays_on_its_festival_day(client, edition,
|
||||
text = reader.pages[0].extract_text()
|
||||
assert "jeudi 9 juillet" in text.lower()
|
||||
assert "01:00" in text
|
||||
|
||||
|
||||
def test_organisation_message_is_passed_as_the_page_footer(client, edition, session, monkeypatch):
|
||||
import app.api.position_sheets as sheets_api
|
||||
|
||||
captured = {}
|
||||
original = sheets_api.generate_position_sheets_pdf
|
||||
|
||||
def spy(sheets, footer=None):
|
||||
captured["footer"] = footer
|
||||
return original(sheets, footer)
|
||||
|
||||
monkeypatch.setattr(sheets_api, "generate_position_sheets_pdf", spy)
|
||||
position = make_position(session, edition)
|
||||
make_shift(session, edition, position, start=dt_at(9, 19), end=dt_at(9, 22))
|
||||
|
||||
assert client.post(_url(edition), json={}).status_code == 200
|
||||
assert captured["footer"] is None
|
||||
|
||||
client.patch(f"/editions/{edition.id}", json={"volunteer_message": "Repas à 18h30"})
|
||||
assert client.post(_url(edition), json={}).status_code == 200
|
||||
assert captured["footer"].message == "Repas à 18h30"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import io
|
||||
|
||||
from app.sheets.pdf import (
|
||||
PositionDaySheet,
|
||||
@@ -142,3 +143,86 @@ def test_generates_a_valid_pdf_without_a_lineup():
|
||||
assert sheet.artists == ()
|
||||
pdf = generate_position_sheets_pdf([sheet])
|
||||
assert pdf.startswith(b"%PDF")
|
||||
|
||||
|
||||
def _phone_photo(seed: int) -> bytes:
|
||||
"""Photo de téléphone : 3000 × 4000 px, plusieurs Mo."""
|
||||
from PIL import Image as PILImage
|
||||
|
||||
img = PILImage.new("RGB", (3000, 4000), (seed * 40 % 255, 120, 60))
|
||||
img.paste(PILImage.effect_noise((800, 800), 80).convert("RGB"), (seed * 100, 200))
|
||||
out = io.BytesIO()
|
||||
img.save(out, format="JPEG", quality=92)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def test_large_photos_are_shrunk_to_the_thumbnail():
|
||||
# Régression : la photo d'origine était intégrée telle quelle pour
|
||||
# chaque bénévole de chaque créneau — génération interminable, PDF
|
||||
# énorme, puis erreur.
|
||||
photos = [_phone_photo(i) for i in range(5)]
|
||||
volunteers = tuple(
|
||||
SheetVolunteer(name=f"Bénévole {i}", phone=None, photo=p) for i, p in enumerate(photos)
|
||||
)
|
||||
pdf = generate_position_sheets_pdf([_sheet(volunteers=volunteers)])
|
||||
assert sum(len(p) for p in photos) > 2_000_000
|
||||
assert len(pdf) < 200_000
|
||||
|
||||
|
||||
def test_unreadable_photo_does_not_break_the_sheet():
|
||||
pdf = generate_position_sheets_pdf(
|
||||
[_sheet(volunteers=(SheetVolunteer(name="Alex", phone=None, photo=b"pas une image"),))]
|
||||
)
|
||||
assert pdf.startswith(b"%PDF")
|
||||
|
||||
|
||||
def _page_texts(pdf: bytes) -> list[str]:
|
||||
from pypdf import PdfReader
|
||||
|
||||
return [page.extract_text() for page in PdfReader(io.BytesIO(pdf)).pages]
|
||||
|
||||
|
||||
def test_footer_message_is_printed_at_the_bottom_of_every_page():
|
||||
from app.sheets.pdf import SheetFooter
|
||||
|
||||
# Deux feuilles, dont une assez longue pour déborder sur une 2e page.
|
||||
many = tuple(SheetVolunteer(name=f"Bénévole {i}", phone=None, photo=None) for i in range(120))
|
||||
pdf = generate_position_sheets_pdf(
|
||||
[_sheet(volunteers=many), _sheet()],
|
||||
SheetFooter(title="Message de l'organisation", message="Repas à 18h30\nBadges à l'accueil"),
|
||||
)
|
||||
pages = _page_texts(pdf)
|
||||
assert len(pages) >= 3
|
||||
for text in pages:
|
||||
assert "Message de l'organisation" in text
|
||||
assert "Repas à 18h30" in text
|
||||
assert "Badges à l'accueil" in text
|
||||
|
||||
|
||||
def test_no_footer_without_message():
|
||||
pages = _page_texts(generate_position_sheets_pdf([_sheet()]))
|
||||
assert "Message de l'organisation" not in pages[0]
|
||||
|
||||
|
||||
def test_volunteers_are_listed_two_per_row():
|
||||
from app.sheets.pdf import _shift_table, _styles
|
||||
|
||||
shift = _sheet(
|
||||
volunteers=tuple(SheetVolunteer(name=f"B{i}", phone=None, photo=None) for i in range(5))
|
||||
).shifts[0]
|
||||
table = _shift_table(shift, {}, _styles(), 180)
|
||||
# 5 bénévoles, deux par ligne : 3 lignes (la dernière à moitié vide).
|
||||
assert len(table._cellvalues) == 3
|
||||
|
||||
|
||||
def test_a_typical_sheet_fits_on_one_page():
|
||||
volunteers = tuple(
|
||||
SheetVolunteer(name=f"Prénom{i} Nom{i}", phone="06 12 34 56 78", photo=_PNG)
|
||||
for i in range(8)
|
||||
)
|
||||
shift = _sheet(volunteers=volunteers).shifts[0]
|
||||
sheet = _sheet(volunteers=volunteers)
|
||||
many_shifts = PositionDaySheet(
|
||||
**{**sheet.__dict__, "shifts": (shift, shift, shift, shift)}
|
||||
)
|
||||
assert len(_page_texts(generate_position_sheets_pdf([many_shifts]))) == 1
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Journal des requêtes (`app/request_logging.py`) : référence de requête,
|
||||
route modèle sans jeton ni chaîne de requête, erreurs détaillées sans
|
||||
valeur saisie."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_each_response_carries_a_request_id(client):
|
||||
resp = client.get("/health")
|
||||
assert len(resp.headers["X-Request-ID"]) >= 8
|
||||
|
||||
|
||||
def test_a_well_formed_incoming_request_id_is_kept(client):
|
||||
resp = client.get("/health", headers={"X-Request-ID": "caddy-1234abcd"})
|
||||
assert resp.headers["X-Request-ID"] == "caddy-1234abcd"
|
||||
|
||||
|
||||
def test_a_malformed_incoming_request_id_is_replaced(client):
|
||||
resp = client.get("/health", headers={"X-Request-ID": "x\nforged log line"})
|
||||
assert resp.headers["X-Request-ID"] != "x\nforged log line"
|
||||
|
||||
|
||||
def test_access_line_uses_the_route_template_and_hides_tokens(raw_client, edition, caplog):
|
||||
with caplog.at_level(logging.INFO, logger="app.request"):
|
||||
raw_client.get(f"/editions/{edition.id}/form/public?token=secret-access-token")
|
||||
|
||||
line = next(r.getMessage() for r in caplog.records if r.name == "app.request")
|
||||
assert "/editions/{edition_id}/form/public" in line
|
||||
assert f"edition_id={edition.id}" in line
|
||||
assert "secret-access-token" not in caplog.text
|
||||
|
||||
|
||||
def test_business_error_code_is_logged(client, edition, caplog):
|
||||
with caplog.at_level(logging.INFO, logger="app.request"):
|
||||
resp = client.get(f"/editions/{edition.id}/volunteers/00000000-0000-0000-0000-000000000000")
|
||||
|
||||
assert resp.status_code == 404
|
||||
record = next(r for r in caplog.records if r.name == "app.request")
|
||||
assert record.levelno == logging.WARNING
|
||||
assert "code=error.not_found" in record.getMessage()
|
||||
|
||||
|
||||
def test_validation_error_logs_the_field_but_not_the_value(client, edition, caplog):
|
||||
with caplog.at_level(logging.INFO, logger="app.request"):
|
||||
resp = client.post(
|
||||
f"/editions/{edition.id}/volunteers",
|
||||
json={"first_name": "Alexandra-Secret", "last_name": 12345678},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
assert "last_name" in caplog.text
|
||||
assert "Alexandra-Secret" not in caplog.text
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def boom_route():
|
||||
def boom():
|
||||
raise RuntimeError("panne")
|
||||
|
||||
app.add_api_route("/__boom", boom)
|
||||
yield
|
||||
app.router.routes = [r for r in app.router.routes if getattr(r, "path", None) != "/__boom"]
|
||||
|
||||
|
||||
def test_unhandled_exception_returns_a_reference(boom_route, caplog):
|
||||
with caplog.at_level(logging.ERROR, logger="app.request"):
|
||||
resp = TestClient(app).get("/__boom")
|
||||
|
||||
assert resp.status_code == 500
|
||||
body = resp.json()
|
||||
assert body["detail"]["code"] == "error.internal"
|
||||
assert body["detail"]["request_id"] == resp.headers["X-Request-ID"]
|
||||
assert "RuntimeError" in caplog.text
|
||||
@@ -13,9 +13,6 @@ def test_build_creates_a_realistic_dataset(session):
|
||||
assert summary.assignments > 100
|
||||
assert summary.understaffed_shifts >= 3 # « quelques trous d'effectif »
|
||||
assert summary.blocking == 0 # aucune violation bloquante
|
||||
# Au moins deux règles d'avertissement représentées, quelques alertes.
|
||||
assert len(summary.warnings) >= 2
|
||||
assert sum(summary.warnings.values()) >= 3
|
||||
# Programmation et vœux des bénévoles : le mode démo doit les illustrer.
|
||||
assert summary.artists > 0
|
||||
assert summary.position_wishes > 0
|
||||
@@ -58,7 +55,6 @@ def test_build_is_deterministic_and_idempotent(session):
|
||||
)
|
||||
assert second.assignments == first.assignments
|
||||
assert second.understaffed_shifts == first.understaffed_shifts
|
||||
assert dict(second.warnings) == dict(first.warnings)
|
||||
assert second.artists == first.artists
|
||||
assert second.position_wishes == first.position_wishes
|
||||
assert second.artist_wishes == first.artist_wishes
|
||||
|
||||
@@ -4,7 +4,7 @@ import datetime as dt
|
||||
|
||||
from app.solver.engine import solve
|
||||
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
|
||||
from app.validation.models import AvailabilityWindow, ShiftInfo
|
||||
from app.validation.models import ShiftInfo
|
||||
|
||||
|
||||
def _dt(day: int, hour: int, minute: int = 0) -> dt.datetime:
|
||||
@@ -39,7 +39,6 @@ def _shift(
|
||||
def _volunteer(
|
||||
vid: str,
|
||||
*,
|
||||
availability=(),
|
||||
wished_position_ids=frozenset(),
|
||||
wished_companion_ids=frozenset(),
|
||||
wished_artist_windows=(),
|
||||
@@ -48,7 +47,6 @@ def _volunteer(
|
||||
) -> SolverVolunteer:
|
||||
return SolverVolunteer(
|
||||
id=vid,
|
||||
availability=tuple(availability),
|
||||
wished_position_ids=frozenset(wished_position_ids),
|
||||
wished_companion_ids=frozenset(wished_companion_ids),
|
||||
wished_artist_windows=tuple(wished_artist_windows),
|
||||
@@ -57,10 +55,6 @@ def _volunteer(
|
||||
)
|
||||
|
||||
|
||||
def _full_day(day: int) -> AvailabilityWindow:
|
||||
return AvailabilityWindow(dt.date(2026, 7, day), dt.time(6, 0), dt.time(4, 0))
|
||||
|
||||
|
||||
def _by_shift(solution, shift_id: str) -> tuple[str, ...]:
|
||||
for s in solution.suggestions:
|
||||
if s.shift_id == shift_id:
|
||||
@@ -75,114 +69,120 @@ def test_no_variables_returns_empty_solution():
|
||||
|
||||
def test_fully_staffed_shift_gets_no_suggestion():
|
||||
shift = _shift(ideal_staff=1, already_assigned=1)
|
||||
volunteer = _volunteer("v1", availability=[_full_day(10)])
|
||||
volunteer = _volunteer("v1")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,)))
|
||||
assert solution.suggestions == ()
|
||||
|
||||
|
||||
def test_busy_volunteer_is_still_suggested_for_an_urgent_gap_as_last_resort():
|
||||
# Déjà affecté ailleurs au même moment : malus, mais seul candidat pour un
|
||||
# créneau sous son minimum ⇒ proposé quand même.
|
||||
shift = _shift(min_staff=1, ideal_staff=1)
|
||||
busy = _volunteer(
|
||||
"busy",
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 15), ends_at=_dt(10, 19))],
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(busy,)))
|
||||
assert _by_shift(solution, "s1") == ("busy",)
|
||||
|
||||
|
||||
def test_volunteer_already_on_the_shift_is_never_suggested_again():
|
||||
shift = _shift(min_staff=2, ideal_staff=2, already_assigned=1)
|
||||
on_it = _volunteer(
|
||||
"on_it",
|
||||
existing_shifts=[ShiftInfo(id="s1", starts_at=_dt(10, 14), ends_at=_dt(10, 18))],
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(on_it,)))
|
||||
assert _by_shift(solution, "s1") == ()
|
||||
|
||||
|
||||
def test_busy_volunteer_is_not_suggested_above_the_minimum():
|
||||
# Minimum déjà atteint : le malus l'emporte, personne n'est proposé.
|
||||
shift = _shift(min_staff=1, ideal_staff=2, already_assigned=1)
|
||||
busy = _volunteer(
|
||||
"busy",
|
||||
wished_position_ids={"p1"},
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 15), ends_at=_dt(10, 19))],
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(busy,)))
|
||||
assert _by_shift(solution, "s1") == ()
|
||||
|
||||
|
||||
def test_picks_the_only_free_volunteer_over_a_busy_one():
|
||||
shift = _shift()
|
||||
busy = _volunteer(
|
||||
"busy",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 15), ends_at=_dt(10, 19))],
|
||||
)
|
||||
free = _volunteer("free", availability=[_full_day(10)])
|
||||
free = _volunteer("free")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(busy, free)))
|
||||
assert _by_shift(solution, "s1") == ("free",)
|
||||
|
||||
|
||||
def test_does_not_exceed_ideal_staff_cap():
|
||||
shift = _shift(ideal_staff=2, already_assigned=1) # il ne manque qu'une place
|
||||
a = _volunteer("a", availability=[_full_day(10)])
|
||||
b = _volunteer("b", availability=[_full_day(10)])
|
||||
a = _volunteer("a")
|
||||
b = _volunteer("b")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(a, b)))
|
||||
assert len(_by_shift(solution, "s1")) == 1
|
||||
|
||||
|
||||
def test_prefers_volunteer_with_matching_position_wish():
|
||||
shift = _shift(position_id="regie")
|
||||
plain = _volunteer("plain", availability=[_full_day(10)])
|
||||
plain = _volunteer("plain")
|
||||
wisher = _volunteer(
|
||||
"wisher", availability=[_full_day(10)], wished_position_ids={"regie"}
|
||||
"wisher", wished_position_ids={"regie"}
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(plain, wisher)))
|
||||
assert _by_shift(solution, "s1") == ("wisher",)
|
||||
|
||||
|
||||
def test_prefers_fully_available_volunteer_over_partially_available():
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
full = _volunteer("full", availability=[_full_day(10)])
|
||||
partial = _volunteer(
|
||||
"partial",
|
||||
availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))],
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial)))
|
||||
assert _by_shift(solution, "s1") == ("full",)
|
||||
|
||||
|
||||
def test_ranks_both_candidates_when_two_slots_are_open():
|
||||
shift = _shift(ideal_staff=2)
|
||||
full = _volunteer("full", availability=[_full_day(10)])
|
||||
partial = _volunteer(
|
||||
"partial",
|
||||
availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))],
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial)))
|
||||
assert _by_shift(solution, "s1") == ("full", "partial")
|
||||
|
||||
|
||||
def test_never_double_books_a_volunteer_across_two_new_suggestions():
|
||||
# Deux créneaux qui se chevauchent, un seul bénévole disponible pour les
|
||||
# deux : le solveur ne le propose qu'une fois.
|
||||
shift_a = _shift("a", start=_dt(10, 14), end=_dt(10, 18))
|
||||
shift_b = _shift("b", start=_dt(10, 16), end=_dt(10, 20))
|
||||
only = _volunteer("only", availability=[_full_day(10)])
|
||||
only = _volunteer("only")
|
||||
solution = solve(SolverInput(shifts=(shift_a, shift_b), volunteers=(only,)))
|
||||
picked_a = _by_shift(solution, "a")
|
||||
picked_b = _by_shift(solution, "b")
|
||||
assert len(picked_a) + len(picked_b) == 1
|
||||
|
||||
|
||||
def test_avoids_volunteer_with_insufficient_rest_since_last_shift():
|
||||
# < 8 h entre la fin d'un créneau déjà décidé et le début de celui-ci.
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
def test_short_rest_is_not_penalized():
|
||||
# Plus de repos minimal (8 h) : 4 h après un créneau déjà décidé, le
|
||||
# bénévole reste à égalité avec un autre.
|
||||
shift = _shift(ideal_staff=2, start=_dt(10, 14), end=_dt(10, 18))
|
||||
tired = _volunteer(
|
||||
"tired",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 8), ends_at=_dt(10, 10))], # repos 4 h
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 8), ends_at=_dt(10, 10))],
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
fresh = _volunteer("fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(tired, fresh)))
|
||||
assert _by_shift(solution, "s1") == ("fresh",)
|
||||
assert set(_by_shift(solution, "s1")) == {"tired", "fresh"}
|
||||
|
||||
|
||||
def test_daily_cumulative_hours_no_longer_penalized():
|
||||
# Le solveur ne plafonne plus le cumul de 8 h/jour (retiré) : seule la
|
||||
# validation, à la confirmation, le signale désormais
|
||||
# (`DAILY_HOURS_EXCEEDED`). Repos respecté (8 h pile) ; cumul du jour
|
||||
# (7 h + 2 h = 9 h) aurait auparavant été pénalisé.
|
||||
# Aucun plafond de cumul journalier : 7 h + 2 h = 9 h sur la journée ne
|
||||
# pénalise pas le bénévole.
|
||||
shift = _shift(ideal_staff=2, start=_dt(10, 21), end=_dt(10, 23))
|
||||
long_day = _volunteer(
|
||||
"long_day",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 6), ends_at=_dt(10, 13))],
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
fresh = _volunteer("fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(long_day, fresh)))
|
||||
assert set(_by_shift(solution, "s1")) == {"long_day", "fresh"}
|
||||
|
||||
|
||||
def test_prefers_volunteer_without_the_immediately_preceding_shift():
|
||||
# Repos suffisant (8 h pile) entre les deux créneaux : seul le chaînage
|
||||
# doit différencier les deux bénévoles.
|
||||
# Seul le chaînage doit différencier les deux bénévoles.
|
||||
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"})
|
||||
chained = _volunteer(
|
||||
"chained",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
fresh = _volunteer("fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained, fresh)))
|
||||
assert _by_shift(solution, "s1") == ("fresh",)
|
||||
|
||||
@@ -196,10 +196,9 @@ def test_chain_penalty_applies_across_different_positions():
|
||||
)
|
||||
chained = _volunteer(
|
||||
"chained",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
fresh = _volunteer("fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained, fresh)))
|
||||
assert _by_shift(solution, "s1") == ("fresh",)
|
||||
|
||||
@@ -210,7 +209,6 @@ def test_still_suggests_the_only_candidate_despite_chaining():
|
||||
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"})
|
||||
chained = _volunteer(
|
||||
"chained",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained,)))
|
||||
@@ -227,9 +225,9 @@ def test_an_unrelated_existing_shift_does_not_trigger_the_chain_penalty():
|
||||
)
|
||||
unrelated = ShiftInfo(id="not-the-predecessor", starts_at=_dt(10, 8), ends_at=_dt(10, 12))
|
||||
almost_chained = _volunteer(
|
||||
"almost_chained", availability=[_full_day(10)], existing_shifts=[unrelated]
|
||||
"almost_chained", existing_shifts=[unrelated]
|
||||
)
|
||||
truly_fresh = _volunteer("truly_fresh", availability=[_full_day(10)])
|
||||
truly_fresh = _volunteer("truly_fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(almost_chained, truly_fresh)))
|
||||
assert set(_by_shift(solution, "s1")) == {"almost_chained", "truly_fresh"}
|
||||
|
||||
@@ -241,9 +239,9 @@ def test_avoids_volunteer_whose_wished_artist_plays_during_the_shift():
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
passage = ShiftInfo(id="art1", starts_at=_dt(10, 16), ends_at=_dt(10, 17))
|
||||
fan = _volunteer(
|
||||
"fan", availability=[_full_day(10)], wished_artist_windows=[passage]
|
||||
"fan", wished_artist_windows=[passage]
|
||||
)
|
||||
indiff = _volunteer("indiff", availability=[_full_day(10)])
|
||||
indiff = _volunteer("indiff")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan, indiff)))
|
||||
assert _by_shift(solution, "s1") == ("indiff",)
|
||||
|
||||
@@ -254,7 +252,7 @@ def test_still_suggests_the_only_candidate_despite_an_artist_conflict():
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
passage = ShiftInfo(id="art1", starts_at=_dt(10, 16), ends_at=_dt(10, 17))
|
||||
fan = _volunteer(
|
||||
"fan", availability=[_full_day(10)], wished_artist_windows=[passage]
|
||||
"fan", wished_artist_windows=[passage]
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,)))
|
||||
assert _by_shift(solution, "s1") == ("fan",)
|
||||
@@ -266,7 +264,7 @@ def test_artist_conflict_outside_shift_hours_has_no_effect():
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
passage = ShiftInfo(id="art1", starts_at=_dt(10, 20), ends_at=_dt(10, 21))
|
||||
fan = _volunteer(
|
||||
"fan", availability=[_full_day(10)], wished_artist_windows=[passage]
|
||||
"fan", wished_artist_windows=[passage]
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,)))
|
||||
assert _by_shift(solution, "s1") == ("fan",)
|
||||
@@ -277,9 +275,9 @@ def test_prefers_volunteer_whose_wished_companion_is_already_on_the_shift():
|
||||
# préféré à « stranger », qui n'a pas ce lien.
|
||||
shift = _shift(ideal_staff=2, already_assigned=1, assigned_volunteer_ids={"copain"})
|
||||
buddy = _volunteer(
|
||||
"buddy", availability=[_full_day(10)], wished_companion_ids={"copain"}
|
||||
"buddy", wished_companion_ids={"copain"}
|
||||
)
|
||||
stranger = _volunteer("stranger", availability=[_full_day(10)])
|
||||
stranger = _volunteer("stranger")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(buddy, stranger)))
|
||||
assert _by_shift(solution, "s1") == ("buddy",)
|
||||
|
||||
@@ -290,10 +288,10 @@ def test_companion_bonus_scales_with_number_of_matches():
|
||||
ideal_staff=4, already_assigned=2, assigned_volunteer_ids={"a", "b"}
|
||||
)
|
||||
two_friends = _volunteer(
|
||||
"two_friends", availability=[_full_day(10)], wished_companion_ids={"a", "b"}
|
||||
"two_friends", wished_companion_ids={"a", "b"}
|
||||
)
|
||||
one_friend = _volunteer(
|
||||
"one_friend", availability=[_full_day(10)], wished_companion_ids={"a"}
|
||||
"one_friend", wished_companion_ids={"a"}
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(two_friends, one_friend)))
|
||||
assert _by_shift(solution, "s1") == ("two_friends", "one_friend")
|
||||
@@ -302,7 +300,7 @@ def test_companion_bonus_scales_with_number_of_matches():
|
||||
def test_wished_companion_absent_from_the_shift_has_no_effect():
|
||||
shift = _shift()
|
||||
volunteer = _volunteer(
|
||||
"v", availability=[_full_day(10)], wished_companion_ids={"not-here"}
|
||||
"v", wished_companion_ids={"not-here"}
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,)))
|
||||
assert _by_shift(solution, "s1") == ("v",)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Client SumUp : lecture de la transaction d'un checkout payé, et
|
||||
diagnostic d'un appel refusé (journalisé sans données du payeur)."""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.sumup import client as sumup
|
||||
|
||||
|
||||
def test_transaction_is_read_from_the_checkout():
|
||||
checkout = sumup._to_checkout(
|
||||
{
|
||||
"id": "co_1",
|
||||
"status": "PAID",
|
||||
"transaction_code": "TEENSK4W2K",
|
||||
"transaction_id": "tx-1",
|
||||
}
|
||||
)
|
||||
assert checkout.transaction_code == "TEENSK4W2K"
|
||||
assert checkout.transaction_id == "tx-1"
|
||||
|
||||
|
||||
def test_transaction_falls_back_to_the_successful_attempt():
|
||||
checkout = sumup._to_checkout(
|
||||
{
|
||||
"id": "co_1",
|
||||
"status": "PAID",
|
||||
"transactions": [
|
||||
{"id": "tx-failed", "transaction_code": "TFAILED", "status": "FAILED"},
|
||||
{"id": "tx-ok", "transaction_code": "TOK", "status": "SUCCESSFUL"},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert checkout.transaction_code == "TOK"
|
||||
assert checkout.transaction_id == "tx-ok"
|
||||
|
||||
|
||||
def test_unpaid_checkout_has_no_transaction():
|
||||
checkout = sumup._to_checkout({"id": "co_1", "status": "PENDING"})
|
||||
assert checkout.transaction_code is None
|
||||
assert checkout.transaction_id is None
|
||||
|
||||
|
||||
def test_refused_call_logs_the_sumup_error_code(monkeypatch, caplog):
|
||||
request = httpx.Request("POST", "https://api.sumup.com/v0.1/checkouts")
|
||||
response = httpx.Response(
|
||||
409,
|
||||
json={"error_code": "DUPLICATED_CHECKOUT", "message": "Checkout already exists"},
|
||||
request=request,
|
||||
)
|
||||
monkeypatch.setattr(httpx, "request", lambda *a, **kw: response)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="app.sumup"):
|
||||
result = sumup.get_checkout(api_key="k", checkout_id="co_1")
|
||||
|
||||
assert result is None
|
||||
assert "HTTP 409" in caplog.text
|
||||
assert "DUPLICATED_CHECKOUT" in caplog.text
|
||||
@@ -4,7 +4,6 @@ règle, plus les cas limites de la sémantique figée (CLAUDE.md § 3)."""
|
||||
import datetime as dt
|
||||
|
||||
from app.validation import (
|
||||
AvailabilityWindow,
|
||||
DayPartWindow,
|
||||
Severity,
|
||||
ShiftInfo,
|
||||
@@ -33,14 +32,6 @@ def _codes(violations) -> list[ViolationCode]:
|
||||
return [v.code for v in violations]
|
||||
|
||||
|
||||
def _win(day: int, start_h: int, end_h: int) -> AvailabilityWindow:
|
||||
"""Fenêtre de dispo le <day> juillet, de start_h:00 à end_h:00.
|
||||
`end_h <= start_h` ⇒ se termine le lendemain."""
|
||||
return AvailabilityWindow(
|
||||
dt.date(2026, 7, day), dt.time(start_h % 24, 0), dt.time(end_h % 24, 0)
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SHIFT_OVERLAP #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -48,230 +39,74 @@ def test_overlap_pass_when_shifts_are_adjacent():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),),
|
||||
availabilities=(_win(10, 8, 23),),
|
||||
)
|
||||
assert ViolationCode.SHIFT_OVERLAP not in _codes(validate(data))
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_overlap_triggers_and_is_blocking():
|
||||
def test_overlap_triggers_as_a_warning_only():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),),
|
||||
availabilities=(_win(10, 8, 23),),
|
||||
)
|
||||
violations = validate(data)
|
||||
overlap = [v for v in violations if v.code is ViolationCode.SHIFT_OVERLAP]
|
||||
assert len(overlap) == 1
|
||||
assert overlap[0].severity is Severity.BLOCKING
|
||||
assert overlap[0].params["other_shift_id"] == "e"
|
||||
assert has_blocking(violations) is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# OUTSIDE_AVAILABILITY #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_availability_pass_when_shift_within_declared_window():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
availabilities=(_win(10, 13, 19),),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_availability_pass_when_window_exactly_matches_shift():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
availabilities=(_win(10, 14, 18),),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_availability_triggers_when_nothing_declared():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert _codes(violations) == [ViolationCode.SHIFT_OVERLAP]
|
||||
# Deux postes en même temps restent possibles : avertissement seulement.
|
||||
assert violations[0].severity is Severity.WARNING
|
||||
assert violations[0].params["date"] == "2026-07-10"
|
||||
assert violations[0].params["uncovered_minutes"] == 240
|
||||
|
||||
|
||||
def test_availability_triggers_on_partial_coverage_and_counts_the_gap():
|
||||
# Créneau 16:00 -> 20:00 ; dispo seulement 13:00 -> 19:00 ⇒ 60 min à découvert.
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 16), _dt(10, 20)),
|
||||
availabilities=(_win(10, 13, 19),),
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert violations[0].params["uncovered_minutes"] == 60
|
||||
|
||||
|
||||
def test_availability_union_of_several_windows_covers_the_shift():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 12), _dt(10, 20)),
|
||||
availabilities=(_win(10, 12, 16), _win(10, 15, 20)),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_availability_midnight_crossing_window_covers_night_shift():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 22), _dt(11, 2)),
|
||||
availabilities=(_win(10, 19, 4),), # 19:00 -> 04:00 le lendemain
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_availability_gap_between_two_windows_is_flagged():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 18)),
|
||||
availabilities=(_win(10, 10, 12), _win(10, 14, 18)), # trou 12:00-14:00
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert violations[0].params["uncovered_minutes"] == 120
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# DAILY_HOURS_EXCEEDED #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_daily_hours_pass_at_exactly_eight():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h pile
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_win(10, 8, 18),),
|
||||
)
|
||||
assert ViolationCode.DAILY_HOURS_EXCEEDED not in _codes(validate(data))
|
||||
|
||||
|
||||
def test_daily_hours_triggers_above_eight():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h
|
||||
existing_shifts=(_shift("e", _dt(10, 20), _dt(10, 20, 30)),), # +30 min
|
||||
day_parts=DAY_PARTS,
|
||||
)
|
||||
violations = [
|
||||
v for v in validate(data) if v.code is ViolationCode.DAILY_HOURS_EXCEEDED
|
||||
]
|
||||
assert len(violations) == 1
|
||||
assert violations[0].params["total_minutes"] == 510
|
||||
assert violations[0].params["date"] == "2026-07-10"
|
||||
|
||||
|
||||
def test_daily_hours_day_starts_at_first_day_part_boundary():
|
||||
# Frontière de journée = 06:00 (début de MORNING). Un créneau de nuit qui
|
||||
# commence à 03:00 le 11 appartient à la journée du 10.
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 8), _dt(10, 15)), # 7 h, journée 10
|
||||
existing_shifts=(_shift("e", _dt(11, 3), _dt(11, 5)),), # 2 h, journée 10
|
||||
day_parts=DAY_PARTS,
|
||||
)
|
||||
violations = [
|
||||
v for v in validate(data) if v.code is ViolationCode.DAILY_HOURS_EXCEEDED
|
||||
]
|
||||
assert len(violations) == 1
|
||||
assert violations[0].params["date"] == "2026-07-10"
|
||||
|
||||
|
||||
def test_daily_hours_ignores_other_days():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h journée 10
|
||||
existing_shifts=(_shift("e", _dt(9, 9), _dt(9, 17)),), # 8 h journée 9
|
||||
day_parts=DAY_PARTS,
|
||||
)
|
||||
assert ViolationCode.DAILY_HOURS_EXCEEDED not in _codes(validate(data))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# INSUFFICIENT_REST #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_rest_pass_with_more_than_eight_hours_gap():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 8), _dt(10, 12)),
|
||||
existing_shifts=(_shift("e", _dt(9, 18), _dt(9, 23)),), # écart 9 h
|
||||
availabilities=(_win(10, 8, 12),),
|
||||
)
|
||||
assert ViolationCode.INSUFFICIENT_REST not in _codes(validate(data))
|
||||
|
||||
|
||||
def test_rest_pass_at_exactly_eight_hours():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 4), _dt(10, 12)),
|
||||
existing_shifts=(_shift("e", _dt(10, 20), _dt(10, 22)),), # écart 8 h pile
|
||||
)
|
||||
assert ViolationCode.INSUFFICIENT_REST not in _codes(validate(data))
|
||||
|
||||
|
||||
def test_rest_triggers_for_shift_before():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 14)),
|
||||
existing_shifts=(_shift("e", _dt(10, 2), _dt(10, 6)),), # écart 4 h
|
||||
)
|
||||
violations = [
|
||||
v for v in validate(data) if v.code is ViolationCode.INSUFFICIENT_REST
|
||||
]
|
||||
assert len(violations) == 1
|
||||
assert violations[0].params["direction"] == "BEFORE"
|
||||
assert violations[0].params["gap_minutes"] == 240
|
||||
|
||||
|
||||
def test_rest_triggers_for_shift_after():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 14)),
|
||||
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 20)),), # écart 4 h
|
||||
)
|
||||
violations = [
|
||||
v for v in validate(data) if v.code is ViolationCode.INSUFFICIENT_REST
|
||||
]
|
||||
assert len(violations) == 1
|
||||
assert violations[0].params["direction"] == "AFTER"
|
||||
|
||||
|
||||
def test_back_to_back_shifts_are_rest_violation_not_overlap():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),),
|
||||
availabilities=(_win(10, 8, 23),),
|
||||
)
|
||||
codes = _codes(validate(data))
|
||||
assert ViolationCode.SHIFT_OVERLAP not in codes
|
||||
assert ViolationCode.INSUFFICIENT_REST in codes
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Combinaisons #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_clean_assignment_has_no_violation():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
existing_shifts=(_shift("e", _dt(9, 8), _dt(9, 12)),),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_win(10, 13, 19),),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_multiple_rules_fire_together():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),), # chevauchement
|
||||
# aucune dispo déclarée ⇒ OUTSIDE_AVAILABILITY
|
||||
)
|
||||
codes = set(_codes(validate(data)))
|
||||
assert {
|
||||
ViolationCode.SHIFT_OVERLAP,
|
||||
ViolationCode.OUTSIDE_AVAILABILITY,
|
||||
} <= codes
|
||||
assert has_blocking(validate(data)) is True
|
||||
|
||||
|
||||
def test_only_warnings_is_not_blocking():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
# pas de dispo ⇒ un seul avertissement OUTSIDE_AVAILABILITY
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert violations[0].params["other_shift_id"] == "e"
|
||||
assert has_blocking(violations) is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Plus de limite de temps dans la journée #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_long_day_and_short_rest_raise_nothing():
|
||||
"""Ni cumul journalier (> 8 h) ni repos minimal (< 8 h) : seuls les
|
||||
chevauchements sont signalés."""
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 22)),
|
||||
existing_shifts=(
|
||||
_shift("matin", _dt(10, 6), _dt(10, 14)),
|
||||
_shift("nuit", _dt(10, 23), _dt(11, 3)),
|
||||
),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_clean_assignment_has_no_violation():
|
||||
data = ValidationInput(candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)))
|
||||
assert validate(data) == []
|
||||
assert has_blocking([]) is False
|
||||
|
||||
|
||||
# --- Début de « journée de festival » (day_boundary) ------------------------
|
||||
|
||||
|
||||
def test_day_boundary_starts_after_last_day_part_ends():
|
||||
from app.validation.engine import day_boundary
|
||||
|
||||
parts = (
|
||||
DayPartWindow("MATIN", dt.time(8, 0), dt.time(13, 0), 0),
|
||||
DayPartWindow("APRES_MIDI", dt.time(13, 0), dt.time(19, 0), 1),
|
||||
DayPartWindow("SOIREE", dt.time(19, 0), dt.time(2, 0), 2),
|
||||
)
|
||||
assert day_boundary(parts) == dt.time(2, 0)
|
||||
|
||||
|
||||
def test_day_boundary_ignores_order_out_of_step_with_hours():
|
||||
"""Un créneau type d'ordre 0 déplacé à 21 h ne fait plus commencer la
|
||||
journée à 21 h : seul compte le creux entre la fin du dernier et le
|
||||
début du premier."""
|
||||
from app.validation.engine import day_boundary
|
||||
|
||||
parts = (
|
||||
DayPartWindow("SOIREE", dt.time(21, 0), dt.time(3, 0), 0),
|
||||
DayPartWindow("JOUR", dt.time(10, 0), dt.time(21, 0), 1),
|
||||
)
|
||||
assert day_boundary(parts) == dt.time(3, 0)
|
||||
|
||||
|
||||
def test_day_boundary_full_coverage_falls_back_to_first_by_order():
|
||||
from app.validation.engine import day_boundary
|
||||
|
||||
assert day_boundary(DAY_PARTS) == dt.time(6, 0)
|
||||
assert day_boundary(()) == dt.time(0, 0)
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import datetime as dt
|
||||
|
||||
from app.models import Assignment
|
||||
from app.validation import AvailabilityWindow
|
||||
from app.validation.loader import build_validation_input
|
||||
from tests.factories import (
|
||||
dt_at,
|
||||
make_availability,
|
||||
make_position,
|
||||
make_shift,
|
||||
make_volunteer,
|
||||
@@ -21,11 +17,6 @@ def test_build_input_gathers_everything_from_db(client, edition, session, day_pa
|
||||
session, edition, position, start=dt_at(11, 9), end=dt_at(11, 12)
|
||||
)
|
||||
volunteer = make_volunteer(session, edition, email="v@ex.fr")
|
||||
make_availability(
|
||||
session, edition, volunteer,
|
||||
date=dt.date(2026, 7, 10),
|
||||
start_time=dt.time(13, 0), end_time=dt.time(19, 0),
|
||||
)
|
||||
session.add(
|
||||
Assignment(
|
||||
edition_id=edition.id, volunteer_id=volunteer.id,
|
||||
@@ -41,10 +32,6 @@ def test_build_input_gathers_everything_from_db(client, edition, session, day_pa
|
||||
assert data.candidate_shift.id == str(candidate.id)
|
||||
# Toute affectation existante compte, sauf le créneau candidat lui-même.
|
||||
assert [s.id for s in data.existing_shifts] == [str(other_shift.id)]
|
||||
assert len(data.day_parts) == 3
|
||||
assert data.availabilities == (
|
||||
AvailabilityWindow(dt.date(2026, 7, 10), dt.time(13, 0), dt.time(19, 0)),
|
||||
)
|
||||
|
||||
|
||||
def test_exclude_assignment_id_is_honoured(client, edition, session, day_parts):
|
||||
|
||||
+106
-2
@@ -18,10 +18,11 @@ import type { AccountRead } from '@/api/schema';
|
||||
import { queryKeys } from '@/api/hooks';
|
||||
import { AuthContext, AuthProvider, type AuthContextValue } from '@/auth/auth-context';
|
||||
import { routes } from '@/app/router';
|
||||
import { buildTimeGrid, placeInTimeGrid } from '@/lib/planning';
|
||||
import { buildTimeGrid, dayBoundaryFromDayParts, placeInTimeGrid } from '@/lib/planning';
|
||||
import { BadgesPage } from '@/pages/BadgesPage';
|
||||
import { ConfigPage } from '@/pages/ConfigPage';
|
||||
import { FormBuilderPage } from '@/pages/FormBuilderPage';
|
||||
import { StatsPage } from '@/pages/StatsPage';
|
||||
import { GrillePage } from '@/pages/GrillePage';
|
||||
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
||||
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
|
||||
@@ -423,7 +424,107 @@ function renderPublic(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Début de journée indépendant de `order` (régression : un créneau type
|
||||
// d'ordre 0 déplacé à 21:00 faisait commencer chaque journée à 21:00 dans les
|
||||
// grilles, jusqu'à 03:00 le lendemain) — miroir de
|
||||
// backend/tests/test_validation_engine.py (day_boundary).
|
||||
function checkDayBoundary() {
|
||||
const cases: Array<[Parameters<typeof dayBoundaryFromDayParts>[0], string]> = [
|
||||
[[], '00:00'],
|
||||
[
|
||||
[
|
||||
{ start_time: '08:00:00', end_time: '13:00:00', order: 0 },
|
||||
{ start_time: '13:00:00', end_time: '19:00:00', order: 1 },
|
||||
{ start_time: '19:00:00', end_time: '02:00:00', order: 2 },
|
||||
],
|
||||
'02:00',
|
||||
],
|
||||
[
|
||||
[
|
||||
{ start_time: '21:00:00', end_time: '03:00:00', order: 0 },
|
||||
{ start_time: '10:00:00', end_time: '21:00:00', order: 1 },
|
||||
],
|
||||
'03:00',
|
||||
],
|
||||
[
|
||||
[
|
||||
{ start_time: '06:00:00', end_time: '19:00:00', order: 1 },
|
||||
{ start_time: '19:00:00', end_time: '06:00:00', order: 0 },
|
||||
],
|
||||
'19:00',
|
||||
],
|
||||
];
|
||||
for (const [parts, expected] of cases) {
|
||||
const got = dayBoundaryFromDayParts(parts);
|
||||
if (got !== expected) throw new Error(`attendu ${expected}, obtenu ${got}`);
|
||||
}
|
||||
console.log(`ok ${'dayBoundary'.padEnd(26)} ${cases.length} cas`);
|
||||
}
|
||||
|
||||
// Éditeur de badges (onglet « Personnaliser ») : un champ par face, rendu
|
||||
// avec les vraies données (nom du premier bénévole dans l'ordre
|
||||
// d'impression), contrôle avant génération présent ; le mock refuse un même
|
||||
// type de champ deux fois sur une face, mais l'accepte sur les deux.
|
||||
async function checkBadgeEditor() {
|
||||
if (!coordinator) throw new Error('badges : pas de session coordination pour ce test');
|
||||
const base = {
|
||||
x_mm: 4, y_mm: 4, width_mm: 40, height_mm: 12, font_size: 9, align: 'LEFT' as const,
|
||||
bold: true, color: '#14110E', bg_color: null, auto_fit: true, photo_shape: 'RECT' as const,
|
||||
};
|
||||
let duplicate = 'non';
|
||||
try {
|
||||
await api.badges.setFields(e, {
|
||||
fields: [
|
||||
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
|
||||
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
|
||||
],
|
||||
});
|
||||
} catch (err) {
|
||||
duplicate = err instanceof ApiError ? err.code : String(err);
|
||||
}
|
||||
if (duplicate !== 'badge.duplicate_field_type') throw new Error(`doublon accepté : ${duplicate}`);
|
||||
await api.badges.setFields(e, {
|
||||
fields: [
|
||||
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
|
||||
{ ...base, field_type: 'POSITIONS', face: 'FRONT', y_mm: 18, height_mm: 30, bold: false },
|
||||
{ ...base, field_type: 'FULL_NAME', face: 'BACK', bg_color: '#FFD23F' },
|
||||
{ ...base, field_type: 'FREE_TEXT', face: 'BACK', y_mm: 30, text: 'STAFF' },
|
||||
],
|
||||
});
|
||||
const vols = [...(await api.volunteers.list(e))].sort(
|
||||
(a, b) =>
|
||||
a.last_name.localeCompare(b.last_name, 'fr') || a.first_name.localeCompare(b.first_name, 'fr'),
|
||||
);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await prefetchCore(client);
|
||||
await Promise.all([
|
||||
client.prefetchQuery({ queryKey: ['badgeTemplate', e], queryFn: () => api.badges.getTemplate(e) }),
|
||||
client.prefetchQuery({ queryKey: ['dayParts', e], queryFn: () => api.dayParts.list(e) }),
|
||||
client.prefetchQuery({ queryKey: ['accounts', e], queryFn: () => api.accounts.list(e) }),
|
||||
]);
|
||||
const html = renderToString(
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client },
|
||||
createElement(
|
||||
AuthContext.Provider,
|
||||
{ value: authValue(coordinator) },
|
||||
createElement(MemoryRouter, null, createElement(BadgesPage)),
|
||||
),
|
||||
),
|
||||
);
|
||||
const expected = [vols[0]?.last_name ?? '', 'STAFF', 'Contrôle des'];
|
||||
const missing = expected.filter((text) => !html.includes(text));
|
||||
if (missing.length) throw new Error(`badges : absent du rendu ${JSON.stringify(missing)}`);
|
||||
console.log(`ok ${'/badges'.padEnd(26)} ${html.length} octets`);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
try {
|
||||
checkDayBoundary();
|
||||
} catch (err) {
|
||||
fail('checkDayBoundary', err);
|
||||
}
|
||||
await checkAuth().catch((err) => fail('checkAuth', err));
|
||||
await checkPublicForm().catch((err) => fail('checkPublicForm', err));
|
||||
await checkPositionSheets().catch((err) => fail('checkPositionSheets', err));
|
||||
@@ -441,8 +542,11 @@ if (coordinator) {
|
||||
await renderProtected('/grille', coordinator, createElement(GrillePage));
|
||||
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
|
||||
await renderProtected('/configuration', coordinator, createElement(ConfigPage));
|
||||
await renderProtected('/badges', coordinator, createElement(BadgesPage));
|
||||
await checkBadgeEditor().catch((err) => fail('checkBadgeEditor', err));
|
||||
await renderProtected('/inscriptions', coordinator, createElement(FormBuilderPage));
|
||||
await renderProtected('/statistiques', coordinator, createElement(StatsPage), async (client) => {
|
||||
await client.prefetchQuery({ queryKey: ['stats', e], queryFn: () => api.stats.get(e) });
|
||||
});
|
||||
}
|
||||
if (volunteerAccount) {
|
||||
const vid = volunteerAccount.volunteer_id as string;
|
||||
|
||||
+82
-37
@@ -16,7 +16,6 @@ import type {
|
||||
AssignmentCreate,
|
||||
AssignmentUpdate,
|
||||
AssignmentValidateResult,
|
||||
AvailabilitySet,
|
||||
BadgeFieldsSet,
|
||||
BadgeGenerateRequest,
|
||||
BadgeSizeUpdate,
|
||||
@@ -38,6 +37,9 @@ import type {
|
||||
ShiftUpdate,
|
||||
VolunteerCreate,
|
||||
VolunteerUpdate,
|
||||
AssignmentRead,
|
||||
AttendanceStatus,
|
||||
TestEmailRequest,
|
||||
} from './schema';
|
||||
|
||||
const edition = EDITION_ID;
|
||||
@@ -104,9 +106,15 @@ export function useVolunteerPlanning(volunteerId: string | null | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
/** URL objet (aperçu) de la photo d'un bénévole (formulaire public), ou
|
||||
* `null` si absente. */
|
||||
export function useVolunteerPhotoUrl(volunteerId: string | null | undefined, hasPhoto: boolean) {
|
||||
/** URL objet (aperçu) de la photo d'un bénévole (formulaire public ou
|
||||
* ajoutée par la coordination), ou `null` si absente. `version` (ex.
|
||||
* `updated_at`) force le rechargement quand la photo est remplacée —
|
||||
* `hasPhoto` resterait `true` et rien ne changerait sinon. */
|
||||
export function useVolunteerPhotoUrl(
|
||||
volunteerId: string | null | undefined,
|
||||
hasPhoto: boolean,
|
||||
version?: string,
|
||||
) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let objectUrl: string | null = null;
|
||||
@@ -124,7 +132,7 @@ export function useVolunteerPhotoUrl(volunteerId: string | null | undefined, has
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [volunteerId, hasPhoto]);
|
||||
}, [volunteerId, hasPhoto, version]);
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -135,23 +143,6 @@ export function useShifts(positionId?: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Fenêtres de disponibilité d'un bénévole. */
|
||||
export function useVolunteerAvailability(volunteerId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['availability', edition, volunteerId ?? null],
|
||||
queryFn: () => api.availability.list(edition, volunteerId as string),
|
||||
enabled: Boolean(volunteerId),
|
||||
});
|
||||
}
|
||||
|
||||
/** Toutes les disponibilités de l'édition (grille des besoins). */
|
||||
export function useEditionAvailability() {
|
||||
return useQuery({
|
||||
queryKey: ['availability', edition, 'all'],
|
||||
queryFn: () => api.availability.listEdition(edition),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssignments(
|
||||
params?: { volunteerId?: string; shiftId?: string },
|
||||
options?: { enabled?: boolean },
|
||||
@@ -167,6 +158,18 @@ export function useMatrix() {
|
||||
return useQuery({ queryKey: queryKeys.matrix, queryFn: () => api.matrix.get(edition) });
|
||||
}
|
||||
|
||||
/** Statistiques de l'édition (inscriptions, réponses, paiements, besoins) —
|
||||
* recalculées côté serveur à chaque appel. */
|
||||
/** Rafraîchie toutes les 30 s tant que l'écran est ouvert : la section
|
||||
* « Présence » suit le pointage des référents en temps réel. */
|
||||
export function useStats() {
|
||||
return useQuery({
|
||||
queryKey: ['stats', edition] as const,
|
||||
queryFn: () => api.stats.get(edition),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Configuration du recto/verso des badges (images, champs). */
|
||||
export function useBadgeTemplate() {
|
||||
return useQuery({
|
||||
@@ -241,7 +244,6 @@ function useInvalidate() {
|
||||
const client = useQueryClient();
|
||||
return {
|
||||
volunteers: () => client.invalidateQueries({ queryKey: ['volunteers', edition] }),
|
||||
availability: () => client.invalidateQueries({ queryKey: ['availability', edition] }),
|
||||
artists: () => client.invalidateQueries({ queryKey: ['artists', edition] }),
|
||||
accounts: () => client.invalidateQueries({ queryKey: ['accounts', edition] }),
|
||||
positions: () => client.invalidateQueries({ queryKey: ['positions', edition] }),
|
||||
@@ -349,6 +351,14 @@ export function useUpdateAccount() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useResetLoginLink() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.accounts.resetLoginLink(edition, id),
|
||||
onSuccess: invalidate.accounts,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAccount() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
@@ -374,6 +384,23 @@ export function useUpdateVolunteer() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetVolunteerPhoto() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, file }: { id: string; file: File }) =>
|
||||
api.volunteers.setPhoto(edition, id, file),
|
||||
onSuccess: invalidate.volunteers,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveVolunteerPhoto() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.volunteers.removePhoto(edition, id),
|
||||
onSuccess: invalidate.volunteers,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteVolunteer() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
@@ -402,20 +429,6 @@ export function useExportMecenatReceipts() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Remplace toutes les fenêtres de disponibilité d'un bénévole. */
|
||||
export function useReplaceAvailability() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: ({ volunteerId, body }: { volunteerId: string; body: AvailabilitySet }) =>
|
||||
api.availability.replace(edition, volunteerId, body),
|
||||
onSuccess: () => {
|
||||
invalidate.availability();
|
||||
// Les avertissements « hors disponibilité » de la grille en dépendent.
|
||||
invalidate.planning();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Import de bénévoles — phase 1 : analyse du fichier. */
|
||||
export function useAnalyzeImport() {
|
||||
return useMutation({
|
||||
@@ -527,6 +540,32 @@ export function useUpdateAssignment() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Pointage présent / absent (`null` annule), enregistré en base. Mis à
|
||||
* jour tout de suite dans les listes d'affectations en cache (le référent
|
||||
* pointe vite, sur un réseau de festival souvent lent), puis rechargé. */
|
||||
export function useSetAttendance() {
|
||||
const client = useQueryClient();
|
||||
const patch = (id: string, change: Partial<AssignmentRead>) =>
|
||||
client.setQueriesData<AssignmentRead[]>({ queryKey: ['assignments', edition] }, (list) =>
|
||||
list?.map((a) => (a.id === id ? { ...a, ...change } : a)),
|
||||
);
|
||||
return useMutation({
|
||||
mutationFn: ({ id, attendance }: { id: string; attendance: AttendanceStatus | null }) =>
|
||||
api.assignments.setAttendance(edition, id, { attendance }),
|
||||
onMutate: async ({ id, attendance }) => {
|
||||
await client.cancelQueries({ queryKey: ['assignments', edition] });
|
||||
const previous = client.getQueriesData<AssignmentRead[]>({ queryKey: ['assignments', edition] });
|
||||
patch(id, { attendance });
|
||||
return { previous };
|
||||
},
|
||||
onError: (_error, _vars, context) => {
|
||||
for (const [key, data] of context?.previous ?? []) client.setQueryData(key, data);
|
||||
},
|
||||
onSuccess: (saved) => patch(saved.id, saved),
|
||||
onSettled: () => client.invalidateQueries({ queryKey: ['assignments', edition] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAssignment() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
@@ -556,6 +595,12 @@ export function useGeneratePositionSheets() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendTestEmail() {
|
||||
return useMutation({
|
||||
mutationFn: (body: TestEmailRequest) => api.notifications.sendTest(edition, body),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendPlanningNotification() {
|
||||
return useMutation({
|
||||
mutationFn: (body: PlanningNotificationRequest) =>
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
AssignmentCreate,
|
||||
AssignmentUpdate,
|
||||
AssignmentValidateRequest,
|
||||
AvailabilitySet,
|
||||
BadgeFieldsSet,
|
||||
BadgeGenerateRequest,
|
||||
BadgeSizeUpdate,
|
||||
@@ -31,6 +30,8 @@ import type {
|
||||
VerifyRequest,
|
||||
VolunteerCreate,
|
||||
VolunteerUpdate,
|
||||
AttendanceUpdate,
|
||||
TestEmailRequest,
|
||||
} from './schema';
|
||||
import { getAuthToken, notifyUnauthorized } from './session';
|
||||
import { ApiError, type PlanningApi } from './types';
|
||||
@@ -71,7 +72,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
? ((detail as { violations: [] }).violations ?? [])
|
||||
: [];
|
||||
if (res.status === 401 && !path.includes('/auth/')) notifyUnauthorized();
|
||||
throw new ApiError(res.status, code, violations);
|
||||
const requestId = res.headers.get('X-Request-ID');
|
||||
if (res.status >= 500) {
|
||||
console.error(`API ${res.status} ${path} — référence ${requestId ?? '?'}`, detail);
|
||||
}
|
||||
throw new ApiError(res.status, code, violations, requestId);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
@@ -100,7 +105,7 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
// Corps non JSON (ex. erreur réseau) : garde le code générique.
|
||||
}
|
||||
if (res.status === 401) notifyUnauthorized();
|
||||
throw new ApiError(res.status, code, []);
|
||||
throw new ApiError(res.status, code, [], res.headers.get('X-Request-ID'));
|
||||
}
|
||||
return res.blob();
|
||||
}
|
||||
@@ -152,6 +157,13 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
planning: (editionId, id) =>
|
||||
request(`${base(editionId)}/volunteers/${id}/planning`),
|
||||
photoBlob: (editionId, id) => requestBlob(`${base(editionId)}/volunteers/${id}/photo`),
|
||||
setPhoto: (editionId, id, file: File) => {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return request(`${base(editionId)}/volunteers/${id}/photo`, { method: 'PUT', body: form });
|
||||
},
|
||||
removePhoto: (editionId, id) =>
|
||||
request(`${base(editionId)}/volunteers/${id}/photo`, { method: 'DELETE' }),
|
||||
exportXlsx: async (editionId) => {
|
||||
const blob = await requestBlob(`${base(editionId)}/volunteers/export`);
|
||||
if (!blob) throw new ApiError(500, 'error.generic', []);
|
||||
@@ -171,16 +183,8 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
request(`${base(editionId)}/accounts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
remove: (editionId, id) =>
|
||||
request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }),
|
||||
},
|
||||
availability: {
|
||||
list: (editionId, volunteerId) =>
|
||||
request(`${base(editionId)}/volunteers/${volunteerId}/availability`),
|
||||
replace: (editionId, volunteerId, body: AvailabilitySet) =>
|
||||
request(`${base(editionId)}/volunteers/${volunteerId}/availability`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
listEdition: (editionId) => request(`${base(editionId)}/availability`),
|
||||
resetLoginLink: (editionId, id) =>
|
||||
request(`${base(editionId)}/accounts/${id}/reset-login-link`, { method: 'POST' }),
|
||||
},
|
||||
imports: {
|
||||
analyze: (editionId, file: File) => {
|
||||
@@ -232,6 +236,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
request(`${base(editionId)}/assignments/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
remove: (editionId, id) =>
|
||||
request(`${base(editionId)}/assignments/${id}`, { method: 'DELETE' }),
|
||||
setAttendance: (editionId, id, body: AttendanceUpdate) =>
|
||||
request(`${base(editionId)}/assignments/${id}/attendance`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
validate: (editionId, body: AssignmentValidateRequest) =>
|
||||
request(`${base(editionId)}/assignments/validate`, {
|
||||
method: 'POST',
|
||||
@@ -241,6 +250,9 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
matrix: {
|
||||
get: (editionId) => request(`${base(editionId)}/matrix`),
|
||||
},
|
||||
stats: {
|
||||
get: (editionId) => request(`${base(editionId)}/stats`),
|
||||
},
|
||||
badges: {
|
||||
getTemplate: (editionId) => request(`${base(editionId)}/badges/template`),
|
||||
uploadFrontImage: (editionId, file: File) => {
|
||||
@@ -369,6 +381,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
sendTest: (editionId, body: TestEmailRequest) =>
|
||||
request(`${base(editionId)}/notifications/test`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
},
|
||||
auth: {
|
||||
login: (editionId, body: LoginRequest) =>
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
AccountRead,
|
||||
ArtistRead,
|
||||
AssignmentRead,
|
||||
AvailabilityRead,
|
||||
BadgeFieldRead,
|
||||
DayPartRead,
|
||||
EditionRead,
|
||||
@@ -67,7 +66,6 @@ export interface MockState {
|
||||
artists: ArtistRead[];
|
||||
volunteers: VolunteerRead[];
|
||||
/** volunteerId -> fenêtres de disponibilité déclarées. */
|
||||
availability: Map<string, AvailabilityRead[]>;
|
||||
positions: PositionRead[];
|
||||
shifts: ShiftRead[];
|
||||
assignments: AssignmentRead[];
|
||||
@@ -161,6 +159,14 @@ const ARTIST_POOL = [
|
||||
];
|
||||
|
||||
const NOW = '2026-10-10T09:00:00';
|
||||
|
||||
/** Date d'inscription de démonstration du i-ème bénévole : plus dense en
|
||||
* fin de période (relances), jamais après `NOW`. */
|
||||
function registeredAt(i: number): string {
|
||||
const daysBefore = Math.floor(((i * 37) % 64) ** 1.4 / 9);
|
||||
const d = new Date(Date.UTC(2026, 9, 10 - daysBefore, 9 + (i % 10), (i * 13) % 60));
|
||||
return d.toISOString().slice(0, 19);
|
||||
}
|
||||
const NEVER_ASSIGNED = new Set([7, 21, 34, 48, 55]);
|
||||
const OVERLOADED = [3, 9];
|
||||
|
||||
@@ -189,6 +195,11 @@ function demoEmail(firstName: string, lastName: string): string {
|
||||
return `${n(firstName)}.${n(lastName)}@mail.fr`;
|
||||
}
|
||||
|
||||
const DEMO_POSITION_MESSAGES: Record<string, string> = {
|
||||
'Parking — Nord': 'Gilet jaune fourni à la base bénévoles. Lampe frontale conseillée le soir.',
|
||||
'Bar — Grande scène': 'Caisse à récupérer auprès du référent en début de créneau. Pas de vente aux mineurs.',
|
||||
};
|
||||
|
||||
export function buildMockState(): MockState {
|
||||
const rnd = mulberry32(20260715);
|
||||
const edition_id = MOCK_EDITION_ID;
|
||||
@@ -200,6 +211,9 @@ export function buildMockState(): MockState {
|
||||
location: p.location,
|
||||
category: p.category,
|
||||
lead_account_id: null,
|
||||
// Quelques consignes de démo (voir `PositionRead.message`).
|
||||
message: DEMO_POSITION_MESSAGES[p.name] ?? null,
|
||||
message_updated_at: DEMO_POSITION_MESSAGES[p.name] ? NOW : null,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
}));
|
||||
@@ -258,37 +272,10 @@ export function buildMockState(): MockState {
|
||||
);
|
||||
|
||||
const volunteers: VolunteerRead[] = [];
|
||||
const availability = new Map<string, AvailabilityRead[]>();
|
||||
let availSeq = 0;
|
||||
const window = (
|
||||
volunteerId: string,
|
||||
date: string,
|
||||
start_time: string,
|
||||
end_time: string,
|
||||
): AvailabilityRead => ({
|
||||
id: `av${availSeq++}`,
|
||||
edition_id,
|
||||
volunteer_id: volunteerId,
|
||||
date,
|
||||
start_time,
|
||||
end_time,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
});
|
||||
|
||||
for (let i = 0; i < 64; i += 1) {
|
||||
const first_name = FIRST_NAMES[i % FIRST_NAMES.length];
|
||||
const last_name = LAST_NAMES[(i * 7 + 3) % LAST_NAMES.length];
|
||||
const id = `v${i}`;
|
||||
// Pour ~75 % des jours : une fenêtre large 08:00 -> 02:00 le lendemain.
|
||||
const windows: AvailabilityRead[] = [];
|
||||
for (const dateStr of DAY_DATES) {
|
||||
const edge = dateStr === DAY_DATES[0] || dateStr === DAY_DATES[3];
|
||||
if (rnd() < (edge ? 0.55 : 0.8)) {
|
||||
windows.push(window(id, dateStr, '08:00:00', '02:00:00'));
|
||||
}
|
||||
}
|
||||
availability.set(id, windows);
|
||||
const pair = () => `0${Math.floor(rnd() * 90 + 10)}`.slice(-2);
|
||||
const artist_ids = artists.filter(() => rnd() < 0.25).map((a) => a.id);
|
||||
const wished_position_ids =
|
||||
@@ -310,6 +297,8 @@ export function buildMockState(): MockState {
|
||||
wished_companion_ids,
|
||||
has_photo: false,
|
||||
companion_wish_note: null,
|
||||
badge_text: null,
|
||||
badge_count: 1,
|
||||
self_declared_available: false,
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
@@ -320,17 +309,36 @@ export function buildMockState(): MockState {
|
||||
mecenat_country: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
fee_transaction_code: null,
|
||||
fee_transaction_id: null,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
});
|
||||
// Démo des statistiques : inscriptions étalées sur ~5 semaines et
|
||||
// quelques cotisations — dérivées de `i`, pas de `rnd()`, pour ne rien
|
||||
// décaler du reste du jeu de démonstration.
|
||||
const v = volunteers[volunteers.length - 1];
|
||||
v.created_at = registeredAt(i);
|
||||
if (i % 4 === 1) v.willing_to_pay_fee = true;
|
||||
if (i % 7 === 2 || i % 11 === 3) {
|
||||
const mecenat = i % 11 === 3;
|
||||
Object.assign(v, {
|
||||
willing_to_pay_fee: true,
|
||||
fee_payment_label: mecenat ? 'Mécénat' : 'Cotisation classique',
|
||||
fee_amount: mecenat ? 40 : 15,
|
||||
fee_payment_status: 'PAID',
|
||||
fee_paid_at: v.created_at,
|
||||
fee_transaction_code: `TDEMO${String(i).padStart(5, '0')}`,
|
||||
fee_transaction_id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
|
||||
...(mecenat
|
||||
? { mecenat_address: '1 rue du Festival', mecenat_city: 'Rennes', mecenat_postal_code: '35000', mecenat_country: 'France' }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const me = volunteers[0];
|
||||
me.email = demoEmail(me.first_name, me.last_name); // toujours un compte pour la démo
|
||||
availability.set(
|
||||
me.id,
|
||||
DAY_DATES.map((dateStr) => window(me.id, dateStr, '08:00:00', '02:00:00')),
|
||||
);
|
||||
NEVER_ASSIGNED.forEach((i) => {
|
||||
volunteers[i].comment = 'Inscrit, pas encore affecté';
|
||||
});
|
||||
@@ -363,10 +371,7 @@ export function buildMockState(): MockState {
|
||||
.sort((a, b) => score(b) - score(a));
|
||||
|
||||
function score(v: VolunteerRead): number {
|
||||
const available = (availability.get(v.id) ?? []).some((w) => w.date === dateStr)
|
||||
? 100
|
||||
: 0;
|
||||
return available - (totalHours.get(v.id) ?? 0) * 3;
|
||||
return -(totalHours.get(v.id) ?? 0) * 3;
|
||||
}
|
||||
|
||||
for (const v of pool.slice(0, target)) {
|
||||
@@ -377,6 +382,8 @@ export function buildMockState(): MockState {
|
||||
shift_id: shift.id,
|
||||
source: 'SOLVER',
|
||||
locked: false,
|
||||
attendance: null,
|
||||
attendance_marked_at: null,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
});
|
||||
@@ -406,6 +413,8 @@ export function buildMockState(): MockState {
|
||||
shift_id: shift.id,
|
||||
source: 'MANUAL',
|
||||
locked: false,
|
||||
attendance: null,
|
||||
attendance_marked_at: null,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
});
|
||||
@@ -469,6 +478,9 @@ export function buildMockState(): MockState {
|
||||
start_date: DAY_DATES[0],
|
||||
end_date: DAY_DATES[DAY_DATES.length - 1],
|
||||
retention_days: 365,
|
||||
volunteer_message:
|
||||
"Badges et t-shirts à récupérer à l'accueil bénévoles dès 17:00. Repas chaud servi au catering de 17:30 à 18:30.",
|
||||
volunteer_message_updated_at: NOW,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
};
|
||||
@@ -483,7 +495,6 @@ export function buildMockState(): MockState {
|
||||
assignments,
|
||||
accounts,
|
||||
passwords,
|
||||
availability,
|
||||
badgeTemplate: {
|
||||
frontImage: null,
|
||||
frontImageType: null,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { uncoveredMinutes } from '@/lib/planning';
|
||||
import { hoursBetween, naiveMinutes } from '@/lib/time';
|
||||
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
|
||||
import { hoursBetween, naiveMinutes, nowNaiveMinutes } from '@/lib/time';
|
||||
|
||||
import type {
|
||||
AccountRead,
|
||||
@@ -8,8 +8,6 @@ import type {
|
||||
ArtistUpdate,
|
||||
AssignmentRead,
|
||||
AssignmentValidateResult,
|
||||
AvailabilityRead,
|
||||
AvailabilitySet,
|
||||
BadgeFieldRead,
|
||||
BadgeFieldsSet,
|
||||
BadgeGenerateRequest,
|
||||
@@ -44,7 +42,9 @@ import type {
|
||||
TokenResponse,
|
||||
Violation,
|
||||
VolunteerRead,
|
||||
EditionStats,
|
||||
} from '../schema';
|
||||
import { MESSAGE_MAX_LENGTH } from '../schema';
|
||||
import { getAuthToken } from '../session';
|
||||
import { ApiError, type PlanningApi } from '../types';
|
||||
import { buildMockState, type MockPendingSubmission, type MockState } from './dataset';
|
||||
@@ -53,7 +53,6 @@ import { buildMockState, type MockPendingSubmission, type MockState } from './da
|
||||
// comportement (chargements, invalidations) proche du back-end réel.
|
||||
|
||||
const LATENCY_MS = 120;
|
||||
const EIGHT_HOURS_MIN = 8 * 60;
|
||||
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
let state: MockState = buildMockState();
|
||||
@@ -198,14 +197,6 @@ const slugCode = (label: string): string =>
|
||||
/** « HH:MM » ou « HH:MM:SS » -> « HH:MM:SS ». */
|
||||
const hms = (time: string): string => (time.length === 5 ? `${time}:00` : time);
|
||||
|
||||
const sortWindows = (ws: AvailabilityRead[]): AvailabilityRead[] =>
|
||||
[...ws].sort(
|
||||
(a, b) =>
|
||||
a.volunteer_id.localeCompare(b.volunteer_id) ||
|
||||
a.date.localeCompare(b.date) ||
|
||||
a.start_time.localeCompare(b.start_time),
|
||||
);
|
||||
|
||||
/** Vœux du bénévole : dé-doublonne, ordre conservé ; 422 si un id est étranger. */
|
||||
function resolveWishes<T extends { id: string }>(
|
||||
pool: T[],
|
||||
@@ -244,6 +235,8 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
|
||||
wished_companion_ids: [],
|
||||
has_photo: pending.photo !== null,
|
||||
companion_wish_note: pending.companionWishNote,
|
||||
badge_text: null,
|
||||
badge_count: 1,
|
||||
self_declared_available: pending.available,
|
||||
willing_to_pay_fee: true,
|
||||
fee_payment_label: pending.paymentLabel,
|
||||
@@ -254,12 +247,13 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
|
||||
mecenat_country: pending.mecenatCountry,
|
||||
fee_payment_status: 'PAID',
|
||||
fee_paid_at: now,
|
||||
fee_transaction_code: `TMOCK${String(state.seq).padStart(5, '0')}`,
|
||||
fee_transaction_id: `mock-transaction-${state.seq}`,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
if (pending.photo) state.photos.set(volunteer.id, pending.photo);
|
||||
state.volunteers.push(volunteer);
|
||||
state.availability.set(volunteer.id, []);
|
||||
|
||||
pending.promotedVolunteerId = volunteer.id;
|
||||
pending.status = 'PAID';
|
||||
@@ -455,12 +449,12 @@ function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignm
|
||||
|
||||
const violations: Violation[] = [];
|
||||
|
||||
// SHIFT_OVERLAP (bloquante)
|
||||
// SHIFT_OVERLAP (avertissement : deux postes à la fois restent possibles)
|
||||
for (const other of existing) {
|
||||
if (overlaps(candidate, other)) {
|
||||
violations.push({
|
||||
code: 'SHIFT_OVERLAP',
|
||||
severity: 'BLOCKING',
|
||||
severity: 'WARNING',
|
||||
params: {
|
||||
other_shift_id: other.id,
|
||||
other_starts_at: other.starts_at,
|
||||
@@ -470,63 +464,6 @@ function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignm
|
||||
}
|
||||
}
|
||||
|
||||
// OUTSIDE_AVAILABILITY (avertissement) — créneau non couvert par l'union des
|
||||
// fenêtres de disponibilité déclarées.
|
||||
const gapMin = uncoveredMinutes(
|
||||
state.availability.get(volunteerId) ?? [],
|
||||
candidate.starts_at,
|
||||
candidate.ends_at,
|
||||
);
|
||||
if (gapMin > 0) {
|
||||
violations.push({
|
||||
code: 'OUTSIDE_AVAILABILITY',
|
||||
severity: 'WARNING',
|
||||
params: {
|
||||
date: candidate.starts_at.slice(0, 10),
|
||||
uncovered_minutes: gapMin,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// DAILY_HOURS_EXCEEDED (avertissement) — journée = date de début du créneau.
|
||||
const dayKey = candidate.starts_at.slice(0, 10);
|
||||
let totalMin = (naiveMinutes(candidate.ends_at) - naiveMinutes(candidate.starts_at));
|
||||
for (const other of existing) {
|
||||
if (other.starts_at.slice(0, 10) === dayKey) {
|
||||
totalMin += naiveMinutes(other.ends_at) - naiveMinutes(other.starts_at);
|
||||
}
|
||||
}
|
||||
if (totalMin > EIGHT_HOURS_MIN) {
|
||||
violations.push({
|
||||
code: 'DAILY_HOURS_EXCEEDED',
|
||||
severity: 'WARNING',
|
||||
params: { date: dayKey, total_minutes: totalMin },
|
||||
});
|
||||
}
|
||||
|
||||
// INSUFFICIENT_REST (avertissement) — < 8 h entre deux créneaux (strict).
|
||||
for (const other of existing) {
|
||||
if (overlaps(candidate, other)) continue;
|
||||
let gap: number;
|
||||
let direction: 'BEFORE' | 'AFTER';
|
||||
if (naiveMinutes(other.ends_at) <= naiveMinutes(candidate.starts_at)) {
|
||||
gap = naiveMinutes(candidate.starts_at) - naiveMinutes(other.ends_at);
|
||||
direction = 'BEFORE';
|
||||
} else if (naiveMinutes(candidate.ends_at) <= naiveMinutes(other.starts_at)) {
|
||||
gap = naiveMinutes(other.starts_at) - naiveMinutes(candidate.ends_at);
|
||||
direction = 'AFTER';
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
if (gap < EIGHT_HOURS_MIN) {
|
||||
violations.push({
|
||||
code: 'INSUFFICIENT_REST',
|
||||
severity: 'WARNING',
|
||||
params: { other_shift_id: other.id, direction, gap_minutes: gap },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
@@ -534,6 +471,179 @@ function hasBlocking(violations: Violation[]): boolean {
|
||||
return violations.some((v) => v.severity === 'BLOCKING');
|
||||
}
|
||||
|
||||
/** Miroir de `app/api/stats.py` (mêmes règles, données du mock). */
|
||||
function buildStats(): EditionStats {
|
||||
const vols = state.volunteers;
|
||||
const round = (n: number) => Math.round(n * 100) / 100;
|
||||
const addDay = (d: string) =>
|
||||
new Date(Date.UTC(+d.slice(0, 4), +d.slice(5, 7) - 1, +d.slice(8, 10) + 1))
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
const perDay = new Map<string, number>();
|
||||
for (const v of vols) perDay.set(v.created_at.slice(0, 10), (perDay.get(v.created_at.slice(0, 10)) ?? 0) + 1);
|
||||
const byDay: EditionStats['registrations']['by_day'] = [];
|
||||
const days = [...perDay.keys()].sort();
|
||||
if (days.length) {
|
||||
let cumulative = 0;
|
||||
for (let d = days[0]; d <= days[days.length - 1]; d = addDay(d)) {
|
||||
cumulative += perDay.get(d) ?? 0;
|
||||
byDay.push({ date: d, count: perDay.get(d) ?? 0, cumulative });
|
||||
}
|
||||
}
|
||||
const latest = days.length ? days[days.length - 1] : '';
|
||||
const weekAgo = latest
|
||||
? new Date(Date.parse(`${latest}T00:00:00Z`) - 6 * 86400000).toISOString().slice(0, 10)
|
||||
: '';
|
||||
|
||||
const paid = vols.filter((v) => v.fee_payment_status === 'PAID');
|
||||
const byLabel = new Map<string, VolunteerRead[]>();
|
||||
for (const v of paid) byLabel.set(v.fee_payment_label ?? '', [...(byLabel.get(v.fee_payment_label ?? '') ?? []), v]);
|
||||
const mecenat = paid.filter((v) => v.mecenat_address);
|
||||
const sum = (list: VolunteerRead[]) => round(list.reduce((t, v) => t + (v.fee_amount ?? 0), 0));
|
||||
|
||||
const boundary = dayBoundaryFromDayParts(state.dayParts);
|
||||
type Row = { shifts: number; min: number; ideal: number; assigned: number };
|
||||
const rows = new Map<string, Row>();
|
||||
const row = (d: string) => {
|
||||
if (!rows.has(d)) rows.set(d, { shifts: 0, min: 0, ideal: 0, assigned: 0 });
|
||||
return rows.get(d) as Row;
|
||||
};
|
||||
const shiftsById = new Map(state.shifts.map((s) => [s.id, s]));
|
||||
for (const s of state.shifts) {
|
||||
const r = row(festivalDay(s.starts_at, boundary));
|
||||
const h = hoursBetween(s.starts_at, s.ends_at);
|
||||
r.shifts += 1;
|
||||
r.min += h * s.min_staff;
|
||||
r.ideal += h * s.ideal_staff;
|
||||
}
|
||||
for (const a of state.assignments) {
|
||||
const s = shiftsById.get(a.shift_id);
|
||||
if (s) row(festivalDay(s.starts_at, boundary)).assigned += hoursBetween(s.starts_at, s.ends_at);
|
||||
}
|
||||
const assignedByShift = new Map<string, number>();
|
||||
for (const a of state.assignments) assignedByShift.set(a.shift_id, (assignedByShift.get(a.shift_id) ?? 0) + 1);
|
||||
|
||||
return {
|
||||
registrations: {
|
||||
total: vols.length,
|
||||
last_7_days: vols.filter((v) => v.created_at.slice(0, 10) >= weekAgo).length,
|
||||
by_day: byDay,
|
||||
},
|
||||
answers: {
|
||||
total: vols.length,
|
||||
email: vols.filter((v) => v.email).length,
|
||||
phone: vols.filter((v) => v.phone).length,
|
||||
photo: vols.filter((v) => v.has_photo).length,
|
||||
comment: vols.filter((v) => v.comment).length,
|
||||
companion_note: vols.filter((v) => v.companion_wish_note).length,
|
||||
available: vols.filter((v) => v.self_declared_available).length,
|
||||
position_wishes: vols.filter((v) => v.wished_position_ids.length).length,
|
||||
artist_wishes: vols.filter((v) => v.artist_ids.length).length,
|
||||
companion_wishes: vols.filter((v) => v.wished_companion_ids.length).length,
|
||||
},
|
||||
payments: {
|
||||
online: vols.filter((v) => v.fee_payment_status !== null).length,
|
||||
on_site: vols.filter((v) => v.fee_payment_status === null && v.willing_to_pay_fee).length,
|
||||
none: vols.filter((v) => v.fee_payment_status === null && !v.willing_to_pay_fee).length,
|
||||
pending_unfinished: [...state.pendingSubmissions.values()].filter((p) => p.promotedVolunteerId === null).length,
|
||||
total_collected: sum(paid),
|
||||
mecenat_count: mecenat.length,
|
||||
mecenat_total: sum(mecenat),
|
||||
by_option: [...byLabel.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([label, group]) => ({
|
||||
label,
|
||||
mecenat: group.some((v) => v.mecenat_address),
|
||||
count: group.length,
|
||||
amount_total: sum(group),
|
||||
})),
|
||||
},
|
||||
artist_wishes: [...state.artists]
|
||||
.sort((a, b) => a.performance_date.localeCompare(b.performance_date) || a.start_time.localeCompare(b.start_time))
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
date: a.performance_date,
|
||||
count: vols.filter((v) => v.artist_ids.includes(a.id)).length,
|
||||
})),
|
||||
needs: {
|
||||
by_day: [...rows.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, r]) => ({
|
||||
date,
|
||||
shifts: r.shifts,
|
||||
min_hours: round(r.min),
|
||||
ideal_hours: round(r.ideal),
|
||||
assigned_hours: round(r.assigned),
|
||||
})),
|
||||
by_position: [...state.positions]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((p) => {
|
||||
const own = state.shifts.filter((s) => s.position_id === p.id);
|
||||
return {
|
||||
position_id: p.id,
|
||||
name: p.name,
|
||||
shifts: own.length,
|
||||
min_slots: own.reduce((t, s) => t + s.min_staff, 0),
|
||||
ideal_slots: own.reduce((t, s) => t + s.ideal_staff, 0),
|
||||
assigned: own.reduce((t, s) => t + (assignedByShift.get(s.id) ?? 0), 0),
|
||||
filled: own.reduce((t, s) => t + Math.min(assignedByShift.get(s.id) ?? 0, s.ideal_staff), 0),
|
||||
wishes: vols.filter((v) => v.wished_position_ids.includes(p.id)).length,
|
||||
};
|
||||
}),
|
||||
volunteers_with_assignment: new Set(state.assignments.map((a) => a.volunteer_id)).size,
|
||||
},
|
||||
attendance: buildAttendance(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Même calcul que `app/api/stats.py:_attendance`. */
|
||||
function buildAttendance(): EditionStats['attendance'] {
|
||||
const now = nowNaiveMinutes();
|
||||
const empty = () => ({ assigned: 0, present: 0, absent: 0, unmarked: 0 });
|
||||
const count = (c: ReturnType<typeof empty>, a: AssignmentRead) => {
|
||||
c.assigned += 1;
|
||||
if (a.attendance === 'PRESENT') c.present += 1;
|
||||
else if (a.attendance === 'ABSENT') c.absent += 1;
|
||||
else c.unmarked += 1;
|
||||
};
|
||||
const past = empty();
|
||||
const live = empty();
|
||||
const rows = new Map(
|
||||
state.positions.map((p) => [
|
||||
p.id,
|
||||
{ position_id: p.id, name: p.name, past: empty(), live: empty(), live_shifts: 0 },
|
||||
]),
|
||||
);
|
||||
const phase = new Map<string, 'past' | 'live'>();
|
||||
for (const s of state.shifts) {
|
||||
if (naiveMinutes(s.ends_at) <= now) phase.set(s.id, 'past');
|
||||
else if (naiveMinutes(s.starts_at) <= now) {
|
||||
phase.set(s.id, 'live');
|
||||
const row = rows.get(s.position_id);
|
||||
if (row) row.live_shifts += 1;
|
||||
}
|
||||
}
|
||||
for (const a of state.assignments) {
|
||||
const when = phase.get(a.shift_id);
|
||||
const shift = state.shifts.find((s) => s.id === a.shift_id);
|
||||
const row = shift ? rows.get(shift.position_id) : undefined;
|
||||
if (!when || !row) continue;
|
||||
count(when === 'past' ? past : live, a);
|
||||
count(when === 'past' ? row.past : row.live, a);
|
||||
}
|
||||
return {
|
||||
as_of: touch(),
|
||||
past,
|
||||
live,
|
||||
live_shifts: [...rows.values()].reduce((n, r) => n + r.live_shifts, 0),
|
||||
by_position: [...rows.values()]
|
||||
.filter((r) => r.past.assigned || r.live.assigned || r.live_shifts)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'fr')),
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatrix(): MatrixResponse {
|
||||
const counts = new Map<string, number>();
|
||||
for (const a of state.assignments) {
|
||||
@@ -573,8 +683,6 @@ function buildMatrix(): MatrixResponse {
|
||||
const SOLVER_URGENT_BASE = 1000;
|
||||
const SOLVER_NORMAL_BASE = 20;
|
||||
const SOLVER_WISH_BONUS = 30;
|
||||
const SOLVER_AVAIL_PENALTY_PER_HALF_HOUR = 5;
|
||||
const SOLVER_REST_PENALTY = 60;
|
||||
const SOLVER_ARTIST_CONFLICT_PENALTY = 80;
|
||||
const SOLVER_FAIRNESS_PENALTY_PER_HOUR = 2;
|
||||
// Le bénévole a déjà tenu le créneau immédiatement précédent (même jour,
|
||||
@@ -585,6 +693,8 @@ const SOLVER_CHAIN_PENALTY = 40;
|
||||
// traité comme réciproque : si A souhaite B, B en profite aussi (voir
|
||||
// `buildEffectiveCompanions`).
|
||||
const SOLVER_COMPANION_BONUS_PER_MATCH = 25;
|
||||
// Déjà affecté à un autre poste au même moment (cf. `_ALREADY_BUSY_PENALTY`).
|
||||
const SOLVER_ALREADY_BUSY_PENALTY = 300;
|
||||
|
||||
type TimeWindow = { starts_at: string; ends_at: string };
|
||||
|
||||
@@ -687,24 +797,7 @@ function solverScore(
|
||||
if (effectiveCompanionIds.has(id)) companionsPresent += 1;
|
||||
}
|
||||
score += SOLVER_COMPANION_BONUS_PER_MATCH * companionsPresent;
|
||||
|
||||
const windows = state.availability.get(volunteer.id) ?? [];
|
||||
const missed = uncoveredMinutes(windows, shift.starts_at, shift.ends_at);
|
||||
score -= Math.floor(missed / 30) * SOLVER_AVAIL_PENALTY_PER_HALF_HOUR;
|
||||
|
||||
for (const other of existing) {
|
||||
if (overlaps(shift, other)) continue;
|
||||
let gap: number | null = null;
|
||||
if (naiveMinutes(other.ends_at) <= naiveMinutes(shift.starts_at)) {
|
||||
gap = naiveMinutes(shift.starts_at) - naiveMinutes(other.ends_at);
|
||||
} else if (naiveMinutes(shift.ends_at) <= naiveMinutes(other.starts_at)) {
|
||||
gap = naiveMinutes(other.starts_at) - naiveMinutes(shift.ends_at);
|
||||
}
|
||||
if (gap !== null && gap < EIGHT_HOURS_MIN) {
|
||||
score -= SOLVER_REST_PENALTY;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (existing.some((other) => overlaps(shift, other))) score -= SOLVER_ALREADY_BUSY_PENALTY;
|
||||
|
||||
const existingMinutes = existing.reduce(
|
||||
(sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)),
|
||||
@@ -747,13 +840,8 @@ function runSolver(): SolverRunResult {
|
||||
if (gap <= 0) continue;
|
||||
const alreadyAssignedIds = assignedVolunteerIds(shift.id);
|
||||
const picked = state.volunteers
|
||||
.filter((v) => {
|
||||
const busy = [
|
||||
...(existingByVolunteer.get(v.id) ?? []),
|
||||
...(takenByVolunteer.get(v.id) ?? []),
|
||||
];
|
||||
return !busy.some((other) => overlaps(shift, other));
|
||||
})
|
||||
.filter((v) => !alreadyAssignedIds.has(v.id))
|
||||
.filter((v) => !(takenByVolunteer.get(v.id) ?? []).some((other) => overlaps(shift, other)))
|
||||
.map((v) => {
|
||||
const existing = existingByVolunteer.get(v.id) ?? [];
|
||||
const predecessorIds = predecessorByShift.get(shift.id);
|
||||
@@ -796,6 +884,14 @@ function runSolver(): SolverRunResult {
|
||||
};
|
||||
}
|
||||
|
||||
/** Message (organisation, poste) comme l'API : bords retirés, vide = plus
|
||||
* de message, 2000 caractères au plus. */
|
||||
function checkMessage(value: string | null | undefined): string | null {
|
||||
const text = (value ?? '').trim() || null;
|
||||
if (text && text.length > MESSAGE_MAX_LENGTH) throw new ApiError(422, 'error.generic');
|
||||
return text;
|
||||
}
|
||||
|
||||
export function createMockApi(): PlanningApi {
|
||||
return {
|
||||
editions: {
|
||||
@@ -807,6 +903,13 @@ export function createMockApi(): PlanningApi {
|
||||
if (body.start_date) next.start_date = body.start_date;
|
||||
if (body.end_date) next.end_date = body.end_date;
|
||||
if ('retention_days' in body) next.retention_days = body.retention_days ?? null;
|
||||
if ('volunteer_message' in body) {
|
||||
const text = checkMessage(body.volunteer_message);
|
||||
if (text !== next.volunteer_message) {
|
||||
next.volunteer_message = text;
|
||||
next.volunteer_message_updated_at = text ? touch() : null;
|
||||
}
|
||||
}
|
||||
if (next.end_date < next.start_date) {
|
||||
throw new ApiError(422, 'edition.dates_inconsistent');
|
||||
}
|
||||
@@ -957,6 +1060,8 @@ export function createMockApi(): PlanningApi {
|
||||
wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []),
|
||||
has_photo: false,
|
||||
companion_wish_note: null,
|
||||
badge_text: null,
|
||||
badge_count: 1,
|
||||
self_declared_available: false,
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
@@ -967,11 +1072,12 @@ export function createMockApi(): PlanningApi {
|
||||
mecenat_country: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
fee_transaction_code: null,
|
||||
fee_transaction_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state.volunteers.push(volunteer);
|
||||
state.availability.set(volunteer.id, []);
|
||||
return delay(volunteer);
|
||||
},
|
||||
update: (_edition, id, body) => {
|
||||
@@ -1000,6 +1106,15 @@ export function createMockApi(): PlanningApi {
|
||||
...('companion_wish_note' in body
|
||||
? { companion_wish_note: body.companion_wish_note ?? null }
|
||||
: {}),
|
||||
...('badge_text' in body ? { badge_text: body.badge_text ?? null } : {}),
|
||||
...(body.badge_count != null
|
||||
? (() => {
|
||||
if (!Number.isInteger(body.badge_count) || body.badge_count < 1 || body.badge_count > 100) {
|
||||
throw new ApiError(422, 'error.generic');
|
||||
}
|
||||
return { badge_count: body.badge_count };
|
||||
})()
|
||||
: {}),
|
||||
...('self_declared_available' in body && body.self_declared_available != null
|
||||
? { self_declared_available: body.self_declared_available }
|
||||
: {}),
|
||||
@@ -1013,7 +1128,6 @@ export function createMockApi(): PlanningApi {
|
||||
remove: (_edition, id) => {
|
||||
state.volunteers = state.volunteers.filter((x) => x.id !== id);
|
||||
state.assignments = state.assignments.filter((a) => a.volunteer_id !== id);
|
||||
state.availability.delete(id);
|
||||
state.photos.delete(id);
|
||||
// RGPD : effacement complet, y compris le compte de connexion éventuel
|
||||
// et les vœux de compagnon dont il était la cible.
|
||||
@@ -1028,6 +1142,21 @@ export function createMockApi(): PlanningApi {
|
||||
if (!dataUrl) return null;
|
||||
return (await fetch(dataUrl)).blob();
|
||||
},
|
||||
setPhoto: async (_edition, id, file) => {
|
||||
const v = state.volunteers.find((x) => x.id === id) ?? notFound();
|
||||
if (!['image/png', 'image/jpeg'].includes(file.type)) {
|
||||
throw new ApiError(422, 'form.photo_type_not_supported');
|
||||
}
|
||||
state.photos.set(id, await fileToDataUrl(file));
|
||||
Object.assign(v, { has_photo: true, updated_at: touch() });
|
||||
return delay(v);
|
||||
},
|
||||
removePhoto: (_edition, id) => {
|
||||
const v = state.volunteers.find((x) => x.id === id) ?? notFound();
|
||||
state.photos.delete(id);
|
||||
Object.assign(v, { has_photo: false, updated_at: touch() });
|
||||
return delay(v);
|
||||
},
|
||||
exportXlsx: () => delay(MOCK_XLSX_BLOB()),
|
||||
exportMecenatReceipts: () => {
|
||||
const paid = state.volunteers.some(
|
||||
@@ -1058,6 +1187,8 @@ export function createMockApi(): PlanningApi {
|
||||
position_location: position?.location ?? null,
|
||||
referent_name: referent ? `${referent.first_name} ${referent.last_name}` : null,
|
||||
referent_phone: referent?.phone ?? null,
|
||||
position_message: position?.message ?? null,
|
||||
position_message_updated_at: position?.message_updated_at ?? null,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.starts_at.localeCompare(b.starts_at));
|
||||
@@ -1109,56 +1240,15 @@ export function createMockApi(): PlanningApi {
|
||||
account.updated_at = touch();
|
||||
return delay(account);
|
||||
},
|
||||
// Pas de lien permanent en démo (liens magiques simulés) : rien à
|
||||
// révoquer, le compte est renvoyé tel quel.
|
||||
resetLoginLink: (_edition, id) => delay(state.accounts.find((a) => a.id === id) ?? notFound()),
|
||||
remove: (_edition, id) => {
|
||||
state.accounts = state.accounts.filter((a) => a.id !== id);
|
||||
return delay(undefined);
|
||||
},
|
||||
},
|
||||
|
||||
availability: {
|
||||
list: (_edition, volunteerId) => {
|
||||
if (!state.volunteers.some((v) => v.id === volunteerId)) return notFound();
|
||||
return delay(sortWindows(state.availability.get(volunteerId) ?? []));
|
||||
},
|
||||
listEdition: () =>
|
||||
delay(
|
||||
sortWindows(
|
||||
[...state.availability.values()].flat().filter((w) =>
|
||||
state.volunteers.some((v) => v.id === w.volunteer_id),
|
||||
),
|
||||
),
|
||||
),
|
||||
replace: (_edition, volunteerId, body: AvailabilitySet) => {
|
||||
if (!state.volunteers.some((v) => v.id === volunteerId)) return notFound();
|
||||
const seen = new Set<string>();
|
||||
const windows: AvailabilityRead[] = [];
|
||||
for (const w of body.windows) {
|
||||
if (w.date < state.edition.start_date || w.date > state.edition.end_date) {
|
||||
throw new ApiError(422, 'availability.date_outside_edition');
|
||||
}
|
||||
const start = hms(w.start_time);
|
||||
const end = hms(w.end_time);
|
||||
if (start === end) throw new ApiError(422, 'availability.empty_window');
|
||||
const key = `${w.date}|${start}|${end}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const now = touch();
|
||||
windows.push({
|
||||
id: `av${state.seq++}`,
|
||||
edition_id: state.edition.id,
|
||||
volunteer_id: volunteerId,
|
||||
date: w.date,
|
||||
start_time: start,
|
||||
end_time: end,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
state.availability.set(volunteerId, windows);
|
||||
return delay(sortWindows(windows));
|
||||
},
|
||||
},
|
||||
|
||||
imports: {
|
||||
analyze: async (_edition, file: File) => {
|
||||
const { headers, rows } = await readImportTable(file);
|
||||
@@ -1308,6 +1398,8 @@ export function createMockApi(): PlanningApi {
|
||||
wished_companion_ids: [],
|
||||
has_photo: false,
|
||||
companion_wish_note: null,
|
||||
badge_text: null,
|
||||
badge_count: 1,
|
||||
self_declared_available: false,
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
@@ -1318,6 +1410,8 @@ export function createMockApi(): PlanningApi {
|
||||
mecenat_country: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
fee_transaction_code: null,
|
||||
fee_transaction_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -1339,9 +1433,6 @@ export function createMockApi(): PlanningApi {
|
||||
|
||||
if (!mapping.dry_run) {
|
||||
state.volunteers = working;
|
||||
for (const v of working) {
|
||||
if (!state.availability.has(v.id)) state.availability.set(v.id, []);
|
||||
}
|
||||
}
|
||||
return delay(report);
|
||||
},
|
||||
@@ -1361,6 +1452,8 @@ export function createMockApi(): PlanningApi {
|
||||
location: body.location ?? null,
|
||||
category: body.category,
|
||||
lead_account_id: body.lead_account_id ?? null,
|
||||
message: null,
|
||||
message_updated_at: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -1372,6 +1465,13 @@ export function createMockApi(): PlanningApi {
|
||||
update: (_edition, id, body) => {
|
||||
const p = state.positions.find((x) => x.id === id) ?? notFound();
|
||||
if ('lead_account_id' in body) checkLeadAccount(body.lead_account_id);
|
||||
if ('message' in body) {
|
||||
const text = checkMessage(body.message);
|
||||
if (text !== p.message) {
|
||||
p.message = text;
|
||||
p.message_updated_at = text ? touch() : null;
|
||||
}
|
||||
}
|
||||
Object.assign(p, {
|
||||
...('name' in body ? { name: body.name } : {}),
|
||||
...('location' in body ? { location: body.location ?? null } : {}),
|
||||
@@ -1486,6 +1586,8 @@ export function createMockApi(): PlanningApi {
|
||||
shift_id: body.shift_id,
|
||||
source: body.source ?? 'MANUAL',
|
||||
locked: body.locked ?? false,
|
||||
attendance: null,
|
||||
attendance_marked_at: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -1501,6 +1603,13 @@ export function createMockApi(): PlanningApi {
|
||||
});
|
||||
return delay({ assignment: a, warnings: [] as Violation[] });
|
||||
},
|
||||
setAttendance: (_edition, id, body) => {
|
||||
const a = state.assignments.find((x) => x.id === id) ?? notFound();
|
||||
a.attendance = body.attendance;
|
||||
a.attendance_marked_at = body.attendance ? touch() : null;
|
||||
a.updated_at = touch();
|
||||
return delay({ ...a });
|
||||
},
|
||||
remove: (_edition, id) => {
|
||||
state.assignments = state.assignments.filter((x) => x.id !== id);
|
||||
return delay(undefined);
|
||||
@@ -1515,6 +1624,10 @@ export function createMockApi(): PlanningApi {
|
||||
get: () => delay(buildMatrix()),
|
||||
},
|
||||
|
||||
stats: {
|
||||
get: () => delay(buildStats()),
|
||||
},
|
||||
|
||||
badges: {
|
||||
getTemplate: (): Promise<BadgeTemplateRead> =>
|
||||
delay({
|
||||
@@ -1561,13 +1674,25 @@ export function createMockApi(): PlanningApi {
|
||||
return (await fetch(state.badgeTemplate.backImage)).blob();
|
||||
},
|
||||
setFields: (_edition, body: BadgeFieldsSet) => {
|
||||
// Un même type de champ peut exister une fois par face (recto/verso),
|
||||
// comme côté back-end.
|
||||
const seen = new Set<string>();
|
||||
const hexColor = /^#[0-9a-fA-F]{6}$/;
|
||||
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 key = `${f.face ?? 'FRONT'}:${f.field_type}`;
|
||||
if (seen.has(key)) throw new ApiError(422, 'badge.duplicate_field_type');
|
||||
seen.add(key);
|
||||
if (!hexColor.test(f.color) || (f.bg_color != null && !hexColor.test(f.bg_color))) {
|
||||
throw new ApiError(422, 'badge.invalid_color');
|
||||
}
|
||||
}
|
||||
const fields: BadgeFieldRead[] = body.fields.map((f) => ({
|
||||
...f,
|
||||
face: f.face ?? 'FRONT',
|
||||
bg_color: f.bg_color ?? null,
|
||||
auto_fit: f.auto_fit ?? true,
|
||||
photo_shape: f.photo_shape ?? 'RECT',
|
||||
z_order: f.z_order ?? 0,
|
||||
id: `bf${state.seq++}`,
|
||||
edition_id: state.edition.id,
|
||||
}));
|
||||
@@ -1775,6 +1900,8 @@ export function createMockApi(): PlanningApi {
|
||||
wished_companion_ids: [],
|
||||
has_photo: photo !== null,
|
||||
companion_wish_note: companionWishNote,
|
||||
badge_text: null,
|
||||
badge_count: 1,
|
||||
self_declared_available: fields.has('AVAILABLE') ? available : false,
|
||||
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'),
|
||||
fee_payment_label: null,
|
||||
@@ -1785,12 +1912,13 @@ export function createMockApi(): PlanningApi {
|
||||
mecenat_country: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
fee_transaction_code: null,
|
||||
fee_transaction_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
if (photo) state.photos.set(volunteer.id, photo);
|
||||
state.volunteers.push(volunteer);
|
||||
state.availability.set(volunteer.id, []);
|
||||
return delay<FormSubmissionResult>({
|
||||
volunteer_id: volunteer.id,
|
||||
pending_token: null,
|
||||
@@ -1927,6 +2055,25 @@ export function createMockApi(): PlanningApi {
|
||||
},
|
||||
|
||||
notifications: {
|
||||
// Pas de serveur SMTP en démo : rien n'est envoyé, comme
|
||||
// `EMAIL_ENABLED=false` côté serveur.
|
||||
sendTest: (_edition, body) => {
|
||||
if (!/^[^\s@]+@[^\s@]+$/.test(body.email.trim())) {
|
||||
throw new ApiError(422, 'notifications.invalid_email');
|
||||
}
|
||||
const assigned = new Set(state.assignments.map((a) => a.volunteer_id));
|
||||
const sorted = [...state.volunteers].sort((a, b) => a.last_name.localeCompare(b.last_name, 'fr'));
|
||||
const example =
|
||||
sorted.find((v) => v.id === body.volunteer_id) ?? sorted.find((v) => assigned.has(v.id)) ?? sorted[0];
|
||||
return delay({
|
||||
status: 'disabled' as const,
|
||||
detail: null,
|
||||
smtp_host: 'localhost:25',
|
||||
smtp_from: 'noreply@festival.local',
|
||||
example_volunteer_id: example?.id ?? null,
|
||||
example_volunteer_name: example ? `${example.first_name} ${example.last_name}`.trim() : '',
|
||||
});
|
||||
},
|
||||
sendPlanning: (_edition, body: PlanningNotificationRequest) => {
|
||||
const ids = body.volunteer_ids ?? null;
|
||||
const targets = state.volunteers.filter((v) => ids === null || ids.includes(v.id));
|
||||
|
||||
+191
-32
@@ -18,11 +18,7 @@ export type AssignmentSource = 'SOLVER' | 'MANUAL';
|
||||
export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER';
|
||||
export type AuthMethod = 'password' | 'magic_link';
|
||||
|
||||
export type ViolationCode =
|
||||
| 'SHIFT_OVERLAP'
|
||||
| 'OUTSIDE_AVAILABILITY'
|
||||
| 'DAILY_HOURS_EXCEEDED'
|
||||
| 'INSUFFICIENT_REST';
|
||||
export type ViolationCode = 'SHIFT_OVERLAP';
|
||||
|
||||
export type ViolationSeverity = 'BLOCKING' | 'WARNING';
|
||||
|
||||
@@ -43,16 +39,25 @@ export interface EditionRead {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
retention_days: number | null;
|
||||
/** Message de l'organisation, le même pour tous les bénévoles (écran
|
||||
* bénévole), et la date de sa dernière modification. */
|
||||
volunteer_message: string | null;
|
||||
volunteer_message_updated_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Longueur maximale d'un message (organisation, poste) — comme l'API. */
|
||||
export const MESSAGE_MAX_LENGTH = 2000;
|
||||
|
||||
export interface EditionUpdate {
|
||||
name?: string | null;
|
||||
year?: number;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
retention_days?: number | null;
|
||||
/** Vide = plus de message. */
|
||||
volunteer_message?: string | null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
@@ -111,6 +116,11 @@ export interface VolunteerRead {
|
||||
/** Renseignés par le formulaire public d'inscription (voir schema forms). */
|
||||
has_photo: boolean;
|
||||
companion_wish_note: string | null;
|
||||
/** Texte libre imprimé sur le badge (champ `FREE_TEXT`), saisi par la
|
||||
* coordination. */
|
||||
badge_text: string | null;
|
||||
/** Exemplaires de son badge imprimés sur les planches (1 par défaut). */
|
||||
badge_count: number;
|
||||
self_declared_available: boolean;
|
||||
willing_to_pay_fee: boolean;
|
||||
/** Paiement de la cotisation via SumUp (voir schema forms) —
|
||||
@@ -121,6 +131,12 @@ export interface VolunteerRead {
|
||||
fee_amount: number | null;
|
||||
fee_payment_status: FeePaymentStatus | null;
|
||||
fee_paid_at: string | null;
|
||||
/** Transaction SumUp qui a réglé la cotisation : `fee_transaction_code` est
|
||||
* le code affiché dans le tableau de bord SumUp (ex. `TEENSK4W2K`),
|
||||
* `fee_transaction_id` l'identifiant technique. `null` tant que non payée
|
||||
* (ou payée avant que l'appli ne l'enregistre). */
|
||||
fee_transaction_code: string | null;
|
||||
fee_transaction_id: string | null;
|
||||
/** Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un type
|
||||
* de paiement « mécénat ». */
|
||||
mecenat_address: string | null;
|
||||
@@ -155,6 +171,9 @@ export interface VolunteerUpdate {
|
||||
wished_position_ids?: string[] | null;
|
||||
wished_companion_ids?: string[] | null;
|
||||
companion_wish_note?: string | null;
|
||||
badge_text?: string | null;
|
||||
/** 1 à 100. */
|
||||
badge_count?: number | null;
|
||||
self_declared_available?: boolean | null;
|
||||
willing_to_pay_fee?: boolean | null;
|
||||
}
|
||||
@@ -175,32 +194,9 @@ export interface VolunteerPlanningEntry {
|
||||
* rattaché à une fiche bénévole (pas de téléphone à donner alors). */
|
||||
referent_name: string | null;
|
||||
referent_phone: string | null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Availability (disponibilités déclarées par le bénévole) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export interface AvailabilityWindowInput {
|
||||
/** Date ISO (YYYY-MM-DD). */
|
||||
date: string;
|
||||
/** Heure « HH:MM » ou « HH:MM:SS ». end <= start ⇒ se termine le lendemain. */
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
}
|
||||
|
||||
export interface AvailabilitySet {
|
||||
windows: AvailabilityWindowInput[];
|
||||
}
|
||||
|
||||
export interface AvailabilityRead {
|
||||
id: string;
|
||||
edition_id: string;
|
||||
volunteer_id: string;
|
||||
date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/** Message du poste (voir `PositionRead.message`). */
|
||||
position_message: string | null;
|
||||
position_message_updated_at: string | null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
@@ -306,6 +302,10 @@ export interface PositionRead {
|
||||
location: string | null;
|
||||
category: PositionCategory;
|
||||
lead_account_id: string | null;
|
||||
/** Message du poste (consignes…), saisi par son référent, montré aux
|
||||
* bénévoles affectés au poste. */
|
||||
message: string | null;
|
||||
message_updated_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -322,6 +322,8 @@ export interface PositionUpdate {
|
||||
location?: string | null;
|
||||
category?: PositionCategory;
|
||||
lead_account_id?: string | null;
|
||||
/** Vide = plus de message. */
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
@@ -368,10 +370,20 @@ export interface AssignmentRead {
|
||||
shift_id: string;
|
||||
source: AssignmentSource;
|
||||
locked: boolean;
|
||||
/** Pointage du référent (`null` = pas encore pointé) et son heure. */
|
||||
attendance: AttendanceStatus | null;
|
||||
attendance_marked_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type AttendanceStatus = 'PRESENT' | 'ABSENT';
|
||||
|
||||
export interface AttendanceUpdate {
|
||||
/** `null` = annuler le pointage. */
|
||||
attendance: AttendanceStatus | null;
|
||||
}
|
||||
|
||||
export interface AssignmentCreate {
|
||||
volunteer_id: string;
|
||||
shift_id: string;
|
||||
@@ -443,11 +455,26 @@ export interface MatrixResponse {
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Badges (planches PDF) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export type BadgeFieldType = 'FULL_NAME' | 'POSITIONS' | 'CATEGORY' | 'ROLE';
|
||||
/** `PHOTO` : photo du bénévole ajustée au cadre ; `FREE_TEXT` : texte libre
|
||||
* de sa fiche (`VolunteerRead.badge_text`), saisi par la coordination. */
|
||||
export type BadgeFieldType =
|
||||
| 'FULL_NAME'
|
||||
| 'POSITIONS'
|
||||
| 'CATEGORY'
|
||||
| 'ROLE'
|
||||
| 'PHOTO'
|
||||
| 'FREE_TEXT';
|
||||
export type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT';
|
||||
/** Face du badge où se trouve un champ : chaque type de champ peut exister
|
||||
* une fois par face. */
|
||||
export type BadgeFace = 'FRONT' | 'BACK';
|
||||
/** Forme du cadre de la photo (`PHOTO`). */
|
||||
export type BadgePhotoShape = 'RECT' | 'OVAL';
|
||||
|
||||
export interface BadgeFieldIn {
|
||||
field_type: BadgeFieldType;
|
||||
/** Défaut `FRONT`. */
|
||||
face: BadgeFace;
|
||||
x_mm: number;
|
||||
y_mm: number;
|
||||
width_mm: number;
|
||||
@@ -456,6 +483,19 @@ export interface BadgeFieldIn {
|
||||
align: BadgeAlign;
|
||||
bold: boolean;
|
||||
color: string;
|
||||
/** Fond de la zone du champ (`#rrggbb`), `null` = transparent. */
|
||||
bg_color: string | null;
|
||||
/** Réduit la taille du texte (par pas de 0,5 pt, jusqu'à 4 pt) quand il
|
||||
* ne tient pas dans le cadre. */
|
||||
auto_fit: boolean;
|
||||
/** `PHOTO` seulement : rectangle ou ovale inscrit dans le cadre. */
|
||||
photo_shape: BadgePhotoShape;
|
||||
/** Ordre d'empilement sur la face : un champ recouvre ceux d'un
|
||||
* `z_order` inférieur (photo dessous à égalité). */
|
||||
z_order: number;
|
||||
/** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour
|
||||
* un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */
|
||||
text?: string | null;
|
||||
}
|
||||
|
||||
export interface BadgeFieldsSet {
|
||||
@@ -564,6 +604,28 @@ export interface PlanningNotificationResult {
|
||||
recipients: string[];
|
||||
}
|
||||
|
||||
/** Résultat de l'email de test (`POST .../notifications/test`). */
|
||||
export interface TestEmailResult {
|
||||
status: 'sent' | 'disabled' | 'connection_failed' | 'rejected';
|
||||
/** Message du serveur SMTP en cas d'échec. */
|
||||
detail: string | null;
|
||||
smtp_host: string;
|
||||
smtp_from: string;
|
||||
/** Bénévole dont les données ont rempli le modèle (`null` : exemple
|
||||
* fictif). */
|
||||
example_volunteer_id: string | null;
|
||||
example_volunteer_name: string;
|
||||
}
|
||||
|
||||
/** Email de test : le récapitulatif de planning en cours d'édition, rempli
|
||||
* avec les données d'un bénévole, envoyé à `email`. */
|
||||
export interface TestEmailRequest {
|
||||
email: string;
|
||||
subject?: string | null;
|
||||
body?: string | null;
|
||||
volunteer_id?: string | null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Formulaire public d'inscription //
|
||||
// --------------------------------------------------------------------------- //
|
||||
@@ -731,3 +793,100 @@ export interface PublicFormSubmission {
|
||||
artist_wish_ids?: string[];
|
||||
photo?: File | null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Statistiques d'une édition (`GET /editions/{id}/stats`, app/api/stats.py) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export interface StatsRegistrationDay {
|
||||
date: string;
|
||||
count: number;
|
||||
cumulative: number;
|
||||
}
|
||||
|
||||
export interface StatsAnswers {
|
||||
total: number;
|
||||
email: number;
|
||||
phone: number;
|
||||
photo: number;
|
||||
comment: number;
|
||||
companion_note: number;
|
||||
available: number;
|
||||
position_wishes: number;
|
||||
artist_wishes: number;
|
||||
companion_wishes: number;
|
||||
}
|
||||
|
||||
export interface StatsPaymentOption {
|
||||
label: string;
|
||||
mecenat: boolean;
|
||||
count: number;
|
||||
amount_total: number;
|
||||
}
|
||||
|
||||
export interface StatsNeedDay {
|
||||
date: string;
|
||||
shifts: number;
|
||||
min_hours: number;
|
||||
ideal_hours: number;
|
||||
assigned_hours: number;
|
||||
}
|
||||
|
||||
export interface StatsNeedPosition {
|
||||
position_id: string;
|
||||
name: string;
|
||||
shifts: number;
|
||||
min_slots: number;
|
||||
ideal_slots: number;
|
||||
assigned: number;
|
||||
/** Places pourvues, plafonnées créneau par créneau à l'effectif idéal. */
|
||||
filled: number;
|
||||
wishes: number;
|
||||
}
|
||||
|
||||
export interface EditionStats {
|
||||
registrations: { total: number; last_7_days: number; by_day: StatsRegistrationDay[] };
|
||||
answers: StatsAnswers;
|
||||
payments: {
|
||||
online: number;
|
||||
on_site: number;
|
||||
none: number;
|
||||
/** Formulaires remplis dont le paiement en ligne n'a jamais abouti. */
|
||||
pending_unfinished: number;
|
||||
total_collected: number;
|
||||
mecenat_count: number;
|
||||
mecenat_total: number;
|
||||
by_option: StatsPaymentOption[];
|
||||
};
|
||||
artist_wishes: Array<{ id: string; name: string; date: string; count: number }>;
|
||||
needs: {
|
||||
by_day: StatsNeedDay[];
|
||||
by_position: StatsNeedPosition[];
|
||||
volunteers_with_assignment: number;
|
||||
};
|
||||
attendance: StatsAttendance;
|
||||
}
|
||||
|
||||
/** Pointage d'un ensemble d'affectations : `assigned` = `present` +
|
||||
* `absent` + `unmarked`. */
|
||||
export interface StatsAttendanceCounts {
|
||||
assigned: number;
|
||||
present: number;
|
||||
absent: number;
|
||||
unmarked: number;
|
||||
}
|
||||
|
||||
/** Présents / absents pointés, sur les créneaux terminés (`past`) et en
|
||||
* cours (`live`) à `as_of` ; les créneaux à venir ne comptent pas. */
|
||||
export interface StatsAttendance {
|
||||
as_of: string;
|
||||
past: StatsAttendanceCounts;
|
||||
live: StatsAttendanceCounts;
|
||||
live_shifts: number;
|
||||
by_position: Array<{
|
||||
position_id: string;
|
||||
name: string;
|
||||
past: StatsAttendanceCounts;
|
||||
live: StatsAttendanceCounts;
|
||||
live_shifts: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
+23
-15
@@ -11,8 +11,6 @@ import type {
|
||||
AssignmentValidateRequest,
|
||||
AssignmentValidateResult,
|
||||
AssignmentWriteResult,
|
||||
AvailabilityRead,
|
||||
AvailabilitySet,
|
||||
BadgeFieldRead,
|
||||
BadgeFieldsSet,
|
||||
BadgeGenerateRequest,
|
||||
@@ -58,6 +56,10 @@ import type {
|
||||
VolunteerPlanningEntry,
|
||||
VolunteerRead,
|
||||
VolunteerUpdate,
|
||||
EditionStats,
|
||||
AttendanceUpdate,
|
||||
TestEmailRequest,
|
||||
TestEmailResult,
|
||||
} from './schema';
|
||||
|
||||
/**
|
||||
@@ -89,8 +91,13 @@ export interface PlanningApi {
|
||||
remove(editionId: string, id: string): Promise<void>;
|
||||
/** Planning enrichi (poste, lieu, référent) — écran bénévole. */
|
||||
planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>;
|
||||
/** Photo transmise via le formulaire public, `null` si absente. */
|
||||
/** Photo transmise via le formulaire public (ou ajoutée par la
|
||||
* coordination), `null` si absente. */
|
||||
photoBlob(editionId: string, id: string): Promise<Blob | null>;
|
||||
/** Ajoute ou remplace la photo (COORDINATION) — PNG/JPEG. */
|
||||
setPhoto(editionId: string, id: string, file: File): Promise<VolunteerRead>;
|
||||
/** Retire la photo (COORDINATION). */
|
||||
removePhoto(editionId: string, id: string): Promise<VolunteerRead>;
|
||||
/** Export Excel de la liste des bénévoles (COORDINATION). */
|
||||
exportXlsx(editionId: string): Promise<Blob>;
|
||||
/** Reçus fiscaux (cerfa) des bénévoles ayant payé en mécénat, un PDF
|
||||
@@ -104,18 +111,9 @@ export interface PlanningApi {
|
||||
/** Changement de rôle seulement (LEAD ↔ VOLUNTEER) — voir `AccountUpdate`. */
|
||||
update(editionId: string, id: string, body: AccountUpdate): Promise<AccountRead>;
|
||||
remove(editionId: string, id: string): Promise<void>;
|
||||
};
|
||||
availability: {
|
||||
/** Fenêtres déclarées par un bénévole. */
|
||||
list(editionId: string, volunteerId: string): Promise<AvailabilityRead[]>;
|
||||
/** Remplace l'intégralité des fenêtres d'un bénévole (COORDINATION). */
|
||||
replace(
|
||||
editionId: string,
|
||||
volunteerId: string,
|
||||
body: AvailabilitySet,
|
||||
): Promise<AvailabilityRead[]>;
|
||||
/** Toutes les fenêtres de l'édition (grille des besoins). */
|
||||
listEdition(editionId: string): Promise<AvailabilityRead[]>;
|
||||
/** Révoque le lien de connexion permanent (récapitulatif de planning) :
|
||||
* les liens déjà envoyés cessent de fonctionner. */
|
||||
resetLoginLink(editionId: string, id: string): Promise<AccountRead>;
|
||||
};
|
||||
imports: {
|
||||
/** Phase 1 : lit le fichier, propose un mapping des colonnes. */
|
||||
@@ -148,6 +146,7 @@ export interface PlanningApi {
|
||||
create(editionId: string, body: AssignmentCreate): Promise<AssignmentWriteResult>;
|
||||
update(editionId: string, id: string, body: AssignmentUpdate): Promise<AssignmentWriteResult>;
|
||||
remove(editionId: string, id: string): Promise<void>;
|
||||
setAttendance(editionId: string, id: string, body: AttendanceUpdate): Promise<AssignmentRead>;
|
||||
validate(
|
||||
editionId: string,
|
||||
body: AssignmentValidateRequest,
|
||||
@@ -156,6 +155,10 @@ export interface PlanningApi {
|
||||
matrix: {
|
||||
get(editionId: string): Promise<MatrixResponse>;
|
||||
};
|
||||
stats: {
|
||||
/** Statistiques de l'édition (COORDINATION uniquement). */
|
||||
get(editionId: string): Promise<EditionStats>;
|
||||
};
|
||||
badges: {
|
||||
/** Configuration du recto/verso (COORDINATION uniquement). */
|
||||
getTemplate(editionId: string): Promise<BadgeTemplateRead>;
|
||||
@@ -226,6 +229,8 @@ export interface PlanningApi {
|
||||
editionId: string,
|
||||
body: PlanningNotificationRequest,
|
||||
): Promise<PlanningNotificationResult>;
|
||||
/** Email de test, envoyé tout de suite (COORDINATION). */
|
||||
sendTest(editionId: string, body: TestEmailRequest): Promise<TestEmailResult>;
|
||||
};
|
||||
auth: {
|
||||
login(editionId: string, body: LoginRequest): Promise<TokenResponse>;
|
||||
@@ -242,6 +247,9 @@ export class ApiError extends Error {
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
public readonly violations: Violation[] = [],
|
||||
/** En-tête `X-Request-ID` de la réponse : référence à communiquer pour
|
||||
* retrouver l'erreur dans les logs du serveur (`app/request_logging.py`). */
|
||||
public readonly requestId: string | null = null,
|
||||
) {
|
||||
super(code);
|
||||
this.name = 'ApiError';
|
||||
|
||||
+140
-60
@@ -1,4 +1,5 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
|
||||
import { API_MODE } from '@/api';
|
||||
import { useVolunteers } from '@/api/hooks';
|
||||
@@ -17,73 +18,122 @@ export function AppShell() {
|
||||
const initials = account
|
||||
? account.email.slice(0, 2).toUpperCase()
|
||||
: '··';
|
||||
const hasNav = tabs.length > 1;
|
||||
|
||||
// Sous `lg:`, les onglets ne tiennent plus à côté du titre : ils passent
|
||||
// sur une seconde ligne (tablette) ou dans un menu déroulant (mobile),
|
||||
// refermé à chaque changement d'écran.
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { pathname } = useLocation();
|
||||
useEffect(() => setMenuOpen(false), [pathname]);
|
||||
const currentTab = tabs.find((tab) => tab.to === pathname);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="sticky top-0 z-20 flex items-stretch border-b-[3px] border-ink bg-yellow">
|
||||
<div className="flex flex-col justify-center border-r-[3px] border-ink px-4 py-2">
|
||||
<div className="font-display text-[14px] font-black tracking-tight">
|
||||
{t('header.title')}
|
||||
<header className="sticky top-0 z-30 border-b-[3px] border-ink bg-yellow">
|
||||
<div className="flex items-stretch">
|
||||
<div className="flex min-w-0 flex-col justify-center border-r-[3px] border-ink px-3 py-2 sm:px-4">
|
||||
<div className="truncate font-display text-[13px] font-black tracking-tight sm:text-[14px]">
|
||||
{t('header.title')}
|
||||
</div>
|
||||
{isCoordinator ? (
|
||||
<div className="truncate font-mono text-[9.5px] font-medium opacity-70">
|
||||
{t('header.meta', { count: volunteers.data?.length ?? 0 })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="truncate font-mono text-[9.5px] font-medium opacity-70">
|
||||
{account ? t(roleLabelKey(account.role)) : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isCoordinator ? (
|
||||
<div className="font-mono text-[9.5px] font-medium opacity-70">
|
||||
{t('header.meta', { count: volunteers.data?.length ?? 0 })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-mono text-[9.5px] font-medium opacity-70">
|
||||
{account ? t(roleLabelKey(account.role)) : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rien à choisir avec un seul écran (bénévole, référent) : la
|
||||
barre d'onglets n'a de sens que pour la coordination. */}
|
||||
<nav className={cx('flex overflow-x-auto', tabs.length <= 1 && 'hidden')}>
|
||||
{tabs.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
className={({ isActive }) =>
|
||||
cx(
|
||||
'relative flex min-w-[112px] flex-col items-start justify-center gap-[3px] border-r-2 border-ink px-4',
|
||||
'font-display text-[11.5px] font-bold uppercase tracking-wide',
|
||||
isActive ? 'bg-yellow' : 'hover:bg-yellow-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className="font-mono text-[8.5px] font-medium opacity-60">{tab.num}</span>
|
||||
<span>{t(tab.labelKey)}</span>
|
||||
{isActive ? (
|
||||
<span className="absolute inset-x-0 bottom-0 h-[5px] bg-ink" />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center gap-[10px] border-l-[3px] border-ink px-[14px]">
|
||||
{API_MODE === 'mock' ? (
|
||||
<div className="hidden border-2 border-ink bg-blue px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider sm:block">
|
||||
{t('header.mockMode')}
|
||||
</div>
|
||||
{/* Rien à choisir avec un seul écran (bénévole, référent) : la
|
||||
barre d'onglets n'a de sens que pour la coordination. */}
|
||||
{hasNav ? (
|
||||
<nav className="hidden min-w-0 overflow-x-auto lg:flex">
|
||||
{tabs.map((tab) => (
|
||||
<TabLink key={tab.to} tab={tab} />
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
<div
|
||||
className="flex h-[30px] w-[30px] items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold"
|
||||
title={account?.email}
|
||||
>
|
||||
{initials}
|
||||
|
||||
<div className="ml-auto flex flex-none items-center gap-2 border-l-[3px] border-ink px-2 sm:gap-[10px] sm:px-[14px]">
|
||||
{API_MODE === 'mock' ? (
|
||||
<div className="hidden border-2 border-ink bg-blue px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider xl:block">
|
||||
{t('header.mockMode')}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="hidden h-[30px] w-[30px] flex-none items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold sm:flex"
|
||||
title={account?.email}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="cursor-pointer border-2 border-ink bg-cream px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider hover:bg-orange sm:px-[10px]"
|
||||
>
|
||||
{t('auth.logout')}
|
||||
</button>
|
||||
{hasNav ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
aria-expanded={menuOpen}
|
||||
aria-label={t(menuOpen ? 'header.closeMenu' : 'header.openMenu')}
|
||||
className="flex h-[30px] w-[34px] flex-none cursor-pointer items-center justify-center border-2 border-ink bg-ink font-display text-[15px] font-black text-yellow md:hidden"
|
||||
>
|
||||
{menuOpen ? '✕' : '☰'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="cursor-pointer border-2 border-ink bg-cream px-[10px] py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider hover:bg-orange"
|
||||
>
|
||||
{t('auth.logout')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasNav ? (
|
||||
<>
|
||||
{/* Tablette : seconde ligne, onglets à parts égales. */}
|
||||
<nav className="hidden overflow-x-auto border-t-[3px] border-ink md:flex lg:hidden">
|
||||
{tabs.map((tab) => (
|
||||
<TabLink key={tab.to} tab={tab} fill />
|
||||
))}
|
||||
</nav>
|
||||
{/* Mobile : écran courant rappelé + menu déroulant. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
className="flex w-full cursor-pointer items-center gap-2 border-t-[3px] border-ink bg-ink px-3 py-[7px] text-left font-display text-[11px] font-extrabold uppercase tracking-wide text-cream md:hidden"
|
||||
>
|
||||
{currentTab ? (
|
||||
<span className="font-mono text-[8.5px] font-medium text-yellow">{currentTab.num}</span>
|
||||
) : null}
|
||||
<span className="flex-1 truncate">
|
||||
{currentTab ? t(currentTab.labelKey) : t('header.openMenu')}
|
||||
</span>
|
||||
<span aria-hidden className="text-yellow">{menuOpen ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{menuOpen ? (
|
||||
<nav className="flex flex-col border-t-[3px] border-ink bg-yellow md:hidden">
|
||||
{tabs.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
className={({ isActive }) =>
|
||||
cx(
|
||||
'flex items-center gap-3 border-b-2 border-ink px-4 py-3 last:border-b-0',
|
||||
'font-display text-[12px] font-bold uppercase tracking-wide',
|
||||
isActive ? 'bg-ink text-yellow' : 'hover:bg-yellow-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="font-mono text-[9px] font-medium opacity-60">{tab.num}</span>
|
||||
<span>{t(tab.labelKey)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<main className="flex flex-1 flex-col">
|
||||
@@ -92,3 +142,33 @@ export function AppShell() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabLink({
|
||||
tab,
|
||||
fill = false,
|
||||
}: {
|
||||
tab: { to: string; num: string; labelKey: string };
|
||||
fill?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<NavLink
|
||||
to={tab.to}
|
||||
className={({ isActive }) =>
|
||||
cx(
|
||||
'relative flex flex-none flex-col items-start justify-center gap-[3px] border-r-2 border-ink px-4 last:border-r-0',
|
||||
'font-display text-[11.5px] font-bold uppercase tracking-wide',
|
||||
fill ? 'min-w-[104px] flex-1 py-2' : 'min-w-[112px]',
|
||||
isActive ? 'bg-yellow' : 'hover:bg-yellow-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className="font-mono text-[8.5px] font-medium opacity-60">{tab.num}</span>
|
||||
<span className="whitespace-nowrap">{t(tab.labelKey)}</span>
|
||||
{isActive ? <span className="absolute inset-x-0 bottom-0 h-[5px] bg-ink" /> : null}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { GrillePage } from '@/pages/GrillePage';
|
||||
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
||||
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
|
||||
import { PublicSignupPage } from '@/pages/PublicSignupPage';
|
||||
import { StatsPage } from '@/pages/StatsPage';
|
||||
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
@@ -35,6 +36,7 @@ export const routes: RouteObject[] = [
|
||||
{ path: 'configuration', element: <ConfigPage /> },
|
||||
{ path: 'badges', element: <BadgesPage /> },
|
||||
{ path: 'inscriptions', element: <FormBuilderPage /> },
|
||||
{ path: 'statistiques', element: <StatsPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -11,6 +11,7 @@ export const ROUTE_ROLES: Record<string, Role[]> = {
|
||||
'/configuration': ['COORDINATOR'],
|
||||
'/badges': ['COORDINATOR'],
|
||||
'/inscriptions': ['COORDINATOR'],
|
||||
'/statistiques': ['COORDINATOR'],
|
||||
'/mobile-benevole': ['VOLUNTEER'],
|
||||
'/mobile-referent': ['LEAD'],
|
||||
};
|
||||
@@ -23,6 +24,7 @@ export const NAV_TABS: Array<{ to: string; num: string; labelKey: string }> = [
|
||||
{ to: '/configuration', num: '06', labelKey: 'tab.config' },
|
||||
{ to: '/badges', num: '07', labelKey: 'tab.badges' },
|
||||
{ to: '/inscriptions', num: '08', labelKey: 'tab.forms' },
|
||||
{ to: '/statistiques', num: '09', labelKey: 'tab.stats' },
|
||||
];
|
||||
|
||||
export function canAccess(pathname: string, role: Role): boolean {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ApiError } from '@/api';
|
||||
import { MESSAGE_MAX_LENGTH } from '@/api/schema';
|
||||
import { Btn } from '@/components/ui';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { messageDateLabel } from '@/lib/slotTimeline';
|
||||
import { useNowMinutes } from '@/lib/useNowMinutes';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
/**
|
||||
* Saisie d'un message montré aux bénévoles — celui de l'organisation
|
||||
* (coordination, `ConfigMessage`) ou celui d'un poste (référent,
|
||||
* `MobileLeadPage`). Enregistré sur demande ; vider le champ retire le
|
||||
* message.
|
||||
*/
|
||||
export function MessageEditor({
|
||||
value,
|
||||
updatedAt,
|
||||
placeholder,
|
||||
saving,
|
||||
error,
|
||||
onSave,
|
||||
dark = false,
|
||||
}: {
|
||||
value: string | null;
|
||||
updatedAt: string | null;
|
||||
placeholder: string;
|
||||
saving: boolean;
|
||||
error: unknown;
|
||||
onSave: (text: string) => Promise<unknown>;
|
||||
/** Sur fond sombre (en-tête de l'écran référent). */
|
||||
dark?: boolean;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value ?? '');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const now = useNowMinutes();
|
||||
// Le message enregistré change (autre poste choisi, autre appareil) :
|
||||
// le brouillon repart de lui.
|
||||
useEffect(() => setDraft(value ?? ''), [value]);
|
||||
|
||||
const dirty = draft.trim() !== (value ?? '');
|
||||
const tooLong = draft.length > MESSAGE_MAX_LENGTH;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[7px]">
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
setSaved(false);
|
||||
}}
|
||||
rows={4}
|
||||
placeholder={placeholder}
|
||||
className="w-full resize-y border-2 border-ink bg-cream p-2 font-display text-[14px] font-semibold leading-snug text-ink"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Btn
|
||||
disabled={!dirty || tooLong || saving}
|
||||
onClick={() => {
|
||||
void onSave(draft.trim()).then(() => setSaved(true), () => undefined);
|
||||
}}
|
||||
>
|
||||
{saving ? t('message.saving') : draft.trim() ? t('message.save') : t('message.remove')}
|
||||
</Btn>
|
||||
<span
|
||||
className={cx(
|
||||
'font-mono text-[10px] leading-snug',
|
||||
tooLong ? 'font-bold text-orange' : 'opacity-65',
|
||||
)}
|
||||
>
|
||||
{saved && !dirty
|
||||
? t('message.saved')
|
||||
: value && updatedAt && !dirty
|
||||
? t('message.updatedAt', { when: messageDateLabel(updatedAt, now) })
|
||||
: t('message.length', { count: draft.length, max: MESSAGE_MAX_LENGTH })}
|
||||
</span>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className={cx('font-mono text-[10.5px] font-bold', dark ? 'text-orange' : 'text-orange-dark')}>
|
||||
{error instanceof ApiError ? t(error.code) : t('error.generic')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { t } from '@/i18n';
|
||||
|
||||
/** Repère horizontal « MAINTENANT », à insérer dans une liste triée
|
||||
* chronologiquement (créneaux d'un bénévole, créneaux d'un poste…) au point
|
||||
* correspondant à l'heure réelle. Purement visuel. */
|
||||
export function NowLine() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1" aria-hidden="true">
|
||||
<div className="h-[3px] flex-1 bg-orange" />
|
||||
<div className="bg-orange px-[7px] py-[3px] font-mono text-[10px] font-bold text-white">
|
||||
{t('common.now')}
|
||||
</div>
|
||||
<div className="h-[3px] flex-1 bg-orange" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
// Graphiques de l'écran Statistiques — SVG à la main (aucune bibliothèque de
|
||||
// graphiques dans le projet). Règles suivies : marques fines (barres ≤ 24 px,
|
||||
// bout arrondi de 4 px côté valeur, carré côté ligne de base ; courbes de
|
||||
// 2 px), séparation par un vide de 2 px couleur de fond (jamais de bordure),
|
||||
// quadrillage en filet plein discret, un seul axe par graphique, texte
|
||||
// toujours à l'encre (jamais dans la couleur de la série), infobulle au
|
||||
// survol et au focus — qui ne fait que compléter : chaque graphique a sa vue
|
||||
// en tableau (`TableToggle`). Palette catégorielle vérifiée (daltonisme,
|
||||
// séparation, contraste) sur le fond `paper` : l'aqua est sous 3:1, d'où
|
||||
// libellés directs + tableau.
|
||||
|
||||
export const SERIES = {
|
||||
blue: '#2a78d6',
|
||||
orange: '#eb6834',
|
||||
aqua: '#1baf7a',
|
||||
/** « Rien / aucun » : gris de mise en retrait, jamais une série à part entière. */
|
||||
muted: '#c9c3b3',
|
||||
} as const;
|
||||
|
||||
const GRID = '#E0D8C4'; // sand-line : un cran au-dessus du fond paper
|
||||
const SURFACE = '#FFFDF7'; // paper
|
||||
const BAR_MAX_PX = 24;
|
||||
|
||||
const fr = (n: number, digits = 0) =>
|
||||
n.toLocaleString('fr-FR', { maximumFractionDigits: digits, minimumFractionDigits: 0 });
|
||||
|
||||
/** Graduations « propres » (0, 5, 10… / 0, 20, 40…) couvrant `max`. */
|
||||
function niceTicks(max: number, target = 4): number[] {
|
||||
if (max <= 0) return [0, 1];
|
||||
const raw = max / target;
|
||||
const pow = 10 ** Math.floor(Math.log10(raw));
|
||||
const step = [1, 2, 2.5, 5, 10].map((m) => m * pow).find((s) => s >= raw) ?? raw;
|
||||
const ticks: number[] = [];
|
||||
for (let v = 0; v <= max + step * 0.999; v += step) ticks.push(Math.round(v * 100) / 100);
|
||||
return ticks;
|
||||
}
|
||||
|
||||
/** Largeur réelle du conteneur (le SVG se dessine en pixels, pas en %). */
|
||||
function useWidth<T extends HTMLElement>(fallback = 640) {
|
||||
const ref = useRef<T>(null);
|
||||
const [width, setWidth] = useState(fallback);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||
const ro = new ResizeObserver(([entry]) => setWidth(Math.max(240, entry.contentRect.width)));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
return { ref, width };
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Infobulle (position: fixed, bornée à l'écran — même raison que //
|
||||
// lib/useTooltipPosition.ts : jamais coincée par un conteneur défilant) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
interface TipState {
|
||||
x: number;
|
||||
y: number;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
function useTip() {
|
||||
const [tip, setTip] = useState<TipState | null>(null);
|
||||
const show = (e: { clientX: number; clientY: number }, content: ReactNode) =>
|
||||
setTip({ x: e.clientX, y: e.clientY, content });
|
||||
const showAt = (el: Element, content: ReactNode) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
setTip({ x: r.left + r.width / 2, y: r.top, content });
|
||||
};
|
||||
return { tip, show, showAt, hide: () => setTip(null) };
|
||||
}
|
||||
|
||||
function Tooltip({ tip }: { tip: TipState | null }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState({ left: -9999, top: -9999 });
|
||||
useLayoutEffect(() => {
|
||||
if (!tip || !ref.current) return;
|
||||
const { width, height } = ref.current.getBoundingClientRect();
|
||||
const left = Math.min(Math.max(tip.x + 12, 8), window.innerWidth - width - 8);
|
||||
const top = tip.y - height - 12 < 8 ? tip.y + 16 : tip.y - height - 12;
|
||||
setPos({ left, top });
|
||||
}, [tip]);
|
||||
if (!tip) return null;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="tooltip"
|
||||
style={{ position: 'fixed', left: pos.left, top: pos.top, zIndex: 50 }}
|
||||
className="pointer-events-none border-2 border-ink bg-paper px-[9px] py-[6px] font-mono text-[11px] shadow-brutal-sm"
|
||||
>
|
||||
{tip.content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ligne d'infobulle : la valeur d'abord (forte), puis la série (avec un
|
||||
* court trait de sa couleur, pas une pastille pleine). */
|
||||
function TipRow({ color, label, value }: { color?: string; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-[6px] whitespace-nowrap">
|
||||
{color ? <span className="inline-block h-[2px] w-[10px]" style={{ background: color }} /> : null}
|
||||
<b>{value}</b>
|
||||
<span className="opacity-70">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Légende, vue tableau //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export function Legend({ items }: { items: Array<{ label: string; color: string; value?: string }> }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-[14px] gap-y-[4px] font-mono text-[10.5px]">
|
||||
{items.map((item) => (
|
||||
<span key={item.label} className="flex items-center gap-[6px]">
|
||||
<span className="inline-block h-[10px] w-[10px] rounded-[2px]" style={{ background: item.color }} />
|
||||
{item.label}
|
||||
{item.value ? <b>{item.value}</b> : null}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Équivalent tableau d'un graphique — toujours disponible, replié. */
|
||||
export function TableToggle({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer font-display text-[9.5px] font-extrabold uppercase tracking-wide opacity-70 hover:opacity-100">
|
||||
{label}
|
||||
</summary>
|
||||
<div className="mt-2 overflow-x-auto">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTable({
|
||||
head,
|
||||
rows,
|
||||
}: {
|
||||
head: string[];
|
||||
rows: Array<Array<ReactNode>>;
|
||||
}) {
|
||||
return (
|
||||
<table className="w-full border-collapse font-mono text-[11px]">
|
||||
<thead>
|
||||
<tr>
|
||||
{head.map((h, i) => (
|
||||
<th
|
||||
key={h}
|
||||
className={cx(
|
||||
'border-b-2 border-ink px-[6px] py-[4px] font-display text-[9.5px] font-extrabold uppercase tracking-wide',
|
||||
i === 0 ? 'text-left' : 'text-right',
|
||||
)}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, r) => (
|
||||
<tr key={r} className="border-b border-sand-line">
|
||||
{row.map((cell, c) => (
|
||||
<td
|
||||
key={c}
|
||||
className={cx('px-[6px] py-[4px]', c === 0 ? 'text-left' : 'text-right tabular-nums')}
|
||||
>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Courbe cumulée (une série : pas de légende, le titre la nomme) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export function CumulativeLineChart({
|
||||
points,
|
||||
dateLabel,
|
||||
valueLabel,
|
||||
dailyLabel,
|
||||
}: {
|
||||
points: Array<{ date: string; count: number; cumulative: number }>;
|
||||
dateLabel: (date: string) => string;
|
||||
valueLabel: string;
|
||||
dailyLabel: string;
|
||||
}) {
|
||||
const { ref, width } = useWidth<HTMLDivElement>();
|
||||
const { tip, show, hide } = useTip();
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const height = 220;
|
||||
const pad = { top: 16, right: 48, bottom: 28, left: 40 };
|
||||
const innerW = width - pad.left - pad.right;
|
||||
const innerH = height - pad.top - pad.bottom;
|
||||
const ticks = niceTicks(Math.max(...points.map((p) => p.cumulative), 1));
|
||||
const yMax = ticks[ticks.length - 1];
|
||||
const x = (i: number) => pad.left + (points.length <= 1 ? innerW / 2 : (i / (points.length - 1)) * innerW);
|
||||
const y = (v: number) => pad.top + innerH - (v / yMax) * innerH;
|
||||
const line = points.map((p, i) => `${i ? 'L' : 'M'}${x(i)},${y(p.cumulative)}`).join(' ');
|
||||
const area = `${line} L${x(points.length - 1)},${y(0)} L${x(0)},${y(0)} Z`;
|
||||
const last = points[points.length - 1];
|
||||
// Quelques dates seulement sous l'axe, réparties du début à la fin — leur
|
||||
// nombre suit la largeur (≈ une tous les 80 px), sinon elles se
|
||||
// chevauchent sur un téléphone.
|
||||
const slots = Math.max(2, Math.min(points.length, Math.floor(innerW / 80) + 1));
|
||||
const labelled = new Set(
|
||||
Array.from({ length: slots }, (_, k) =>
|
||||
Math.round((k * (points.length - 1)) / Math.max(1, slots - 1)),
|
||||
),
|
||||
);
|
||||
|
||||
const pick = (clientX: number, rect: DOMRect) => {
|
||||
const rel = clientX - rect.left - pad.left;
|
||||
return Math.min(points.length - 1, Math.max(0, Math.round((rel / innerW) * (points.length - 1))));
|
||||
};
|
||||
const tipContent = (i: number) => (
|
||||
<>
|
||||
<div className="mb-[2px] font-display text-[10px] font-extrabold">{dateLabel(points[i].date)}</div>
|
||||
<TipRow color={SERIES.blue} label={valueLabel} value={fr(points[i].cumulative)} />
|
||||
<TipRow label={dailyLabel} value={`+${fr(points[i].count)}`} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full min-w-0 overflow-hidden">
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
role="img"
|
||||
tabIndex={0}
|
||||
aria-label={`${valueLabel} : ${last ? fr(last.cumulative) : 0}`}
|
||||
onPointerMove={(e) => {
|
||||
const i = pick(e.clientX, e.currentTarget.getBoundingClientRect());
|
||||
setHover(i);
|
||||
show(e, tipContent(i));
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setHover(null);
|
||||
hide();
|
||||
}}
|
||||
onBlur={() => {
|
||||
setHover(null);
|
||||
hide();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||
const i = Math.min(points.length - 1, Math.max(0, (hover ?? points.length - 1) + (e.key === 'ArrowLeft' ? -1 : 1)));
|
||||
setHover(i);
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
show({ clientX: rect.left + x(i), clientY: rect.top + y(points[i].cumulative) }, tipContent(i));
|
||||
}}
|
||||
className="block outline-none focus-visible:outline-2 focus-visible:outline-ink"
|
||||
>
|
||||
{ticks.map((tk) => (
|
||||
<g key={tk}>
|
||||
<line x1={pad.left} x2={width - pad.right} y1={y(tk)} y2={y(tk)} stroke={GRID} strokeWidth={1} />
|
||||
<text x={pad.left - 6} y={y(tk) + 3} textAnchor="end" className="fill-ink font-mono text-[10px] opacity-60">
|
||||
{fr(tk)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{points.map((p, i) =>
|
||||
labelled.has(i) ? (
|
||||
<text
|
||||
key={p.date}
|
||||
x={x(i)}
|
||||
y={height - 8}
|
||||
textAnchor={i === 0 ? 'start' : i === points.length - 1 ? 'end' : 'middle'}
|
||||
className="fill-ink font-mono text-[10px] opacity-60"
|
||||
>
|
||||
{dateLabel(p.date)}
|
||||
</text>
|
||||
) : null,
|
||||
)}
|
||||
<path d={area} fill={SERIES.blue} fillOpacity={0.1} />
|
||||
<path d={line} fill="none" stroke={SERIES.blue} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||
{hover !== null ? (
|
||||
<>
|
||||
<line x1={x(hover)} x2={x(hover)} y1={pad.top} y2={pad.top + innerH} stroke="#14110E" strokeOpacity={0.35} strokeWidth={1} />
|
||||
<circle cx={x(hover)} cy={y(points[hover].cumulative)} r={4} fill={SERIES.blue} stroke={SURFACE} strokeWidth={2} />
|
||||
</>
|
||||
) : null}
|
||||
{last ? (
|
||||
<>
|
||||
<circle cx={x(points.length - 1)} cy={y(last.cumulative)} r={4} fill={SERIES.blue} stroke={SURFACE} strokeWidth={2} />
|
||||
<text x={x(points.length - 1) + 8} y={y(last.cumulative) + 4} className="fill-ink font-mono text-[11px] font-bold">
|
||||
{fr(last.cumulative)}
|
||||
</text>
|
||||
</>
|
||||
) : null}
|
||||
</svg>
|
||||
<Tooltip tip={tip} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Barres horizontales (une série, une couleur pour toutes les barres) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export function BarList({
|
||||
rows,
|
||||
max,
|
||||
color = SERIES.blue,
|
||||
}: {
|
||||
rows: Array<{ key: string; label: string; value: number; valueLabel: string; tip?: string }>;
|
||||
/** Échelle commune (ex. 100 pour des pourcentages) — défaut : la plus grande valeur. */
|
||||
max?: number;
|
||||
color?: string;
|
||||
}) {
|
||||
const { tip, show, showAt, hide } = useTip();
|
||||
const scale = max ?? Math.max(...rows.map((r) => r.value), 1);
|
||||
return (
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
{rows.map((r) => (
|
||||
<div
|
||||
key={r.key}
|
||||
tabIndex={0}
|
||||
onPointerMove={(e) => show(e, <TipRow color={color} label={r.label} value={r.tip ?? r.valueLabel} />)}
|
||||
onPointerLeave={hide}
|
||||
onFocus={(e) => showAt(e.currentTarget, <TipRow color={color} label={r.label} value={r.tip ?? r.valueLabel} />)}
|
||||
onBlur={hide}
|
||||
className="grid items-center gap-[8px] outline-none hover:bg-cream focus-visible:bg-cream"
|
||||
style={{ gridTemplateColumns: 'minmax(90px, 34%) 1fr' }}
|
||||
>
|
||||
<span className="truncate font-mono text-[11px]" title={r.label}>
|
||||
{r.label}
|
||||
</span>
|
||||
<span className="flex items-center gap-[6px]">
|
||||
<span
|
||||
className="block h-[14px] rounded-r-[4px]"
|
||||
style={{
|
||||
width: `${Math.max(r.value > 0 ? 1.5 : 0, (r.value / scale) * 82)}%`,
|
||||
background: color,
|
||||
}}
|
||||
/>
|
||||
<span className="whitespace-nowrap font-mono text-[10.5px] font-bold">{r.valueLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<Tooltip tip={tip} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Colonnes groupées (plusieurs séries, même unité, un seul axe) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export function GroupedColumns({
|
||||
groups,
|
||||
series,
|
||||
unit,
|
||||
}: {
|
||||
groups: Array<{ key: string; label: string; values: number[] }>;
|
||||
series: Array<{ label: string; color: string }>;
|
||||
unit: string;
|
||||
}) {
|
||||
const { ref, width } = useWidth<HTMLDivElement>();
|
||||
const { tip, show, hide, showAt } = useTip();
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const height = 240;
|
||||
const pad = { top: 18, right: 8, bottom: 30, left: 44 };
|
||||
const innerW = width - pad.left - pad.right;
|
||||
const innerH = height - pad.top - pad.bottom;
|
||||
const ticks = niceTicks(Math.max(...groups.flatMap((g) => g.values), 1));
|
||||
const yMax = ticks[ticks.length - 1];
|
||||
const band = innerW / Math.max(groups.length, 1);
|
||||
const barW = Math.min(BAR_MAX_PX, (band * 0.7 - 2 * (series.length - 1)) / series.length);
|
||||
const groupW = barW * series.length + 2 * (series.length - 1);
|
||||
const y = (v: number) => pad.top + innerH - (v / yMax) * innerH;
|
||||
const content = (g: (typeof groups)[number]) => (
|
||||
<>
|
||||
<div className="mb-[2px] font-display text-[10px] font-extrabold">{g.label}</div>
|
||||
{series.map((s, i) => (
|
||||
<TipRow key={s.label} color={s.color} label={s.label} value={`${fr(g.values[i], 1)} ${unit}`} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
// Barre au bout arrondi côté valeur (haut), carré côté ligne de base.
|
||||
const bar = (x0: number, v: number) => {
|
||||
const top = y(v);
|
||||
const h = y(0) - top;
|
||||
if (h <= 0) return '';
|
||||
const r = Math.min(4, h, barW / 2);
|
||||
return `M${x0},${y(0)} V${top + r} Q${x0},${top} ${x0 + r},${top} H${x0 + barW - r} Q${x0 + barW},${top} ${x0 + barW},${top + r} V${y(0)} Z`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full min-w-0 overflow-hidden">
|
||||
<svg width={width} height={height} role="img" aria-label={series.map((s) => s.label).join(', ')} className="block">
|
||||
{ticks.map((tk) => (
|
||||
<g key={tk}>
|
||||
<line x1={pad.left} x2={width - pad.right} y1={y(tk)} y2={y(tk)} stroke={GRID} strokeWidth={1} />
|
||||
<text x={pad.left - 6} y={y(tk) + 3} textAnchor="end" className="fill-ink font-mono text-[10px] opacity-60">
|
||||
{fr(tk)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{groups.map((g, gi) => {
|
||||
const x0 = pad.left + gi * band + (band - groupW) / 2;
|
||||
return (
|
||||
<g
|
||||
key={g.key}
|
||||
tabIndex={0}
|
||||
onPointerMove={(e) => {
|
||||
setHover(gi);
|
||||
show(e, content(g));
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setHover(null);
|
||||
hide();
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
setHover(gi);
|
||||
showAt(e.currentTarget, content(g));
|
||||
}}
|
||||
onBlur={() => {
|
||||
setHover(null);
|
||||
hide();
|
||||
}}
|
||||
className="outline-none"
|
||||
>
|
||||
{/* Zone de survol : toute la bande du jour, pas seulement les barres. */}
|
||||
<rect
|
||||
x={pad.left + gi * band}
|
||||
y={pad.top}
|
||||
width={band}
|
||||
height={innerH}
|
||||
fill={hover === gi ? '#14110E' : 'transparent'}
|
||||
fillOpacity={hover === gi ? 0.04 : 0}
|
||||
/>
|
||||
{series.map((s, si) => (
|
||||
<path key={s.label} d={bar(x0 + si * (barW + 2), g.values[si])} fill={s.color} />
|
||||
))}
|
||||
<text x={pad.left + gi * band + band / 2} y={height - 10} textAnchor="middle" className="fill-ink font-mono text-[10.5px]">
|
||||
{g.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<line x1={pad.left} x2={width - pad.right} y1={y(0)} y2={y(0)} stroke="#14110E" strokeOpacity={0.4} strokeWidth={1} />
|
||||
</svg>
|
||||
<Tooltip tip={tip} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Répartition (partie d'un tout) — une barre, segments séparés par 2 px //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export function ShareBar({
|
||||
segments,
|
||||
}: {
|
||||
segments: Array<{ label: string; value: number; color: string }>;
|
||||
}) {
|
||||
const { tip, show, showAt, hide } = useTip();
|
||||
const total = segments.reduce((t, s) => t + s.value, 0);
|
||||
const visible = segments.filter((s) => s.value > 0);
|
||||
const pct = (v: number) => (total ? Math.round((v / total) * 100) : 0);
|
||||
return (
|
||||
<div>
|
||||
<div className="flex h-[22px] w-full gap-[2px]">
|
||||
{visible.map((s, i) => (
|
||||
<div
|
||||
key={s.label}
|
||||
tabIndex={0}
|
||||
onPointerMove={(e) => show(e, <TipRow color={s.color} label={s.label} value={`${fr(s.value)} (${pct(s.value)} %)`} />)}
|
||||
onPointerLeave={hide}
|
||||
onFocus={(e) => showAt(e.currentTarget, <TipRow color={s.color} label={s.label} value={`${fr(s.value)} (${pct(s.value)} %)`} />)}
|
||||
onBlur={hide}
|
||||
className={cx(
|
||||
'h-full outline-none hover:brightness-110 focus-visible:brightness-110',
|
||||
i === 0 && 'rounded-l-[4px]',
|
||||
i === visible.length - 1 && 'rounded-r-[4px]',
|
||||
)}
|
||||
style={{ flexGrow: s.value, flexBasis: 0, background: s.color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Legend items={segments.map((s) => ({ label: s.label, color: s.color, value: `${fr(s.value)} · ${pct(s.value)} %` }))} />
|
||||
</div>
|
||||
<Tooltip tip={tip} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Jauge : une valeur rapportée à un maximum (piste = même teinte, plus claire) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export function Meter({
|
||||
value,
|
||||
max,
|
||||
label,
|
||||
extra,
|
||||
title,
|
||||
}: {
|
||||
value: number;
|
||||
max: number;
|
||||
label: string;
|
||||
/** Complément court après le libellé (ex. « +2 »), dans une colonne fixe
|
||||
* pour que les jauges restent alignées d'une ligne à l'autre. */
|
||||
extra?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const ratio = max > 0 ? Math.min(1, value / max) : 0;
|
||||
return (
|
||||
<span className="flex items-center gap-[6px]" title={title ?? label}>
|
||||
<span className="relative block h-[8px] w-[72px] rounded-[4px]" style={{ background: '#cde2fb' }}>
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 rounded-[4px]"
|
||||
style={{ width: `${ratio * 100}%`, background: SERIES.blue }}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-[64px] text-left font-mono text-[10.5px] font-bold">{label}</span>
|
||||
<span className="w-[26px] text-left font-mono text-[10px] opacity-60">{extra ?? ''}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,11 @@ export function ErrorNote({ error }: { error: unknown }) {
|
||||
<span className="mt-1 block font-mono text-[10px] font-normal opacity-80">
|
||||
{known ? t(code) : code}
|
||||
</span>
|
||||
{error instanceof ApiError && error.requestId ? (
|
||||
<span className="mt-1 block select-all font-mono text-[10px] font-normal opacity-80">
|
||||
{t('app.error.reference', { id: error.requestId })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
"action.remove": "Retirer",
|
||||
"action.add": "Ajouter",
|
||||
"app.loading": "Chargement…",
|
||||
"common.now": "MAINTENANT",
|
||||
"app.error.generic": "Une erreur est survenue",
|
||||
"app.error.reference": "Référence à communiquer : {id}",
|
||||
"error.generic": "Erreur inattendue",
|
||||
"error.internal": "Erreur interne du serveur",
|
||||
"error.not_found": "Ressource introuvable",
|
||||
"error.forbidden": "Accès refusé",
|
||||
"error.unauthorized": "Authentification requise",
|
||||
@@ -52,6 +53,8 @@
|
||||
"header.title": "GESTION DES BÉNÉVOLES",
|
||||
"header.meta": "BÉNÉVOLAT · {count} INSCRITS",
|
||||
"header.mockMode": "DONNÉES DE DÉMO",
|
||||
"header.openMenu": "Menu",
|
||||
"header.closeMenu": "Fermer le menu",
|
||||
"tab.grille": "Grille des besoins",
|
||||
"tab.volunteers": "Vue par bénévole",
|
||||
"tab.mobileVolunteer": "Bénévole · mobile",
|
||||
@@ -69,13 +72,7 @@
|
||||
"position.category.other": "Autre",
|
||||
"validation.SHIFT_OVERLAP": "Se chevauche avec un autre créneau",
|
||||
"validation.SHIFT_OVERLAP.at": "Déjà affecté sur {position} au même moment",
|
||||
"validation.SHIFT_OVERLAP.rule": "Deux créneaux d'un même bénévole se chevauchent.",
|
||||
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées ({gap} non couvertes).",
|
||||
"validation.OUTSIDE_AVAILABILITY.rule": "Le créneau n'est pas entièrement couvert par les disponibilités déclarées.",
|
||||
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h sur la journée ({hours})",
|
||||
"validation.DAILY_HOURS_EXCEEDED.rule": "Plus de 8 h 00 d'affectations cumulées sur une même journée.",
|
||||
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux ({rest})",
|
||||
"validation.INSUFFICIENT_REST.rule": "Moins de 8 h 00 entre la fin d'un créneau et le début du suivant.",
|
||||
"validation.SHIFT_OVERLAP.rule": "Deux créneaux d'un même bénévole se chevauchent (avertissement, non bloquant).",
|
||||
"grille.filter.day": "JOUR",
|
||||
"grille.filter.position": "POSTE",
|
||||
"grille.legend.critical": "SOUS-EFFECTIF CRITIQUE",
|
||||
@@ -107,32 +104,91 @@
|
||||
"grille.panel.warningsTitle": "⚠ AVERTISSEMENTS — AFFECTATION POSSIBLE",
|
||||
"volunteers.filter.show": "AFFICHER",
|
||||
"volunteers.filter.all": "TOUS",
|
||||
"volunteers.filter.overloaded": "SURCHARGÉS",
|
||||
"volunteers.filter.unassigned": "SANS AFFECTATION",
|
||||
"volunteers.legend.assigned": "AFFECTATION",
|
||||
"volunteers.legend.overlap": "DEUX POSTES EN MÊME TEMPS",
|
||||
"volunteers.legend.now": "MAINTENANT",
|
||||
"volunteers.bar.title": "{position} · {time}",
|
||||
"volunteers.bar.overlapTitle": "{position} · {time} — en même temps qu'un autre poste",
|
||||
"volunteers.search.placeholder": "Rechercher un bénévole ou un poste…",
|
||||
"volunteers.search.label": "Rechercher un bénévole ou un poste",
|
||||
"volunteers.search.empty": "Aucun bénévole ni poste ne correspond à « {query} ».",
|
||||
"volunteer.unnamed": "Sans nom",
|
||||
"volunteers.count": "{count} BÉNÉVOLES",
|
||||
"volunteers.totalHours": "TOTAL H",
|
||||
"volunteers.sort.label": "TRIER PAR",
|
||||
"volunteers.sort.name": "NOM",
|
||||
"volunteers.sort.hoursAsc": "HEURES ↑",
|
||||
"volunteers.sort.hoursDesc": "HEURES ↓",
|
||||
"volunteers.sort.toggle": "Trier par total d'heures (croissant / décroissant)",
|
||||
"volunteers.name": "NOM",
|
||||
"volunteers.cumulative": "CUMUL",
|
||||
"volunteers.badge.overloaded": "▲ SURCHARGE",
|
||||
"volunteers.badge.unassigned": "○ OUBLIÉ",
|
||||
"volunteers.empty": "Aucun bénévole ne correspond à ce filtre — bonne nouvelle. Repassez sur « TOUS » pour revoir l'ensemble de l'équipe.",
|
||||
"volunteers.noData": "Aucun bénévole pour le moment. Ajoutez-les dans Configuration → « Bénévoles & import ».",
|
||||
"volunteers.footnote": "Surcharge = plus de 8 h sur une même journée ou moins de 8 h entre deux créneaux. Oublié = aucune affectation.",
|
||||
"mobileVol.kicker": "MON PLANNING BÉNÉVOLE",
|
||||
"mobileVol.greeting": "SALUT {name}",
|
||||
"mobileVol.slots": "{count} CRÉNEAUX",
|
||||
"mobileVol.hours": "{count} HEURES",
|
||||
"mobileVol.location": "📍 {location}",
|
||||
"mobileVol.referent": "👤 Responsable : {name}",
|
||||
"mobileVol.phone": "📞 {phone}",
|
||||
"volunteers.footnote": "Oublié = aucune affectation.",
|
||||
"mobileVol.greeting": "Salut {name}",
|
||||
"mobileVol.summary.one": "{count} créneau · {duration} sur {days} jour",
|
||||
"mobileVol.summary.other": "{count} créneaux · {duration} sur {days} jour(s)",
|
||||
"mobileVol.summary.none": "Pas encore de créneau.",
|
||||
"mobileVol.featured.now": "● EN COURS",
|
||||
"mobileVol.featured.next": "PROCHAIN CRÉNEAU",
|
||||
"mobileVol.featured.overlap": "⚠ Tu es aussi sur {position} de {start} à {end}. Vérifie avec la coordination.",
|
||||
"mobileVol.featured.referent": "TON RÉFÉRENT",
|
||||
"mobileVol.featured.call": "APPELER",
|
||||
"mobileVol.allDone": "Festival terminé. Merci {name}, tu as assuré !",
|
||||
"mobileVol.day.one": "{count} créneau · {duration}",
|
||||
"mobileVol.day.other": "{count} créneaux · {duration}",
|
||||
"mobileVol.slot.done": "TERMINÉ",
|
||||
"mobileVol.slot.now": "EN COURS",
|
||||
"mobileVol.slot.referent": " · réf. {name}",
|
||||
"mobileVol.slot.overlap": "⚠ Chevauche {position} de {start} à {end}. Vérifie avec la coordination où tu dois être.",
|
||||
"slot.duration.hours": "{h} h",
|
||||
"slot.duration.hoursMinutes": "{h} h {m}",
|
||||
"slot.countdown.endsAt": "FIN À {time}",
|
||||
"slot.countdown.minutes": "DANS {count} MIN",
|
||||
"slot.countdown.in": "DANS {duration}",
|
||||
"slot.countdown.days": "DANS {count} J",
|
||||
"mobileVol.orgMessage.title": "MESSAGE DE L'ORGANISATION",
|
||||
"mobileVol.orgMessage.signature": "Coordination · {when}",
|
||||
"message.save": "Enregistrer",
|
||||
"message.remove": "Retirer le message",
|
||||
"message.saving": "Enregistrement…",
|
||||
"message.saved": "✓ Enregistré",
|
||||
"message.updatedAt": "Publié {when}",
|
||||
"message.length": "{count} / {max} caractères",
|
||||
"message.date.today": "aujourd'hui {time}",
|
||||
"message.date.yesterday": "hier {time}",
|
||||
"message.date.other": "{day} · {time}",
|
||||
"mobileVol.noSlots": "Aucun créneau pour l'instant. La coordination t'affectera bientôt.",
|
||||
"mobileVol.noAccount": "Ce compte n'est rattaché à aucun bénévole. Contactez la coordination.",
|
||||
"mobileLead.kicker": "RÉFÉRENT DE POSTE",
|
||||
"mobileLead.positionsCount": "{count} POSTE(S) DONT VOUS ÊTES RÉFÉRENT",
|
||||
"mobileLead.kicker": "Ton équipe",
|
||||
"mobileLead.kickerNamed": "{name} · ton équipe",
|
||||
"mobileLead.status.done": "terminé",
|
||||
"mobileLead.status.now": "en cours",
|
||||
"mobileLead.status.next": "à venir",
|
||||
"mobileLead.count.present": "PRÉSENTS",
|
||||
"mobileLead.count.waiting": "ATTENDUS",
|
||||
"mobileLead.count.absent": "ABSENTS",
|
||||
"mobileLead.absentAlert.one": "⚠ 1 absent sur le créneau — il manque du monde",
|
||||
"mobileLead.absentAlert.other": "⚠ {count} absents sur le créneau — il manque du monde",
|
||||
"mobileLead.roll": "POINTAGE · {count} BÉNÉVOLE(S)",
|
||||
"mobileLead.when.now": "Créneau en cours",
|
||||
"mobileLead.when.next": "Début {time}",
|
||||
"mobileLead.when.done": "Créneau terminé",
|
||||
"mobileLead.call": "APPEL",
|
||||
"mobileLead.callLabel": "Appeler {name}",
|
||||
"mobileLead.message.title": "MESSAGE À TON ÉQUIPE",
|
||||
"mobileLead.message.audience": "{count} bénévole(s) le verront",
|
||||
"mobileLead.message.help": "Consignes du poste (tenue, matériel, point de rendez-vous…), affichées sur l'écran de chaque bénévole affecté au poste.",
|
||||
"mobileLead.message.placeholder": "Ex. Gilet jaune fourni à la base bénévoles. Rendez-vous 10 min avant le début.",
|
||||
"mobileLead.view.team": "Mon équipe",
|
||||
"mobileLead.view.planning": "Mon planning · {count}",
|
||||
"mobileLead.you": "TOI",
|
||||
"mobileLead.markedAt": "pointé à {time}",
|
||||
"mobileLead.attendanceError": "Le pointage n'a pas pu être enregistré (réseau ?). Réessaie.",
|
||||
"mobileLead.noPosition": "Vous n'êtes actuellement référent d'aucun poste.",
|
||||
"mobileLead.noShift": "Aucun créneau sur ce poste.",
|
||||
"mobileLead.present": "{present}/{total} POINTÉS PRÉSENTS",
|
||||
"mobileLead.badgePresent": "✓ PRÉSENT",
|
||||
"mobileLead.badgeAbsent": "✕ ABSENT",
|
||||
"mobileLead.markPresent": "PRÉSENT",
|
||||
@@ -142,6 +198,31 @@
|
||||
"config.reserved": "RÉSERVÉ À LA COORDINATION",
|
||||
"config.tab.grid": "POSTES & CRÉNEAUX",
|
||||
"config.tab.volunteers": "BÉNÉVOLES & IMPORT",
|
||||
"config.mail.test.title": "Tester l'envoi d'emails",
|
||||
"config.mail.test.help": "Envoie tout de suite à l'adresse de votre choix le récapitulatif ci-contre tel que les bénévoles le recevront : sujet (précédé de « [Test] ») et texte en cours de saisie, variables remplies avec le planning d'un bénévole. Le lien de connexion y mène à la page de connexion, sans ouvrir le compte de ce bénévole.",
|
||||
"config.mail.test.example": "Rempli avec le planning de",
|
||||
"config.mail.test.exampleAuto": "Automatique (premier bénévole avec un créneau)",
|
||||
"config.mail.test.withLink": "Avec le lien de connexion (comme « Envoyer avec lien de connexion »)",
|
||||
"config.mail.test.filledWith": "Contenu rempli avec le planning de {name}.",
|
||||
"config.mail.test.filledWithExample": "Aucun bénévole : contenu rempli avec un exemple fictif.",
|
||||
"config.mail.test.email": "Adresse de test",
|
||||
"config.mail.test.placeholder": "moi@exemple.fr",
|
||||
"config.mail.test.send": "Envoyer un email de test",
|
||||
"config.mail.test.sending": "Envoi en cours…",
|
||||
"config.mail.test.result.sent": "✓ Email envoyé. Vérifiez la boîte de réception (et les indésirables).",
|
||||
"config.mail.test.result.disabled": "L'envoi d'emails est désactivé sur ce serveur (EMAIL_ENABLED=false) : rien n'a été envoyé.",
|
||||
"config.mail.test.result.connection_failed": "✕ Connexion au serveur d'envoi impossible (adresse, port ou identifiants SMTP).",
|
||||
"config.mail.test.result.rejected": "✕ Le serveur d'envoi a refusé l'email (souvent : expéditeur non autorisé).",
|
||||
"config.mail.test.serverSaid": "Réponse du serveur : {detail}",
|
||||
"config.mail.test.config": "Serveur {host} · expéditeur {from}",
|
||||
"notifications.invalid_email": "Adresse email invalide.",
|
||||
"config.tab.message": "MESSAGE AUX BÉNÉVOLES",
|
||||
"config.message.orgTitle": "Message de l'organisation",
|
||||
"config.message.orgHelp": "Le même pour tous les bénévoles, affiché en bas de leur écran planning. Laissez vide pour ne rien afficher.",
|
||||
"config.message.orgPlaceholder": "Ex. Badges et t-shirts à récupérer à l'accueil bénévoles dès 17:00.",
|
||||
"config.message.positionsTitle": "Messages des postes",
|
||||
"config.message.positionsHelp": "Saisis par chaque référent depuis son écran et montrés aux bénévoles du poste. Vous pouvez aussi les modifier ici.",
|
||||
"config.message.none": "Pas de message",
|
||||
"config.tab.mail": "MESSAGES",
|
||||
"config.tab.rules": "RÈGLES & ÉDITION",
|
||||
"config.grid.positionsHeader": "POSTES · {count} CRÉNEAUX",
|
||||
@@ -174,7 +255,7 @@
|
||||
"config.vol.phone": "TÉLÉPHONE",
|
||||
"config.vol.email": "EMAIL (LIEN MAGIQUE)",
|
||||
"config.vol.delete": "SUPPRIMER",
|
||||
"config.vol.deleteConfirm": "Supprimer définitivement ce bénévole et toutes ses données (disponibilités, affectations) ?",
|
||||
"config.vol.deleteConfirm": "Supprimer définitivement ce bénévole et toutes ses données (affectations, vœux) ?",
|
||||
"config.vol.note": "Modifications enregistrées immédiatement via l'API. La suppression est un effacement complet en cascade.",
|
||||
"config.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",
|
||||
@@ -182,7 +263,7 @@
|
||||
"config.mail.subject": "OBJET",
|
||||
"config.mail.body": "CORPS DU MESSAGE",
|
||||
"config.mail.variables": "VARIABLES DISPONIBLES",
|
||||
"config.mail.variablesHint": "Cliquer une variable l'ajoute au corps. {lien_connexion} insère un lien de connexion personnel.",
|
||||
"config.mail.variablesHint": "Cliquer une variable l'ajoute au corps. {lien_connexion} insère le lien de connexion directe permanent du bénévole (un clic suffit, sans limite de durée ; crée son accès s'il n'en a pas encore) — révocable depuis sa fiche. {creneaux} insère le tableau de ses créneaux.",
|
||||
"config.mail.send": "ENVOYER LE RÉCAPITULATIF",
|
||||
"config.mail.sendWithLink": "ENVOYER AVEC LIEN DE CONNEXION",
|
||||
"config.mail.sending": "ENVOI EN COURS…",
|
||||
@@ -209,9 +290,7 @@
|
||||
"config.rules.slotAdd": "AJOUTER",
|
||||
"config.rules.slotsNote": "Ces créneaux sont ajoutés à besoin 0/0 sur chaque jour à la création d'un poste. Après un ajout ici (ou une extension des dates), utilisez « Générer les créneaux » sur le poste.",
|
||||
"config.rules.thresholdsTitle": "MOTEUR DE VALIDATION",
|
||||
"config.rules.maxDaily": "Maximum d'heures par journée",
|
||||
"config.rules.minRest": "Repos minimum entre deux créneaux",
|
||||
"config.rules.thresholdsNote": "Seuils figés côté back-end (CLAUDE.md §3) : non paramétrables. Le double emploi au même créneau est toujours bloquant.",
|
||||
"config.rules.thresholdsNote": "Seule règle : le double emploi au même moment donne un avertissement, sans bloquer (un bénévole peut tenir deux postes à la fois) ; le solveur évite de le proposer. Aucune limite d'heures par journée ni de temps de repos.",
|
||||
"config.rules.rulesTitle": "RÈGLES APPLIQUÉES",
|
||||
"config.rules.rolesTitle2": "RÔLES ET ACCÈS",
|
||||
"config.rules.roleCoordinator": "Coordination",
|
||||
@@ -236,12 +315,27 @@
|
||||
"config.vol.field.email": "Email (lien magique)",
|
||||
"config.vol.field.phone": "Téléphone",
|
||||
"config.vol.field.comment": "Commentaire",
|
||||
"config.vol.field.badgeText": "Texte du badge",
|
||||
"config.vol.field.badgeCount": "Nombre de badges",
|
||||
"config.vol.field.badgeCount.help": "Nombre d'exemplaires de son badge imprimés sur les planches (1 à 100). 1 par défaut.",
|
||||
"config.vol.field.badgeText.help": "Remplace, pour ce bénévole seulement, le texte libre commun saisi dans l'écran Badges. Vide = texte commun.",
|
||||
"config.vol.photo.title": "Photo",
|
||||
"config.vol.photo.hint": "Imprimée sur le badge (champ « Photo du bénévole ») et affichée sur les écrans bénévole et référent. Celle envoyée au formulaire d'inscription, ou ajoutée ici. PNG ou JPEG.",
|
||||
"config.vol.photo.none": "Aucune photo",
|
||||
"config.vol.photo.add": "Ajouter une photo",
|
||||
"config.vol.photo.replace": "Remplacer la photo",
|
||||
"config.vol.photo.remove": "Retirer",
|
||||
"config.vol.photo.removeConfirm": "Retirer la photo de ce bénévole ?",
|
||||
"config.vol.access.title": "Accès de connexion",
|
||||
"config.vol.access.hint": "Un email sur la fiche ne suffit pas à lui seul : sans un accès donné ici, le bénévole ne reçoit jamais de lien de connexion.",
|
||||
"config.vol.access.hint": "Un email sur la fiche ne suffit pas à lui seul : il faut un accès donné ici, ou l'envoi d'un récapitulatif de planning avec {lien_connexion} (qui en crée un).",
|
||||
"config.vol.access.grant": "Donner un accès de connexion",
|
||||
"config.vol.access.active": "Accès actif : {email}",
|
||||
"config.vol.access.revoke": "Révoquer",
|
||||
"config.vol.access.revokeConfirm": "Révoquer l'accès de connexion de ce bénévole ?",
|
||||
"config.vol.access.resetLink": "Renouveler le lien de connexion",
|
||||
"config.vol.access.resetLinkHint": "Le lien de connexion des récapitulatifs de planning est permanent. Le renouveler rend inutilisables tous les liens déjà envoyés (email transféré, téléphone perdu…) ; le prochain récapitulatif en portera un nouveau.",
|
||||
"config.vol.access.resetLinkConfirm": "Rendre inutilisables les liens de connexion déjà envoyés à ce bénévole ?",
|
||||
"config.vol.access.resetLinkDone": "✓ Anciens liens révoqués",
|
||||
"config.vol.access.needEmail": "Renseignez un email ci-dessus pour pouvoir donner un accès.",
|
||||
"config.vol.field.referentPositions": "Référent des postes",
|
||||
"config.vol.referentHint": "Cocher un poste crée ou met à niveau son accès de connexion en référent : à sa prochaine connexion, il verra l'écran référent (son équipe, le pointage) au lieu de l'écran bénévole. Décocher retire seulement le poste, sans jamais rétrograder l'accès automatiquement.",
|
||||
@@ -250,7 +344,6 @@
|
||||
"config.vol.addTitle": "NOUVEAU BÉNÉVOLE",
|
||||
"config.vol.addHint": "Renseigner un email ne donne pas d'accès à lui seul : une fois le bénévole créé, utilisez « Donner un accès de connexion » sur sa fiche.",
|
||||
"config.vol.create": "CRÉER LE BÉNÉVOLE",
|
||||
"config.vol.requiredNames": "Le prénom et le nom sont obligatoires.",
|
||||
"config.vol.wiz.title": "IMPORTER UN FICHIER",
|
||||
"config.vol.wiz.step1": "1 · FICHIER",
|
||||
"config.vol.wiz.step2": "2 · COLONNES",
|
||||
@@ -317,6 +410,11 @@
|
||||
"config.vol.fee.mecenat": "Mécénat — reçu fiscal",
|
||||
"config.vol.fee.onSite": "Paiera sur place",
|
||||
"config.vol.fee.none": "aucune cotisation déclarée",
|
||||
"config.vol.fee.paidAt": "Payée le",
|
||||
"config.vol.fee.transaction": "Transaction SumUp",
|
||||
"config.vol.fee.transactionHint": "Code de la transaction tel qu'affiché dans le tableau de bord SumUp : recherchez-le là-bas pour retrouver ce paiement. Côté SumUp, la description de la transaction porte aussi le nom du bénévole.",
|
||||
"config.vol.fee.transactionUnknown": "non enregistrée (paiement antérieur à ce suivi)",
|
||||
"config.vol.fee.transactionId": "Identifiant",
|
||||
"config.vol.fee.onSiteShort": "Sur place",
|
||||
"config.vol.fee.onlineShort": "En ligne",
|
||||
"config.vol.fee.mecenatShort": "Mécénat",
|
||||
@@ -325,17 +423,6 @@
|
||||
"config.vol.fee.short.failed": "échouée",
|
||||
"config.vol.fee.short.expired": "expirée",
|
||||
"config.vol.wishesCount": "{count} vœu(x)",
|
||||
"config.vol.addAvailabilityHint": "Les disponibilités se renseignent sur la fiche du bénévole, une fois celui-ci créé.",
|
||||
"config.vol.avail.title": "Disponibilités",
|
||||
"config.vol.avail.hint": "Plages horaires où le bénévole peut venir. Une heure de fin ≤ début signifie que la plage se termine le lendemain.",
|
||||
"config.vol.avail.none": "Aucune disponibilité déclarée",
|
||||
"config.vol.avail.add": "Ajouter une plage",
|
||||
"config.vol.avail.save": "Enregistrer les disponibilités",
|
||||
"grille.panel.coverage.full": "Disponible",
|
||||
"grille.panel.coverage.partial": "Partiellement dispo",
|
||||
"grille.panel.coverage.none": "Dispo non déclarée",
|
||||
"availability.date_outside_edition": "Cette date est hors des dates de l'édition.",
|
||||
"availability.empty_window": "Une plage doit avoir un début et une fin différents.",
|
||||
"grille.solver.run": "SUGGESTIONS (SOLVEUR)",
|
||||
"grille.solver.running": "CALCUL EN COURS…",
|
||||
"grille.solver.result": "{count} suggestion(s)",
|
||||
@@ -352,18 +439,29 @@
|
||||
"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.front.help": "Fond personnalisable + champs positionnables (nom, postes avec horaires, catégorie, rôle, photo, texte libre). Le format du badge est celui de cette image (300 dpi supposés). Propre à cette édition.",
|
||||
"badges.back": "Verso",
|
||||
"badges.back.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": "Créneaux affectés (tableau)",
|
||||
"badges.field.positions.placeholder": "Bar central 19:00–22:00\nAccueil 09:00–13:00 (Référent : Sam Nuit)",
|
||||
"badges.field.positions.sample1.day": "Vendredi 10",
|
||||
"badges.field.positions.sample1.rows": "18:00–22:00|Bar central",
|
||||
"badges.field.positions.sample2.day": "Samedi 11",
|
||||
"badges.field.positions.sample2.rows": "09:00–13:00|Accueil\n23:00–01:00|Bar central",
|
||||
"badges.field.category": "Catégorie",
|
||||
"badges.field.category.placeholder": "Bar",
|
||||
"badges.field.role": "Rôle (référent / bénévole)",
|
||||
"badges.field.role.placeholder": "Référent",
|
||||
"badges.field.photo": "Photo du bénévole",
|
||||
"badges.field.photo.placeholder": "Photo",
|
||||
"badges.field.photo.help": "La photo du bénévole (envoyée avec le formulaire d'inscription ou ajoutée sur sa fiche), recadrée au format du cadre. Rien n'est imprimé pour un bénévole sans photo.",
|
||||
"badges.field.freeText": "Texte libre",
|
||||
"badges.field.freeText.placeholder": "Accès backstage",
|
||||
"badges.field.freeText.help": "Texte saisi ci-dessous, imprimé sur tous les badges. Pour un bénévole en particulier, le « Texte du badge » de sa fiche (Configuration › Bénévoles) le remplace.",
|
||||
"badges.field.freeText.input": "Texte à imprimer (ex. STAFF 2026)",
|
||||
"badges.field.size": "Taille",
|
||||
"badges.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)",
|
||||
@@ -376,18 +474,210 @@
|
||||
"badges.saved": "Enregistré ✓",
|
||||
"badges.generate.title": "Génération de la planche PDF",
|
||||
"badges.generate.all": "Tous les bénévoles ({count})",
|
||||
"badges.generate.total": "{count} badge(s) à imprimer",
|
||||
"badges.generate.pick": "Choisir ({count} sélectionné(s))",
|
||||
"badges.generate.action": "Générer le PDF",
|
||||
"badges.generating": "Génération…",
|
||||
"badges.face.front": "Recto",
|
||||
"badges.face.back": "Verso",
|
||||
"badges.face.front.short": "recto",
|
||||
"badges.face.back.short": "verso",
|
||||
"badges.dims": "{width} × {height} mm",
|
||||
"badges.drop.front": "Déposez ici le visuel du recto (PNG ou JPEG)",
|
||||
"badges.drop.back": "Déposez ici le visuel du verso (PNG ou JPEG)",
|
||||
"badges.format.title": "Format du badge",
|
||||
"badges.format.current": "Format actuel",
|
||||
"badges.preset.cr80Portrait": "Carte CR80 portrait",
|
||||
"badges.preset.cr80Landscape": "Carte CR80 paysage",
|
||||
"badges.preset.a7": "A7 tour de cou",
|
||||
"badges.fieldsOf": "Champs du {face}",
|
||||
"badges.fieldsShown": "{count} affiché(s)",
|
||||
"badges.layer.toggle": "Afficher / masquer",
|
||||
"badges.layer.hidden": "masqué",
|
||||
"badges.layer.meta": "{size} pt · {weight}",
|
||||
"badges.layer.bold": "gras",
|
||||
"badges.layer.regular": "normal",
|
||||
"badges.layer.rect": "rectangle",
|
||||
"badges.layer.oval": "ovale",
|
||||
"badges.layer.issueCount": "▲ {count}",
|
||||
"badges.layer.issueTruncated.one": "{count} badge avec texte coupé",
|
||||
"badges.layer.issueTruncated.other": "{count} badges avec texte coupé",
|
||||
"badges.layer.issueReduced.one": "{count} badge réduit automatiquement",
|
||||
"badges.layer.issueReduced.other": "{count} badges réduits automatiquement",
|
||||
"badges.keys.hint": "Clic sur un champ pour le régler. Flèches : déplacer de 0,5 mm. Maj + flèches : 2 mm. Échap : désélectionner. Ctrl/Cmd + Z : annuler.",
|
||||
"badges.undo": "↶ Annuler",
|
||||
"badges.undo.title": "Ctrl/Cmd + Z",
|
||||
"badges.dirty": "Modifications non enregistrées",
|
||||
"badges.clean": "✓ Enregistré",
|
||||
"badges.savedAt": "✓ Enregistré à {time}",
|
||||
"badges.sample.firstName": "Prénom",
|
||||
"badges.sample.lastName": "Nom",
|
||||
"badges.preview.with": "Aperçu avec",
|
||||
"badges.preview.prev": "Bénévole précédent",
|
||||
"badges.preview.next": "Bénévole suivant",
|
||||
"badges.preview.counter": "Badge {index} / {total} · {role}",
|
||||
"badges.preview.noVolunteer": "Aucun bénévole : exemple fictif",
|
||||
"badges.preview.longest": "Tester le nom le plus long",
|
||||
"badges.preview.guides": "Repères",
|
||||
"badges.preview.guides.help": "Affiche la marge de découpe (1,5 mm) et le cadre de chaque champ",
|
||||
"badges.preview.zoomIn": "Agrandir l'aperçu",
|
||||
"badges.preview.zoomOut": "Réduire l'aperçu",
|
||||
"badges.preview.zoom": "{percent} %",
|
||||
"badges.preview.noPhoto": "sans photo",
|
||||
"badges.preview.truncated": "▲ TEXTE COUPÉ",
|
||||
"badges.preview.dims": "{width} × {height} mm",
|
||||
"badges.preview.reducedTo": " · réduit à {size} pt",
|
||||
"badges.pt": "{size} pt",
|
||||
"badges.color.white": "Blanc",
|
||||
"badges.color.black": "Noir",
|
||||
"badges.color.yellow": "Jaune",
|
||||
"badges.color.coral": "Corail",
|
||||
"badges.color.none": "Aucun",
|
||||
"badges.color.custom": "Autre",
|
||||
"badges.align.left": "Gauche",
|
||||
"badges.align.center": "Centre",
|
||||
"badges.align.right": "Droite",
|
||||
"badges.inspector.kicker": "{face} · champ sélectionné",
|
||||
"badges.inspector.content": "Contenu",
|
||||
"badges.inspector.contentHelp": "Identique sur tous les badges — sauf pour un bénévole dont la fiche porte un « Texte du badge », qui le remplace.",
|
||||
"badges.inspector.fontSize": "Taille du texte",
|
||||
"badges.inspector.reducedHere": "Ici réduit à {size} pt",
|
||||
"badges.inspector.capHeight": "Hauteur ≈ {mm} mm",
|
||||
"badges.inspector.smaller": "Diminuer la taille",
|
||||
"badges.inspector.larger": "Augmenter la taille",
|
||||
"badges.inspector.autoFit": "Réduire automatiquement si le texte est trop long",
|
||||
"badges.inspector.align": "Alignement",
|
||||
"badges.inspector.weight": "Graisse",
|
||||
"badges.inspector.regular": "Normal",
|
||||
"badges.inspector.bold": "Gras",
|
||||
"badges.inspector.textColor": "Couleur du texte",
|
||||
"badges.inspector.background": "Fond de la zone",
|
||||
"badges.inspector.lowContrast": "⚠ Contraste faible : le texte risque d'être illisible à l'impression.",
|
||||
"badges.inspector.photoShape": "Forme de la photo",
|
||||
"badges.inspector.photoRect": "▢ Rectangle",
|
||||
"badges.inspector.photoOval": "◯ Ovale",
|
||||
"badges.inspector.photoStat": "{count} photo(s) reçue(s) sur {total}. Sans photo, le cadre reste vide.",
|
||||
"badges.inspector.geometry": "Position et taille (mm)",
|
||||
"badges.inspector.x": "X",
|
||||
"badges.inspector.y": "Y",
|
||||
"badges.inspector.w": "L",
|
||||
"badges.inspector.h": "H",
|
||||
"badges.inspector.center": "↔ Centrer",
|
||||
"badges.inspector.fullWidth": "⇤⇥ Pleine largeur",
|
||||
"badges.inspector.stack": "Superposition",
|
||||
"badges.inspector.stack.position": "{rank} / {total} · 1 = dessus",
|
||||
"badges.inspector.stack.front": "⤒ Premier plan",
|
||||
"badges.inspector.stack.forward": "↑ Avancer",
|
||||
"badges.inspector.stack.backward": "↓ Reculer",
|
||||
"badges.inspector.stack.back": "⤓ Arrière-plan",
|
||||
"badges.inspector.stack.help": "Quand des champs se chevauchent, celui du dessus recouvre les autres, sur l'aperçu comme à l'impression.",
|
||||
"badges.inspector.hide": "Masquer ce champ",
|
||||
"badges.inspector.empty.title": "Sélectionnez un champ sur le badge ou dans la liste de gauche.",
|
||||
"badges.inspector.empty.hint": "Faites-le glisser pour le déplacer, tirez le carré jaune pour le redimensionner. Ses réglages apparaissent ici.",
|
||||
"badges.inspector.empty.drop": "Pour changer le visuel de fond, déposez une image directement sur le recto ou le verso.",
|
||||
"badges.check.title": "Contrôle des {count} badges",
|
||||
"badges.check.subtitle": "Avant génération des planches",
|
||||
"badges.check.critical": "⛔ À corriger",
|
||||
"badges.check.warning": "⚠ À vérifier",
|
||||
"badges.check.fixAutoFit": "Activer la réduction auto",
|
||||
"badges.check.showExample": "Voir un exemple",
|
||||
"badges.check.ok": "Les {count} badges s'affichent sans texte coupé ni champ hors zone de découpe.",
|
||||
"badges.check.estimate": "Estimation faite dans le navigateur : le PDF généré reste la référence.",
|
||||
"badges.issue.where": "{field} ({face})",
|
||||
"badges.issue.truncated.one": "{where} : texte coupé sur {count} badge.",
|
||||
"badges.issue.truncated.other": "{where} : texte coupé sur {count} badges.",
|
||||
"badges.issue.reduced.one": "{where} : réduit automatiquement sur {count} badge, jusqu'à {size} pt.",
|
||||
"badges.issue.reduced.other": "{where} : réduit automatiquement sur {count} badges, jusqu'à {size} pt.",
|
||||
"badges.issue.margin": "{where} : touche la marge de découpe (1,5 mm), risque d'être rogné.",
|
||||
"badges.issue.contrast": "{where} : texte et fond de zone trop proches, illisible.",
|
||||
"badges.issue.noPhoto.one": "{count} bénévole n'a pas de photo : le cadre restera vide sur son badge.",
|
||||
"badges.issue.noPhoto.other": "{count} bénévoles n'ont pas de photo : le cadre restera vide sur leur badge.",
|
||||
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
|
||||
"badge.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.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois par face.",
|
||||
"badge.invalid_color": "Couleur invalide (format attendu : #rrggbb).",
|
||||
"badge.invalid_size": "La taille du badge doit être strictement positive.",
|
||||
"badge.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",
|
||||
"tab.stats": "Statistiques",
|
||||
"stats.kpi.registered": "Bénévoles inscrits · +{count} en 7 jours",
|
||||
"stats.kpi.collected": "Cotisations encaissées en ligne",
|
||||
"stats.kpi.coverage": "Places pourvues · {assigned} / {ideal}",
|
||||
"stats.kpi.withAssignment": "Ont au moins une affectation · sur {total}",
|
||||
"stats.attendance.title": "Présence",
|
||||
"stats.attendance.help": "Présents et absents pointés par les référents sur leur écran, par poste. « En ce moment » : créneaux commencés et pas encore finis ; « Créneaux terminés » : tout ce qui est fini. Les créneaux à venir ne comptent pas. Mis à jour toutes les 30 secondes.",
|
||||
"stats.attendance.live": "En ce moment",
|
||||
"stats.attendance.past": "Créneaux terminés",
|
||||
"stats.attendance.asOf": "mis à jour à {time}",
|
||||
"stats.attendance.noLive": "Aucun créneau en cours.",
|
||||
"stats.attendance.noPast": "Aucun créneau terminé pour l'instant.",
|
||||
"stats.attendance.liveSummary": "{shifts} créneau(x) en cours · {expected} bénévole(s) attendu(s)",
|
||||
"stats.attendance.shiftsCount": "{count} en cours",
|
||||
"stats.attendance.expected": "Attendus",
|
||||
"stats.attendance.present": "Présents",
|
||||
"stats.attendance.absent": "Absents",
|
||||
"stats.attendance.unmarked": "Non pointés",
|
||||
"stats.attendance.rate": "Présence",
|
||||
"stats.attendance.rateTip": "{present} présent(s) sur {marked} pointé(s)",
|
||||
"stats.attendance.unmarkedHint": "{count} affectation(s) sur des créneaux terminés n'ont jamais été pointées : le taux de présence ne porte que sur les bénévoles pointés.",
|
||||
"stats.empty": "Aucun bénévole inscrit pour l'instant.",
|
||||
"stats.showTable": "Voir le tableau",
|
||||
"stats.unit.hours": "h",
|
||||
"stats.registrations.title": "Évolution des inscriptions",
|
||||
"stats.registrations.help": "Nombre cumulé de bénévoles en base, par jour d'ajout (formulaire public, import ou ajout à la main).",
|
||||
"stats.registrations.cumulative": "inscrits au total",
|
||||
"stats.registrations.daily": "nouveaux ce jour",
|
||||
"stats.answers.title": "Réponses",
|
||||
"stats.answers.help": "Part des bénévoles ayant renseigné chaque information (formulaire public ou fiche).",
|
||||
"stats.answers.email": "Email",
|
||||
"stats.answers.phone": "Téléphone",
|
||||
"stats.answers.available": "Case « disponible »",
|
||||
"stats.answers.positionWishes": "Vœux de poste",
|
||||
"stats.answers.artistWishes": "Vœux d'artiste",
|
||||
"stats.answers.companionWishes": "Vœux de compagnon",
|
||||
"stats.answers.companionNote": "Compagnon (texte libre)",
|
||||
"stats.answers.photo": "Photo",
|
||||
"stats.answers.comment": "Commentaire",
|
||||
"stats.artists.title": "Artistes que les bénévoles veulent voir",
|
||||
"stats.payments.title": "Cotisations",
|
||||
"stats.payments.help": "En ligne : paiement SumUp confirmé. Sur place : « je paierai sur place » ou case cotisation cochée sans paiement en ligne.",
|
||||
"stats.payments.online": "En ligne",
|
||||
"stats.payments.onSite": "Sur place",
|
||||
"stats.payments.none": "Aucune",
|
||||
"stats.payments.byOption": "Paiements en ligne par type",
|
||||
"stats.payments.noOnline": "Aucun paiement en ligne pour l'instant.",
|
||||
"stats.payments.mecenatTag": "mécénat",
|
||||
"stats.payments.total": "Total",
|
||||
"stats.payments.mecenat": "Dont mécénat : {count} don(s), {amount} (reçus fiscaux).",
|
||||
"stats.payments.unfinished": "{count} formulaire(s) rempli(s) sans paiement abouti : ces personnes ne sont pas comptées parmi les bénévoles (paiement refusé, abandonné ou expiré).",
|
||||
"stats.needs.title": "Besoins et bénévoles",
|
||||
"stats.needs.help": "Heures × personnes demandées par les créneaux, face aux heures déjà affectées — par journée de festival (une soirée qui passe minuit compte pour le jour où elle commence).",
|
||||
"stats.needs.empty": "Aucun créneau défini pour l'instant.",
|
||||
"stats.needs.byDay": "Par jour (heures × personnes)",
|
||||
"stats.needs.byPosition": "Par poste (places = personnes × créneaux)",
|
||||
"stats.needs.min": "Besoin minimal",
|
||||
"stats.needs.ideal": "Besoin idéal",
|
||||
"stats.needs.assigned": "Déjà affecté",
|
||||
"stats.needs.filledWithExtra": "{filled} / {ideal} (+{extra} en sureffectif)",
|
||||
"stats.needs.wishesHint": "Pourvues : places occupées, plafonnées à l'effectif idéal de chaque créneau (« +2 » = bénévoles en sureffectif, non comptés). Vœux / place : nombre de bénévoles qui ont demandé le poste, rapporté aux places idéales. En dessous de 1, le poste aura besoin de bénévoles qui ne l'ont pas choisi.",
|
||||
"stats.col.date": "Date",
|
||||
"stats.col.day": "Jour",
|
||||
"stats.col.answer": "Information",
|
||||
"stats.col.volunteers": "Bénévoles",
|
||||
"stats.col.share": "Part",
|
||||
"stats.col.artist": "Artiste",
|
||||
"stats.col.paymentType": "Type de paiement",
|
||||
"stats.col.amount": "Montant",
|
||||
"stats.col.shifts": "Créneaux",
|
||||
"stats.col.position": "Poste",
|
||||
"stats.col.minSlots": "Places min.",
|
||||
"stats.col.idealSlots": "Places idéales",
|
||||
"stats.col.filled": "Pourvues",
|
||||
"stats.col.wishes": "Vœux",
|
||||
"stats.col.wishesPerSlot": "Vœux / place",
|
||||
"form.field.email": "Email",
|
||||
"form.field.phone": "Téléphone",
|
||||
"form.field.positionWishes": "Vœux de poste",
|
||||
@@ -397,7 +687,7 @@
|
||||
"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.available.help": "Purement informatif — simple case à cocher, sans effet sur les affectations.",
|
||||
"form.field.willingToPayFee": "Case « je veux bien payer une cotisation »",
|
||||
"config.vol.feeStatus.pending": "Cotisation : paiement en attente",
|
||||
"config.vol.feeStatus.paid": "Cotisation payée ✓",
|
||||
@@ -487,6 +777,7 @@
|
||||
"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.",
|
||||
"volunteer.empty_photo": "Le fichier de la photo est vide.",
|
||||
"form.photo_too_large": "La photo est trop volumineuse.",
|
||||
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
|
||||
"form.logo_empty": "Le logo est vide.",
|
||||
|
||||
+47
-202
@@ -1,9 +1,7 @@
|
||||
import type {
|
||||
ArtistRead,
|
||||
AssignmentRead,
|
||||
AvailabilityRead,
|
||||
DayPartRead,
|
||||
MatrixResponse,
|
||||
PositionCategory,
|
||||
ShiftRead,
|
||||
Violation,
|
||||
@@ -25,71 +23,6 @@ export function foldAccents(value: string): string {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Disponibilités : couverture d'un créneau par les fenêtres déclarées //
|
||||
// (portage de `backend/app/validation/engine.py`). //
|
||||
// --------------------------------------------------------------------------- //
|
||||
const _dayNum = (iso: string): number => {
|
||||
const [y, m, d] = iso.split('-').map(Number);
|
||||
return Math.floor(Date.UTC(y, m - 1, d) / 86_400_000);
|
||||
};
|
||||
|
||||
/** Minutes absolues (depuis une origine arbitraire) d'un datetime naïf ISO. */
|
||||
function absMinutes(iso: string): number {
|
||||
const [date, time] = iso.split('T');
|
||||
const [h, min] = time.split(':').map(Number);
|
||||
return _dayNum(date) * 1440 + h * 60 + min;
|
||||
}
|
||||
|
||||
function windowInterval(w: AvailabilityRead): [number, number] {
|
||||
const [sh, sm] = w.start_time.split(':').map(Number);
|
||||
const [eh, em] = w.end_time.split(':').map(Number);
|
||||
const start = _dayNum(w.date) * 1440 + sh * 60 + sm;
|
||||
let end = _dayNum(w.date) * 1440 + eh * 60 + em;
|
||||
if (end <= start) end += 1440; // fenêtre à cheval sur minuit
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
/** Minutes du créneau `[startIso, endIso)` hors de l'union des fenêtres. */
|
||||
export function uncoveredMinutes(
|
||||
windows: AvailabilityRead[],
|
||||
startIso: string,
|
||||
endIso: string,
|
||||
): number {
|
||||
const shiftStart = absMinutes(startIso);
|
||||
const shiftEnd = absMinutes(endIso);
|
||||
const intervals = windows
|
||||
.map(windowInterval)
|
||||
.filter(([a, b]) => b > a)
|
||||
.sort((a, b) => a[0] - b[0]);
|
||||
let cursor = shiftStart;
|
||||
let gap = 0;
|
||||
for (const [s, e] of intervals) {
|
||||
if (e <= cursor) continue;
|
||||
if (s > shiftEnd) break;
|
||||
if (s > cursor) gap += Math.min(s, shiftEnd) - cursor;
|
||||
cursor = Math.max(cursor, e);
|
||||
if (cursor >= shiftEnd) return gap;
|
||||
}
|
||||
if (cursor < shiftEnd) gap += shiftEnd - cursor;
|
||||
return gap;
|
||||
}
|
||||
|
||||
export type Coverage = 'full' | 'partial' | 'none';
|
||||
|
||||
/** État de couverture d'un créneau par les fenêtres d'un bénévole. */
|
||||
export function availabilityCoverage(
|
||||
windows: AvailabilityRead[],
|
||||
startIso: string,
|
||||
endIso: string,
|
||||
): Coverage {
|
||||
if (windows.length === 0) return 'none';
|
||||
const gap = uncoveredMinutes(windows, startIso, endIso);
|
||||
if (gap === 0) return 'full';
|
||||
const total = absMinutes(endIso) - absMinutes(startIso);
|
||||
return gap >= total ? 'none' : 'partial';
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Score de chance d'un candidat pour un créneau — portage simplifié du //
|
||||
// solveur (`backend/app/solver/engine.py`) : sert à trier la liste des //
|
||||
@@ -145,121 +78,78 @@ export function buildEffectiveCompanionIds(
|
||||
}
|
||||
|
||||
const CHANCE_WISH_POSITION_BONUS = 30;
|
||||
const CHANCE_AVAIL_PENALTY_PER_HALF_HOUR = 5;
|
||||
const CHANCE_ARTIST_CONFLICT_PENALTY = 80;
|
||||
const CHANCE_FAIRNESS_PENALTY_PER_HOUR = 2;
|
||||
const CHANCE_COMPANION_BONUS_PER_MATCH = 25;
|
||||
// Déjà affecté à un autre poste au même moment : possible, mais en fin de liste.
|
||||
const CHANCE_ALREADY_BUSY_PENALTY = 300;
|
||||
|
||||
/**
|
||||
* Score de chance d'un bénévole pour un créneau : vœu de poste, disponibilité
|
||||
* (y compris le conflit avec un artiste qu'il veut voir), compagnons
|
||||
* souhaités déjà affectés au créneau, charge déjà assignée. Plus haut =
|
||||
* Score de chance d'un bénévole pour un créneau : vœu de poste, conflit avec
|
||||
* un artiste qu'il veut voir, compagnons souhaités déjà affectés au créneau,
|
||||
* déjà affecté ailleurs au même moment, charge déjà assignée. Plus haut =
|
||||
* meilleur candidat. Sert uniquement à trier la liste affichée ; la
|
||||
* validation (blocage / avertissements) reste seule juge.
|
||||
*/
|
||||
export function candidateChanceScore(params: {
|
||||
wishesPosition: boolean;
|
||||
uncoveredMinutes: number;
|
||||
conflictsWishedArtist: boolean;
|
||||
companionMatches: number;
|
||||
busyElsewhere: boolean;
|
||||
assignedHours: number;
|
||||
}): number {
|
||||
let score = 0;
|
||||
if (params.busyElsewhere) score -= CHANCE_ALREADY_BUSY_PENALTY;
|
||||
if (params.wishesPosition) score += CHANCE_WISH_POSITION_BONUS;
|
||||
score -= Math.floor(params.uncoveredMinutes / 30) * CHANCE_AVAIL_PENALTY_PER_HALF_HOUR;
|
||||
if (params.conflictsWishedArtist) score -= CHANCE_ARTIST_CONFLICT_PENALTY;
|
||||
score += CHANCE_COMPANION_BONUS_PER_MATCH * params.companionMatches;
|
||||
score -= CHANCE_FAIRNESS_PENALTY_PER_HOUR * params.assignedHours;
|
||||
return score;
|
||||
}
|
||||
|
||||
/** Colonne de la grille : un créneau distinct (date, heure de début, heure de
|
||||
* fin) — pas seulement une heure de début : deux créneaux qui démarrent à la
|
||||
* même heure mais durent différemment ont chacun leur colonne. */
|
||||
export interface LatticeColumn {
|
||||
key: string; // `YYYY-MM-DD|HH:MM|HH:MM`
|
||||
date: string;
|
||||
time: string;
|
||||
endTime: string;
|
||||
label: string; // « 17:00 → 22:00 »
|
||||
}
|
||||
|
||||
export interface LatticeDay {
|
||||
date: string;
|
||||
columns: LatticeColumn[];
|
||||
}
|
||||
|
||||
/** Frontière du jour (« journée » au sens § 3 du CLAUDE.md) : début du
|
||||
* créneau type de plus petit `order` — même règle que
|
||||
* `validation.engine._day_boundary` côté backend. Sans créneau type, minuit. */
|
||||
/** Frontière du jour (« journée » au sens § 3 du CLAUDE.md) : début du plus
|
||||
* long creux laissé par les créneaux types sur le cadran de 24 h (l'heure où
|
||||
* le dernier de la journée se termine) — même règle que
|
||||
* `validation.engine.day_boundary` côté backend. Indépendant de `order`, qui
|
||||
* ne suit pas les heures quand on les modifie. Sans creux, début du premier
|
||||
* par `order` ; sans créneau type, minuit. */
|
||||
export function dayBoundaryFromDayParts(
|
||||
dayParts: Array<Pick<DayPartRead, 'start_time' | 'order'>>,
|
||||
dayParts: Array<Pick<DayPartRead, 'start_time' | 'end_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[];
|
||||
columns: LatticeColumn[];
|
||||
} {
|
||||
const boundaryMinutes = minutesOfDay(dayBoundary);
|
||||
const seen = new Map<string, LatticeColumn & { festivalDay: string; sortMinutes: number }>();
|
||||
for (const s of starts) {
|
||||
const date = datePart(s.starts_at);
|
||||
const time = timePart(s.starts_at);
|
||||
const endTime = timePart(s.ends_at);
|
||||
const key = `${date}|${time}|${endTime}`;
|
||||
if (!seen.has(key)) {
|
||||
const minutes = minutesOfDay(time);
|
||||
const festivalDay = minutes < boundaryMinutes ? addDays(date, -1) : date;
|
||||
const sortMinutes = (minutes - boundaryMinutes + 24 * 60) % (24 * 60);
|
||||
seen.set(key, { key, date, time, endTime, label: `${time}–${endTime}`, festivalDay, sortMinutes });
|
||||
const DAY = 24 * 60;
|
||||
const covered = new Array<boolean>(DAY).fill(false);
|
||||
for (const part of dayParts) {
|
||||
const start = minutesOfDay(part.start_time.slice(0, 5));
|
||||
const end = minutesOfDay(part.end_time.slice(0, 5));
|
||||
const length = (end - start + DAY) % DAY || DAY;
|
||||
for (let offset = 0; offset < length; offset++) covered[(start + offset) % DAY] = true;
|
||||
}
|
||||
const origin = covered.indexOf(true);
|
||||
if (covered.every(Boolean)) {
|
||||
const first = dayParts.reduce((min, part) => (part.order < min.order ? part : min));
|
||||
return first.start_time.slice(0, 5);
|
||||
}
|
||||
// Parcours circulaire depuis une minute couverte : un creux qui enjambe
|
||||
// minuit est vu d'un seul tenant.
|
||||
let bestStart = 0;
|
||||
let bestLength = 0;
|
||||
let runStart = 0;
|
||||
let runLength = 0;
|
||||
for (let offset = 1; offset <= DAY; offset++) {
|
||||
const minute = (origin + offset) % DAY;
|
||||
if (covered[minute]) {
|
||||
runLength = 0;
|
||||
continue;
|
||||
}
|
||||
if (runLength === 0) runStart = minute;
|
||||
runLength += 1;
|
||||
if (runLength > bestLength) {
|
||||
bestStart = runStart;
|
||||
bestLength = runLength;
|
||||
}
|
||||
}
|
||||
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[] = [];
|
||||
for (const column of ordered) {
|
||||
let day = days.find((d) => d.date === column.festivalDay);
|
||||
if (!day) {
|
||||
day = { date: column.festivalDay, columns: [] };
|
||||
days.push(day);
|
||||
}
|
||||
day.columns.push({
|
||||
key: column.key,
|
||||
date: column.date,
|
||||
time: column.time,
|
||||
endTime: column.endTime,
|
||||
label: column.label,
|
||||
});
|
||||
}
|
||||
return { days, columns };
|
||||
}
|
||||
|
||||
export function latticeFromMatrix(matrix: MatrixResponse, dayBoundary?: string) {
|
||||
return buildLattice(
|
||||
matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at, ends_at: s.ends_at }))),
|
||||
dayBoundary,
|
||||
);
|
||||
return clockLabel(bestStart);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,9 +168,8 @@ export function columnWidthPx(slotsInDay: number): number {
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 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.) //
|
||||
// dans cette grille fixe en fusionnant les cases qu'ils couvrent. Même axe //
|
||||
// pour la « Vue par bénévole » (`VolunteerViewPage.tsx`). //
|
||||
// --------------------------------------------------------------------------- //
|
||||
const TIME_GRID_SLOT_MINUTES = 30;
|
||||
|
||||
@@ -426,32 +315,6 @@ export function hoursByVolunteer(
|
||||
return totals;
|
||||
}
|
||||
|
||||
/** Surcharge : > 8 h sur une journée civile ou < 8 h de repos entre créneaux. */
|
||||
export function isOverloaded(
|
||||
volunteerId: string,
|
||||
assignments: AssignmentRead[],
|
||||
shiftsById: Map<string, Pick<ShiftRead, 'starts_at' | 'ends_at'>>,
|
||||
): boolean {
|
||||
const shifts = assignments
|
||||
.filter((a) => a.volunteer_id === volunteerId)
|
||||
.map((a) => shiftsById.get(a.shift_id))
|
||||
.filter((s): s is ShiftRead => Boolean(s))
|
||||
.sort((a, b) => a.starts_at.localeCompare(b.starts_at));
|
||||
|
||||
const perDay = new Map<string, number>();
|
||||
for (const s of shifts) {
|
||||
const day = datePart(s.starts_at);
|
||||
perDay.set(day, (perDay.get(day) ?? 0) + hoursBetween(s.starts_at, s.ends_at));
|
||||
}
|
||||
for (const hours of perDay.values()) if (hours > 8) return true;
|
||||
|
||||
for (let i = 1; i < shifts.length; i += 1) {
|
||||
const gap = naiveMinutes(shifts[i].starts_at) - naiveMinutes(shifts[i - 1].ends_at);
|
||||
if (gap > 0 && gap < 8 * 60) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rend une violation en texte lisible. Les `params` suivent exactement le
|
||||
* contrat du moteur back-end (`backend/app/validation/engine.py`).
|
||||
@@ -468,25 +331,7 @@ export function violationText(
|
||||
? t('validation.SHIFT_OVERLAP.at', { position: where })
|
||||
: t('validation.SHIFT_OVERLAP');
|
||||
}
|
||||
case 'OUTSIDE_AVAILABILITY':
|
||||
return t('validation.OUTSIDE_AVAILABILITY', {
|
||||
gap: formatMinutes(Number(p.uncovered_minutes)),
|
||||
});
|
||||
case 'DAILY_HOURS_EXCEEDED':
|
||||
return t('validation.DAILY_HOURS_EXCEEDED', {
|
||||
hours: formatMinutes(Number(p.total_minutes)),
|
||||
});
|
||||
case 'INSUFFICIENT_REST':
|
||||
return t('validation.INSUFFICIENT_REST', {
|
||||
rest: formatMinutes(Number(p.gap_minutes)),
|
||||
});
|
||||
default:
|
||||
return violation.code;
|
||||
}
|
||||
}
|
||||
|
||||
function formatMinutes(total: number): string {
|
||||
const h = Math.floor(total / 60);
|
||||
const m = total % 60;
|
||||
return m === 0 ? `${h} h` : `${h} h ${String(m).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Aides des écrans bénévole et référent (`VolunteerHomeView`,
|
||||
// `MobileLeadPage`) : statut d'un créneau par rapport à maintenant, durées,
|
||||
// compte à rebours et frise horaire d'une journée. Tout en minutes
|
||||
// « murales » (`naiveMinutes` / `nowNaiveMinutes`), jamais en `Date` locale.
|
||||
|
||||
import { t } from '@/i18n';
|
||||
import { longDayLabel, naiveMinutes } from '@/lib/time';
|
||||
|
||||
export type SlotStatus = 'done' | 'now' | 'next';
|
||||
|
||||
export function slotStatus(start: number, end: number, now: number): SlotStatus {
|
||||
if (now >= end) return 'done';
|
||||
return now >= start ? 'now' : 'next';
|
||||
}
|
||||
|
||||
/** `HH:MM` d'un instant en minutes naïves (passe minuit sans broncher). */
|
||||
export function clockLabel(minutes: number): string {
|
||||
const m = ((minutes % 1440) + 1440) % 1440;
|
||||
return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** « 9 h », « 2 h 30 ». */
|
||||
export function durationLabel(minutes: number): string {
|
||||
const total = Math.round(minutes);
|
||||
const h = Math.floor(total / 60);
|
||||
const m = total % 60;
|
||||
return m ? t('slot.duration.hoursMinutes', { h, m: String(m).padStart(2, '0') }) : t('slot.duration.hours', { h });
|
||||
}
|
||||
|
||||
/** Compte à rebours du créneau mis en avant : « FIN À 03:00 » s'il est en
|
||||
* cours, sinon « DANS 20 MIN », « DANS 2 h 30 » ou « DANS 3 J ». */
|
||||
export function countdownLabel(start: number, end: number, now: number): string {
|
||||
if (now >= start) return t('slot.countdown.endsAt', { time: clockLabel(end) });
|
||||
const diff = start - now;
|
||||
if (diff < 60) return t('slot.countdown.minutes', { count: Math.max(1, Math.round(diff)) });
|
||||
if (diff < 24 * 60) return t('slot.countdown.in', { duration: durationLabel(diff) });
|
||||
return t('slot.countdown.days', { count: Math.round(diff / (24 * 60)) });
|
||||
}
|
||||
|
||||
export interface Interval {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Plage commune à deux créneaux qui se chevauchent (`null` sinon) —
|
||||
* jointifs (fin = début) : pas de chevauchement, comme `SHIFT_OVERLAP`. */
|
||||
export function overlapOf(a: Interval, b: Interval): Interval | null {
|
||||
const start = Math.max(a.start, b.start);
|
||||
const end = Math.min(a.end, b.end);
|
||||
return start < end ? { start, end } : null;
|
||||
}
|
||||
|
||||
export interface RibbonBar {
|
||||
/** Position et largeur en % de la frise. */
|
||||
left: number;
|
||||
width: number;
|
||||
/** Ligne de la frise : deux créneaux qui se chevauchent ne se masquent
|
||||
* pas, le second descend d'une ligne. */
|
||||
lane: number;
|
||||
}
|
||||
|
||||
export interface Ribbon {
|
||||
bars: RibbonBar[];
|
||||
lanes: number;
|
||||
/** Repères horaires, en % de la frise. */
|
||||
ticks: Array<{ left: number; label: string }>;
|
||||
/** Nombre d'heures couvertes (une graduation par heure). */
|
||||
hours: number;
|
||||
/** Position de maintenant en %, `null` hors de la frise. */
|
||||
now: number | null;
|
||||
}
|
||||
|
||||
const MIN_RIBBON_HOURS = 4;
|
||||
const MAX_TICKS = 4;
|
||||
|
||||
/** Frise d'une journée : de l'heure pleine qui précède le premier créneau à
|
||||
* celle qui suit le dernier (4 h au moins), `slots` triés par début. */
|
||||
export function buildRibbon(slots: Interval[], now: number): Ribbon {
|
||||
const first = Math.floor(Math.min(...slots.map((s) => s.start)) / 60) * 60;
|
||||
const last = Math.ceil(Math.max(...slots.map((s) => s.end)) / 60) * 60;
|
||||
const hours = Math.max(MIN_RIBBON_HOURS, (last - first) / 60);
|
||||
const span = hours * 60;
|
||||
const pct = (minutes: number) => ((minutes - first) / span) * 100;
|
||||
|
||||
const laneEnds: number[] = [];
|
||||
const bars = slots.map((s) => {
|
||||
let lane = 0;
|
||||
while (laneEnds[lane] !== undefined && laneEnds[lane] > s.start) lane++;
|
||||
laneEnds[lane] = s.end;
|
||||
return { left: pct(s.start), width: ((s.end - s.start) / span) * 100, lane };
|
||||
});
|
||||
|
||||
const step = Math.ceil(hours / (MAX_TICKS - 1));
|
||||
const ticks = [];
|
||||
for (let h = 0; h <= hours; h += step) ticks.push({ left: (h / hours) * 100, label: clockLabel(first + h * 60) });
|
||||
|
||||
return {
|
||||
bars,
|
||||
lanes: Math.max(1, laneEnds.length),
|
||||
ticks,
|
||||
hours,
|
||||
now: now >= first && now <= first + span ? pct(now) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Date d'un message (organisation, poste) : « AUJOURD'HUI 18:42 »,
|
||||
* « HIER 18:42 », sinon « Samedi 31 · 18:42 ». */
|
||||
export function messageDateLabel(iso: string, now: number): string {
|
||||
const at = naiveMinutes(iso);
|
||||
const days = Math.floor(now / 1440) - Math.floor(at / 1440);
|
||||
const time = clockLabel(at);
|
||||
if (days === 0) return t('message.date.today', { time });
|
||||
if (days === 1) return t('message.date.yesterday', { time });
|
||||
return t('message.date.other', { day: longDayLabel(iso), time });
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Nom et initiales d'un bénévole à l'écran. Prénom et nom ne sont pas
|
||||
// obligatoires à l'ajout manuel (étape 66) : un bénévole peut n'avoir ni
|
||||
// l'un ni l'autre, il doit rester visible et sélectionnable partout.
|
||||
|
||||
import { t } from '@/i18n';
|
||||
|
||||
type Named = { first_name: string; last_name: string };
|
||||
|
||||
/** « Prénom Nom », ou « Sans nom » si les deux sont vides. */
|
||||
export function volunteerName(v: Named): string {
|
||||
return `${v.first_name} ${v.last_name}`.trim() || t('volunteer.unnamed');
|
||||
}
|
||||
|
||||
/** Initiales (prénom puis nom), « ? » si les deux sont vides. */
|
||||
export function volunteerInitials(v: Named): string {
|
||||
return `${v.first_name.trim().charAt(0)}${v.last_name.trim().charAt(0)}`.toUpperCase() || '?';
|
||||
}
|
||||
@@ -1,526 +1,44 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
useBadgeImageUrl,
|
||||
useBadgeTemplate,
|
||||
useGenerateBadges,
|
||||
useSetBadgeFields,
|
||||
useSetBadgeSize,
|
||||
useUploadBadgeBackImage,
|
||||
useUploadBadgeFrontImage,
|
||||
useVolunteers,
|
||||
} from '@/api/hooks';
|
||||
import type { BadgeAlign, BadgeFieldIn, BadgeFieldType } from '@/api/schema';
|
||||
import { useGenerateBadges, useVolunteers } from '@/api/hooks';
|
||||
import { ApiError } from '@/api';
|
||||
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
||||
import { Btn, Chip, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui';
|
||||
import { Btn, Chip, Panel, PanelHeader } from '@/components/ui';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { volunteerName } from '@/lib/volunteer';
|
||||
|
||||
// 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
|
||||
import { BadgeCustomizeTab } from './badges/BadgeCustomizeTab';
|
||||
|
||||
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;
|
||||
}
|
||||
// L'onglet « Personnaliser » (éditeur recto/verso, aperçu avec les vraies
|
||||
// données, contrôle avant génération) vit dans `./badges/`.
|
||||
|
||||
export function BadgesPage() {
|
||||
const [tab, setTab] = useState<'customize' | 'generate'>('customize');
|
||||
|
||||
const tabBar = (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
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>
|
||||
{tab === 'customize' ? (
|
||||
<BadgeCustomizeTab tabBar={tabBar} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b-[3px] border-ink bg-sand px-3 py-2 sm:px-4">
|
||||
{tabBar}
|
||||
</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',
|
||||
<BadgeGenerateTab />
|
||||
</>
|
||||
)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -570,7 +88,7 @@ function BadgeGenerateTab() {
|
||||
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">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-3 sm:p-4">
|
||||
<Panel className="max-w-[560px]">
|
||||
<PanelHeader>{t('badges.generate.title')}</PanelHeader>
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
@@ -583,6 +101,18 @@ function BadgeGenerateTab() {
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
{/* Un bénévole peut demander plusieurs exemplaires (fiche,
|
||||
« Nombre de badges ») : le total imprimé peut dépasser le
|
||||
nombre de bénévoles. */}
|
||||
<span className="font-mono text-[10px] opacity-70">
|
||||
{t('badges.generate.total', {
|
||||
count: (mode === 'pick' ? sorted.filter((v) => picked.has(v.id)) : sorted).reduce(
|
||||
(sum, v) => sum + v.badge_count,
|
||||
0,
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
|
||||
{mode === 'pick' ? (
|
||||
<div className="max-h-[320px] overflow-auto border-2 border-ink">
|
||||
{sorted.map((v) => (
|
||||
@@ -596,7 +126,10 @@ function BadgeGenerateTab() {
|
||||
onChange={() => toggle(v.id)}
|
||||
className="h-[14px] w-[14px] accent-ink"
|
||||
/>
|
||||
{v.first_name} {v.last_name}
|
||||
{volunteerName(v)}
|
||||
{v.badge_count > 1 ? (
|
||||
<span className="ml-auto opacity-60">× {v.badge_count}</span>
|
||||
) : null}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,16 +4,18 @@ import { Chip } from '@/components/ui';
|
||||
import { ConfigArtists } from '@/pages/config/ConfigArtists';
|
||||
import { ConfigGrid } from '@/pages/config/ConfigGrid';
|
||||
import { ConfigMail } from '@/pages/config/ConfigMail';
|
||||
import { ConfigMessage } from '@/pages/config/ConfigMessage';
|
||||
import { ConfigRules } from '@/pages/config/ConfigRules';
|
||||
import { ConfigVolunteers } from '@/pages/config/ConfigVolunteers';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
type ConfigTab = 'grid' | 'volunteers' | 'artists' | 'mail' | 'rules';
|
||||
type ConfigTab = 'grid' | 'volunteers' | 'artists' | 'message' | 'mail' | 'rules';
|
||||
|
||||
const TABS: Array<{ id: ConfigTab; labelKey: string }> = [
|
||||
{ id: 'grid', labelKey: 'config.tab.grid' },
|
||||
{ id: 'volunteers', labelKey: 'config.tab.volunteers' },
|
||||
{ id: 'artists', labelKey: 'config.tab.artists' },
|
||||
{ id: 'message', labelKey: 'config.tab.message' },
|
||||
{ id: 'mail', labelKey: 'config.tab.mail' },
|
||||
{ id: 'rules', labelKey: 'config.tab.rules' },
|
||||
];
|
||||
@@ -38,6 +40,7 @@ export function ConfigPage() {
|
||||
{tab === 'grid' ? <ConfigGrid /> : null}
|
||||
{tab === 'volunteers' ? <ConfigVolunteers /> : null}
|
||||
{tab === 'artists' ? <ConfigArtists /> : null}
|
||||
{tab === 'message' ? <ConfigMessage /> : null}
|
||||
{tab === 'mail' ? <ConfigMail /> : null}
|
||||
{tab === 'rules' ? <ConfigRules /> : null}
|
||||
</div>
|
||||
|
||||
@@ -117,9 +117,9 @@ export function FormBuilderPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-auto p-4">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-auto p-3 sm:p-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start">
|
||||
<Panel className="max-w-[420px] flex-1">
|
||||
<Panel className="flex-1 lg:max-w-[420px]">
|
||||
<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">
|
||||
@@ -172,7 +172,7 @@ export function FormBuilderPage() {
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel className="min-w-[320px] flex-1">
|
||||
<Panel className="min-w-0 flex-1 lg:min-w-[320px]">
|
||||
<PanelHeader>{t('form.fields.title')}</PanelHeader>
|
||||
<div className="flex flex-col divide-y-2 divide-ink/10">
|
||||
{FIELD_TYPES.map((type) => (
|
||||
@@ -398,14 +398,14 @@ function PaymentOptionsForm() {
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-[6px]">
|
||||
{rows.map((o) => (
|
||||
<div key={o.id} className="flex items-center gap-1">
|
||||
<div key={o.id} className="flex flex-wrap items-center gap-1 border-b border-sand-line pb-2 sm:flex-nowrap sm:border-b-0 sm:pb-0">
|
||||
<input
|
||||
defaultValue={o.label}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (v && v !== o.label) update.mutate({ id: o.id, body: { label: v } });
|
||||
}}
|
||||
className="min-w-0 flex-1 border-2 border-ink bg-paper px-2 py-1 font-display text-[12px] font-bold"
|
||||
className="min-w-0 flex-1 basis-full border-2 border-ink bg-paper px-2 py-1 font-display text-[12px] font-bold sm:basis-0"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
@@ -449,19 +449,19 @@ function PaymentOptionsForm() {
|
||||
type="button"
|
||||
onClick={() => remove.mutate(o.id)}
|
||||
aria-label={t('action.remove')}
|
||||
className="h-[26px] w-[26px] flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange"
|
||||
className="ml-auto h-[26px] w-[26px] flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange sm:ml-0"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-1 flex items-center gap-1 border-t-2 border-ink pt-2">
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1 border-t-2 border-ink pt-2 sm:flex-nowrap">
|
||||
<input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t('form.customize.paymentOptions.label')}
|
||||
className="min-w-0 flex-1 border-2 border-ink bg-yellow px-2 py-1 font-display text-[12px] font-bold"
|
||||
className="min-w-0 flex-1 basis-full border-2 border-ink bg-yellow px-2 py-1 font-display text-[12px] font-bold sm:basis-0"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
@@ -519,7 +519,7 @@ function PaymentOptionsForm() {
|
||||
},
|
||||
)
|
||||
}
|
||||
className="h-[26px] flex-none cursor-pointer border-2 border-ink bg-ink px-2 font-display text-[10px] font-extrabold uppercase text-cream hover:bg-yellow hover:text-ink disabled:opacity-50"
|
||||
className="ml-auto h-[26px] flex-none cursor-pointer border-2 border-ink bg-ink px-2 sm:ml-0 font-display text-[10px] font-extrabold uppercase text-cream hover:bg-yellow hover:text-ink disabled:opacity-50"
|
||||
>
|
||||
{t('form.customize.paymentOptions.add')}
|
||||
</button>
|
||||
|
||||
@@ -88,7 +88,9 @@ export function GrillePage() {
|
||||
: matrix.data.positions.filter((p) => p.category === categoryFilter);
|
||||
|
||||
const gridTemplateColumns = [
|
||||
'230px',
|
||||
// Colonne des postes rétrécie sur petit écran, pour laisser voir au
|
||||
// moins quelques tranches horaires à côté.
|
||||
'clamp(132px, 36vw, 230px)',
|
||||
...shownDays.flatMap((day) => {
|
||||
const width = columnWidthPx(day.slotLabels.length);
|
||||
return day.slotLabels.map(() => `${width}px`);
|
||||
@@ -134,8 +136,8 @@ export function GrillePage() {
|
||||
|
||||
return (
|
||||
<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 items-center gap-[6px]">
|
||||
<div className="flex flex-wrap items-center gap-[10px] border-b-2 border-ink bg-sand px-3 py-[9px] sm:gap-[14px] sm:px-4">
|
||||
<div className="flex flex-wrap items-center gap-[6px]">
|
||||
<span className="font-mono text-[9px] opacity-60">{t('grille.filter.day')}</span>
|
||||
<Chip active={dayFilter === 'all'} onClick={() => setDayFilter('all')}>
|
||||
{t('filter.all')}
|
||||
@@ -163,7 +165,7 @@ export function GrillePage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runSolver.mutate()}
|
||||
@@ -199,7 +201,7 @@ export function GrillePage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-2 border-ink bg-cream px-[9px] py-[5px] lg:ml-auto">
|
||||
<span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
|
||||
<LegendItem swatch="bg-hatch-orange" glyph="▲">
|
||||
{t('grille.legend.critical')}
|
||||
@@ -220,7 +222,7 @@ export function GrillePage() {
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-stretch">
|
||||
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
|
||||
<div className="max-h-[calc(100dvh-150px)] min-w-0 flex-1 overflow-auto lg:max-h-[calc(100vh-118px)]">
|
||||
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}>
|
||||
<div
|
||||
style={{ gridColumn: '1 / span 1', gridRow: 1 }}
|
||||
@@ -278,7 +280,25 @@ export function GrillePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="max-h-[calc(100vh-118px)] w-[392px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
|
||||
{/* Sous `lg:`, pas la place d'un panneau à côté de la grille : il
|
||||
s'ouvre par-dessus (plein écran sur mobile, tiroir à droite sur
|
||||
tablette) seulement quand un créneau est choisi. */}
|
||||
{selected ? (
|
||||
<div
|
||||
aria-hidden
|
||||
onClick={() => setSelectedShiftId(null)}
|
||||
className="fixed inset-0 z-40 bg-ink/40 lg:hidden"
|
||||
/>
|
||||
) : null}
|
||||
<aside
|
||||
className={cx(
|
||||
'overflow-auto border-ink bg-paper',
|
||||
'lg:static lg:z-auto lg:block lg:max-h-[calc(100vh-118px)] lg:w-[392px] lg:flex-none lg:border-l-[3px]',
|
||||
selected
|
||||
? 'fixed inset-y-0 right-0 z-50 block w-full sm:w-[420px] sm:border-l-[3px]'
|
||||
: 'hidden',
|
||||
)}
|
||||
>
|
||||
{selected ? (
|
||||
<AssignmentPanel
|
||||
key={selected.shift.shift_id}
|
||||
|
||||
@@ -1,33 +1,61 @@
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { ShiftRead } from '@/api/schema';
|
||||
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
|
||||
import type { PositionRead, ShiftRead } from '@/api/schema';
|
||||
import {
|
||||
useAssignments,
|
||||
useDayParts,
|
||||
usePositions,
|
||||
useSetAttendance,
|
||||
useShifts,
|
||||
useUpdatePosition,
|
||||
useVolunteer,
|
||||
useVolunteerPlanning,
|
||||
useVolunteers,
|
||||
} from '@/api/hooks';
|
||||
import { useAuth } from '@/auth/auth-context';
|
||||
import { MessageEditor } from '@/components/MessageEditor';
|
||||
import { ErrorNote, Loading } from '@/components/state';
|
||||
import { NowLine } from '@/components/NowLine';
|
||||
import { VolunteerAvatar } from '@/components/VolunteerAvatar';
|
||||
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
|
||||
import { clockLabel, slotStatus, type SlotStatus } from '@/lib/slotTimeline';
|
||||
import { longDayLabel, naiveMinutes } from '@/lib/time';
|
||||
import { useNowMinutes } from '@/lib/useNowMinutes';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { VolunteerHomeView } from '@/pages/VolunteerHomeView';
|
||||
import { volunteerInitials, volunteerName } from '@/lib/volunteer';
|
||||
|
||||
// Page réellement consultée par le référent (accueil de son rôle LEAD, cf.
|
||||
// `auth/roles.ts:homePath`) — page responsive (mobile d'abord, élargie à
|
||||
// partir de `sm:`), plus de maquette de téléphone. Un compte peut être
|
||||
// référent de plusieurs postes (`Position.lead_account_id`) : chacun a sa
|
||||
// propre carte, listant TOUS ses créneaux (pas seulement le plus proche) avec
|
||||
// un repère « MAINTENANT » pour se situer. Aucun repli vers « le premier
|
||||
// poste de l'édition » si le compte n'est référent d'aucun poste — ce serait
|
||||
// montrer un poste qui n'est pas le sien ; s'il est par ailleurs rattaché à
|
||||
// une fiche bénévole (cas courant : « Donner un accès de connexion » promu
|
||||
// en référent), il voit alors sa vue de bénévole classique plutôt qu'un
|
||||
// écran référent vide (`VolunteerHomeView`).
|
||||
// `auth/roles.ts:homePath`), d'après la maquette `design/Planning bénévole
|
||||
// mobile.dc.html` : son poste, ses créneaux à choisir (celui en cours ou le
|
||||
// prochain par défaut), les compteurs présents / attendus / absents, puis
|
||||
// l'équipe du créneau à pointer. Pensée pour le téléphone ; sur un écran
|
||||
// plus large, la même colonne, centrée. Un compte peut être référent de
|
||||
// plusieurs postes (`Position.lead_account_id`) : on passe de l'un à l'autre
|
||||
// par des onglets. Aucun repli vers « le premier poste de l'édition » si le
|
||||
// compte n'est référent d'aucun poste — ce serait montrer un poste qui n'est
|
||||
// pas le sien ; s'il est par ailleurs rattaché à une fiche bénévole (cas
|
||||
// courant : « Donner un accès de connexion » promu en référent), il voit
|
||||
// alors sa vue de bénévole classique (`VolunteerHomeView`).
|
||||
//
|
||||
// Le pointage présent/absent reste local à l'écran (état React, jamais
|
||||
// envoyé au serveur) : le pointage sur site est hors périmètre (CLAUDE.md §5).
|
||||
// Un référent peut aussi être affecté comme bénévole, y compris la même
|
||||
// soirée : s'il est rattaché à une fiche bénévole, deux onglets — « Mon
|
||||
// équipe » (cet écran) et « Mon planning » (`VolunteerHomeView`, ses
|
||||
// propres créneaux). On ouvre sur « Mon planning » quand il tient en ce
|
||||
// moment un créneau de bénévole hors de ses postes et qu'aucun de ses
|
||||
// postes n'a de créneau en cours ; sinon sur « Mon équipe ».
|
||||
//
|
||||
// Le pointage présent/absent est enregistré sur l'affectation
|
||||
// (`Assignment.attendance`, `PUT .../assignments/{id}/attendance`) : il
|
||||
// survit à un rechargement et se retrouve sur un autre téléphone.
|
||||
|
||||
type Presence = Record<string, 'present' | 'absent'>;
|
||||
type View = 'team' | 'planning';
|
||||
|
||||
const STATUS_KEY: Record<SlotStatus, string> = {
|
||||
done: 'mobileLead.status.done',
|
||||
now: 'mobileLead.status.now',
|
||||
next: 'mobileLead.status.next',
|
||||
};
|
||||
|
||||
export function MobileLeadPage() {
|
||||
const { account } = useAuth();
|
||||
@@ -35,26 +63,25 @@ export function MobileLeadPage() {
|
||||
const shifts = useShifts();
|
||||
const volunteers = useVolunteers();
|
||||
const assignments = useAssignments();
|
||||
const nowMinutes = useNowMinutes();
|
||||
const [presence, setPresence] = useState<Presence>({});
|
||||
|
||||
const myPositions = (positions.data ?? []).filter(
|
||||
(p) => account && p.lead_account_id === account.id,
|
||||
);
|
||||
const dayParts = useDayParts();
|
||||
const me = useVolunteer(account?.volunteer_id);
|
||||
const myPlanning = useVolunteerPlanning(account?.volunteer_id);
|
||||
const [view, setView] = useState<View | null>(null);
|
||||
const updatePosition = useUpdatePosition();
|
||||
const now = useNowMinutes();
|
||||
const setAttendance = useSetAttendance();
|
||||
const [positionId, setPositionId] = useState<string | null>(null);
|
||||
const [shiftId, setShiftId] = useState<string | null>(null);
|
||||
|
||||
const boundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
|
||||
const shiftsByPosition = useMemo(() => {
|
||||
const map = new Map<string, ShiftRead[]>();
|
||||
for (const s of shifts.data ?? []) {
|
||||
const list = map.get(s.position_id) ?? [];
|
||||
list.push(s);
|
||||
map.set(s.position_id, list);
|
||||
}
|
||||
for (const s of shifts.data ?? []) map.set(s.position_id, [...(map.get(s.position_id) ?? []), s]);
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at));
|
||||
}
|
||||
return map;
|
||||
}, [shifts.data]);
|
||||
|
||||
const volunteersById = useMemo(
|
||||
() => new Map((volunteers.data ?? []).map((v) => [v.id, v])),
|
||||
[volunteers.data],
|
||||
@@ -64,6 +91,10 @@ export function MobileLeadPage() {
|
||||
const error = positions.error ?? shifts.error ?? volunteers.error ?? assignments.error;
|
||||
if (error) return <ErrorNote error={error} />;
|
||||
|
||||
const myPositions = (positions.data ?? []).filter(
|
||||
(p) => account && p.lead_account_id === account.id,
|
||||
);
|
||||
|
||||
if (myPositions.length === 0) {
|
||||
if (account?.volunteer_id) {
|
||||
return (
|
||||
@@ -82,149 +113,356 @@ export function MobileLeadPage() {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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="font-mono text-[10px] text-yellow">{t('mobileLead.kicker')}</div>
|
||||
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]">
|
||||
{t('mobileLead.positionsCount', { count: myPositions.length })}
|
||||
</div>
|
||||
// Par défaut : le poste dont un créneau est en cours ou arrive le plus tôt.
|
||||
const nextEnd = (p: PositionRead) =>
|
||||
(shiftsByPosition.get(p.id) ?? []).find((s) => now < naiveMinutes(s.ends_at));
|
||||
const position =
|
||||
myPositions.find((p) => p.id === positionId) ??
|
||||
[...myPositions].sort((a, b) => {
|
||||
const na = nextEnd(a);
|
||||
const nb = nextEnd(b);
|
||||
if (!na || !nb) return na ? -1 : nb ? 1 : 0;
|
||||
return naiveMinutes(na.starts_at) - naiveMinutes(nb.starts_at);
|
||||
})[0];
|
||||
const positionShifts = shiftsByPosition.get(position.id) ?? [];
|
||||
// Par défaut : le créneau en cours ou le prochain, sinon le dernier.
|
||||
const shift =
|
||||
positionShifts.find((s) => s.id === shiftId) ??
|
||||
nextEnd(position) ??
|
||||
positionShifts[positionShifts.length - 1] ??
|
||||
null;
|
||||
|
||||
const team = shift
|
||||
? (assignments.data ?? [])
|
||||
.filter((a) => a.shift_id === shift.id)
|
||||
.map((a) => {
|
||||
const volunteer = volunteersById.get(a.volunteer_id);
|
||||
return {
|
||||
id: a.id,
|
||||
volunteerId: a.volunteer_id,
|
||||
name: volunteer ? volunteerName(volunteer) : a.volunteer_id,
|
||||
initials: volunteer ? volunteerInitials(volunteer) : '?',
|
||||
hasPhoto: volunteer?.has_photo ?? false,
|
||||
phone: volunteer?.phone ?? '',
|
||||
state: a.attendance === 'PRESENT' ? 'present' : a.attendance === 'ABSENT' ? 'absent' : null,
|
||||
markedAt: a.attendance_marked_at,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
|
||||
: [];
|
||||
const presentCount = team.filter((m) => m.state === 'present').length;
|
||||
const absentCount = team.filter((m) => m.state === 'absent').length;
|
||||
// Un second appui sur le même bouton annule le pointage.
|
||||
const mark = (member: (typeof team)[number], value: 'present' | 'absent') =>
|
||||
setAttendance.mutate({
|
||||
id: member.id,
|
||||
attendance: member.state === value ? null : value === 'present' ? 'PRESENT' : 'ABSENT',
|
||||
});
|
||||
|
||||
// Bénévoles distincts affectés à au moins un créneau du poste.
|
||||
const positionShiftIds = new Set(positionShifts.map((s) => s.id));
|
||||
const teamSize = new Set(
|
||||
(assignments.data ?? []).filter((a) => positionShiftIds.has(a.shift_id)).map((a) => a.volunteer_id),
|
||||
).size;
|
||||
|
||||
const shiftStatus = shift
|
||||
? slotStatus(naiveMinutes(shift.starts_at), naiveMinutes(shift.ends_at), now)
|
||||
: null;
|
||||
const myName = me.data ? `${me.data.first_name} ${me.data.last_name}` : null;
|
||||
|
||||
const inProgress = (start: string, end: string) =>
|
||||
slotStatus(naiveMinutes(start), naiveMinutes(end), now) === 'now';
|
||||
const ledShiftIds = new Set(myPositions.flatMap((p) => (shiftsByPosition.get(p.id) ?? []).map((s) => s.id)));
|
||||
const leadingNow = (shifts.data ?? []).some((s) => ledShiftIds.has(s.id) && inProgress(s.starts_at, s.ends_at));
|
||||
const myEntries = myPlanning.data ?? [];
|
||||
const volunteeringNow = myEntries.some(
|
||||
(e) => !ledShiftIds.has(e.shift_id) && inProgress(e.starts_at, e.ends_at),
|
||||
);
|
||||
const currentView: View = view ?? (volunteeringNow && !leadingNow ? 'planning' : 'team');
|
||||
const tabs = account?.volunteer_id ? (
|
||||
<div className="mx-auto grid w-full max-w-[640px] grid-cols-2 border-b-[3px] border-ink">
|
||||
<ViewTab active={currentView === 'team'} live={leadingNow} onClick={() => setView('team')}>
|
||||
{t('mobileLead.view.team')}
|
||||
</ViewTab>
|
||||
<ViewTab active={currentView === 'planning'} live={volunteeringNow} onClick={() => setView('planning')}>
|
||||
{t('mobileLead.view.planning', { count: myEntries.length })}
|
||||
</ViewTab>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (currentView === 'planning' && account?.volunteer_id) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
{tabs}
|
||||
<VolunteerHomeView volunteerId={account.volunteer_id} fallbackName={account.email.split('@')[0]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-[22px] p-4 sm:p-6">
|
||||
{myPositions.map((position) => {
|
||||
const positionShifts = shiftsByPosition.get(position.id) ?? [];
|
||||
const nowIndex = positionShifts.findIndex((s) => naiveMinutes(s.starts_at) > nowMinutes);
|
||||
const nowInsertAt = nowIndex === -1 ? positionShifts.length : nowIndex;
|
||||
|
||||
return (
|
||||
<div key={position.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||
<div className="bg-ink px-[14px] pb-[11px] pt-[11px] text-cream">
|
||||
<div className="font-display text-[19px] font-black leading-tight tracking-tight">
|
||||
{position.name.toUpperCase()}
|
||||
</div>
|
||||
{position.location ? (
|
||||
<div className="font-display text-[13px] font-semibold opacity-80">
|
||||
{position.location}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-[14px] px-[14px] pb-[18px] pt-[13px]">
|
||||
{positionShifts.length === 0 ? (
|
||||
<div className="font-mono text-[11px] leading-relaxed opacity-70">
|
||||
{t('mobileLead.noShift')}
|
||||
</div>
|
||||
) : null}
|
||||
{positionShifts.map((shift, i) => {
|
||||
const team = (assignments.data ?? [])
|
||||
.filter((a) => a.shift_id === shift.id)
|
||||
.map((a) => {
|
||||
const volunteer = volunteersById.get(a.volunteer_id);
|
||||
return {
|
||||
id: a.id,
|
||||
volunteerId: a.volunteer_id,
|
||||
name: volunteer
|
||||
? `${volunteer.first_name} ${volunteer.last_name}`
|
||||
: a.volunteer_id,
|
||||
initials: volunteer
|
||||
? `${volunteer.first_name[0]}${volunteer.last_name[0]}`
|
||||
: '?',
|
||||
hasPhoto: volunteer?.has_photo ?? false,
|
||||
phone: volunteer?.phone ?? '',
|
||||
present: presence[a.id] === 'present',
|
||||
absent: presence[a.id] === 'absent',
|
||||
};
|
||||
});
|
||||
const presentCount = team.filter((m) => m.present).length;
|
||||
|
||||
return (
|
||||
<Fragment key={shift.id}>
|
||||
{i === nowInsertAt ? <NowLine /> : null}
|
||||
<div className="border-2 border-ink">
|
||||
<div className="flex flex-wrap items-stretch gap-2 border-b-2 border-ink bg-sand px-[11px] py-[9px]">
|
||||
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
|
||||
{longDayLabel(shift.starts_at)} ·{' '}
|
||||
{hourRangeLabel(shift.starts_at, shift.ends_at)}
|
||||
</div>
|
||||
<div className="border-2 border-ink bg-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
|
||||
{t('mobileLead.present', { present: presentCount, total: team.length })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-[11px] p-[11px]">
|
||||
{team.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="border-[3px] border-ink bg-cream shadow-brutal-sm"
|
||||
>
|
||||
<div className="flex items-center gap-[10px] px-[11px] py-[10px]">
|
||||
<VolunteerAvatar
|
||||
volunteerId={member.volunteerId}
|
||||
hasPhoto={member.hasPhoto}
|
||||
initials={member.initials}
|
||||
className="h-[38px] w-[38px] text-[13px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-display text-[17px] font-extrabold leading-tight">
|
||||
{member.name}
|
||||
</span>
|
||||
<span className="block font-mono text-[10.5px] opacity-65">
|
||||
{member.phone}
|
||||
</span>
|
||||
</span>
|
||||
{member.present ? (
|
||||
<span className="flex-none border-2 border-ink bg-green px-[7px] py-[5px] font-display text-[11px] font-extrabold text-white">
|
||||
{t('mobileLead.badgePresent')}
|
||||
</span>
|
||||
) : null}
|
||||
{member.absent ? (
|
||||
<span className="flex-none border-2 border-ink bg-orange px-[7px] py-[5px] font-display text-[11px] font-extrabold">
|
||||
{t('mobileLead.badgeAbsent')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 border-t-[3px] border-ink">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPresence((p) => ({ ...p, [member.id]: 'present' }))
|
||||
}
|
||||
className={cx(
|
||||
'min-h-[52px] cursor-pointer border-r-[3px] border-ink bg-paper font-display text-[14px] font-black hover:bg-green hover:text-white',
|
||||
)}
|
||||
>
|
||||
{t('mobileLead.markPresent')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPresence((p) => ({ ...p, [member.id]: 'absent' }))
|
||||
}
|
||||
className="min-h-[52px] cursor-pointer bg-paper font-display text-[14px] font-black hover:bg-orange"
|
||||
>
|
||||
{t('mobileLead.markAbsent')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{team.length === 0 ? (
|
||||
<div className="font-mono text-[11px] leading-relaxed opacity-70">
|
||||
{t('mobileLead.emptyTeam')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{nowInsertAt === positionShifts.length && positionShifts.length > 0 ? (
|
||||
<NowLine />
|
||||
) : null}
|
||||
</div>
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
{tabs}
|
||||
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
|
||||
<div className="bg-ink px-4 pb-4 pt-[15px] text-cream sm:px-6">
|
||||
{myPositions.length > 1 ? (
|
||||
<div className="mb-3 flex flex-wrap gap-[6px]">
|
||||
{myPositions.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPositionId(p.id);
|
||||
setShiftId(null);
|
||||
}}
|
||||
className={cx(
|
||||
'min-h-[36px] cursor-pointer border-2 border-cream px-[10px] font-display text-[11.5px] font-extrabold uppercase',
|
||||
p.id === position.id ? 'bg-yellow text-ink' : 'bg-transparent text-cream',
|
||||
)}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
) : null}
|
||||
<div className="flex items-center gap-3">
|
||||
{me.data ? (
|
||||
<VolunteerAvatar
|
||||
volunteerId={me.data.id}
|
||||
hasPhoto={me.data.has_photo}
|
||||
initials={volunteerInitials(me.data)}
|
||||
className="h-[60px] w-[60px] !border-blue text-[19px]"
|
||||
/>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-mono text-[10.5px] font-medium uppercase leading-none text-blue">
|
||||
{myName ? t('mobileLead.kickerNamed', { name: myName }) : t('mobileLead.kicker')}
|
||||
</div>
|
||||
<div className="mt-[5px] break-words font-display text-[27px] font-black leading-[1.05] tracking-tight">
|
||||
{position.name.toUpperCase()}
|
||||
</div>
|
||||
{position.location ? (
|
||||
<div className="mt-[3px] font-display text-[13px] font-semibold leading-snug text-sand-dark">
|
||||
{position.location}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{positionShifts.length ? (
|
||||
<div className="mt-[13px] grid grid-cols-2 gap-[6px]">
|
||||
{positionShifts.map((s) => {
|
||||
const on = s.id === shift?.id;
|
||||
const status = slotStatus(naiveMinutes(s.starts_at), naiveMinutes(s.ends_at), now);
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setShiftId(s.id)}
|
||||
aria-pressed={on}
|
||||
className={cx(
|
||||
'min-h-[50px] cursor-pointer border-2 border-cream px-2 py-[6px] text-left',
|
||||
on ? 'bg-blue text-ink' : 'bg-transparent text-cream',
|
||||
)}
|
||||
>
|
||||
<span className="block font-display text-[12px] font-black leading-tight">
|
||||
{longDayLabel(`${festivalDay(s.starts_at, boundary)}T12:00`)}
|
||||
</span>
|
||||
<span className="block font-mono text-[11px] font-semibold leading-snug">
|
||||
{clockLabel(naiveMinutes(s.starts_at))}–{clockLabel(naiveMinutes(s.ends_at))} ·{' '}
|
||||
{t(STATUS_KEY[status])}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 font-mono text-[11px] leading-relaxed opacity-70">{t('mobileLead.noShift')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shift ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 border-b-[3px] border-ink">
|
||||
<Counter value={presentCount} label={t('mobileLead.count.present')} className="border-r-2 bg-mint" />
|
||||
<Counter
|
||||
value={team.length - presentCount - absentCount}
|
||||
label={t('mobileLead.count.waiting')}
|
||||
className="border-r-2 bg-paper"
|
||||
/>
|
||||
<Counter
|
||||
value={absentCount}
|
||||
label={t('mobileLead.count.absent')}
|
||||
className={absentCount ? 'bg-orange' : 'bg-paper'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{absentCount ? (
|
||||
<div className="border-b-[3px] border-ink bg-[repeating-linear-gradient(45deg,#FF5C39_0_9px,#E0421F_9px_18px)] px-[14px] py-[11px] font-display text-[15px] font-black leading-snug">
|
||||
{t(absentCount > 1 ? 'mobileLead.absentAlert.other' : 'mobileLead.absentAlert.one', {
|
||||
count: absentCount,
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-[11px] px-[14px] pb-7 pt-[14px] sm:px-6">
|
||||
{setAttendance.isError ? (
|
||||
<div className="border-[3px] border-ink bg-orange px-3 py-2 font-display text-[12.5px] font-bold leading-snug">
|
||||
{t('mobileLead.attendanceError')}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-display text-[12px] font-black tracking-wider">
|
||||
{t('mobileLead.roll', { count: team.length })}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[10px] font-medium uppercase opacity-65">
|
||||
{shiftStatus === 'now'
|
||||
? t('mobileLead.when.now')
|
||||
: shiftStatus === 'next'
|
||||
? t('mobileLead.when.next', { time: clockLabel(naiveMinutes(shift.starts_at)) })
|
||||
: t('mobileLead.when.done')}
|
||||
</span>
|
||||
</div>
|
||||
{team.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className={cx(
|
||||
'border-[3px] border-ink shadow-brutal-md',
|
||||
member.state === 'absent' ? 'bg-orange-blush' : member.state === 'present' ? 'bg-mint-pale' : 'bg-paper',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-[10px] px-[10px] py-[9px]">
|
||||
<VolunteerAvatar
|
||||
volunteerId={member.volunteerId}
|
||||
hasPhoto={member.hasPhoto}
|
||||
initials={member.initials}
|
||||
className="h-12 w-12 !border-2 text-[15px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block break-words font-display text-[16px] font-extrabold leading-tight">
|
||||
{member.name}
|
||||
{member.volunteerId === account?.volunteer_id ? (
|
||||
<span className="ml-[6px] inline-block border-2 border-ink bg-blue px-[5px] py-[2px] align-middle font-display text-[9.5px] font-black leading-none">
|
||||
{t('mobileLead.you')}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{member.phone ? (
|
||||
<span className="block font-mono text-[11px] font-medium leading-snug opacity-70">
|
||||
{member.phone}
|
||||
</span>
|
||||
) : null}
|
||||
{member.state && member.markedAt ? (
|
||||
<span className="block font-mono text-[10px] font-medium leading-snug opacity-60">
|
||||
{t('mobileLead.markedAt', { time: clockLabel(naiveMinutes(member.markedAt)) })}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{member.phone ? (
|
||||
<a
|
||||
href={`tel:${member.phone.replace(/\s+/g, '')}`}
|
||||
aria-label={t('mobileLead.callLabel', { name: member.name })}
|
||||
className="flex h-[46px] w-[46px] flex-none items-center justify-center border-2 border-ink bg-cream font-display text-[10px] font-black"
|
||||
>
|
||||
{t('mobileLead.call')}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 border-t-[3px] border-ink">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mark(member, 'present')}
|
||||
aria-pressed={member.state === 'present'}
|
||||
className={cx(
|
||||
'min-h-[52px] cursor-pointer border-r-[3px] border-ink font-display text-[15px] font-black',
|
||||
member.state === 'present' ? 'bg-green text-white' : 'bg-cream',
|
||||
)}
|
||||
>
|
||||
{t(member.state === 'present' ? 'mobileLead.badgePresent' : 'mobileLead.markPresent')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mark(member, 'absent')}
|
||||
aria-pressed={member.state === 'absent'}
|
||||
className={cx(
|
||||
'min-h-[52px] cursor-pointer font-display text-[15px] font-black',
|
||||
member.state === 'absent' ? 'bg-orange' : 'bg-cream',
|
||||
)}
|
||||
>
|
||||
{t(member.state === 'absent' ? 'mobileLead.badgeAbsent' : 'mobileLead.markAbsent')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{team.length === 0 ? (
|
||||
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
|
||||
{t('mobileLead.emptyTeam')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Message du poste : montré à chaque bénévole affecté, sur son
|
||||
écran (créneau mis en avant et liste des créneaux). */}
|
||||
<div className="border-t-[3px] border-ink bg-sand px-[14px] pb-7 pt-[14px] sm:px-6">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-display text-[12px] font-black tracking-wider">
|
||||
{t('mobileLead.message.title')}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[10px] font-medium opacity-65">
|
||||
{t('mobileLead.message.audience', { count: teamSize })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mb-[9px] mt-1 font-mono text-[10.5px] leading-relaxed opacity-70">
|
||||
{t('mobileLead.message.help')}
|
||||
</p>
|
||||
<MessageEditor
|
||||
key={position.id}
|
||||
value={position.message}
|
||||
updatedAt={position.message_updated_at}
|
||||
placeholder={t('mobileLead.message.placeholder')}
|
||||
saving={updatePosition.isPending}
|
||||
error={updatePosition.error}
|
||||
onSave={(text) => updatePosition.mutateAsync({ id: position.id, body: { message: text } })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Counter({ value, label, className }: { value: number; label: string; className: string }) {
|
||||
return (
|
||||
<div className={cx('border-ink px-3 py-[10px]', className)}>
|
||||
<div className="font-mono text-[24px] font-bold leading-none">{value}</div>
|
||||
<div className="mt-[3px] font-display text-[9.5px] font-extrabold leading-tight tracking-wider">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewTab({
|
||||
active,
|
||||
live,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
/** Un créneau de cette vue est en cours : pastille orange. */
|
||||
live: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cx(
|
||||
'flex min-h-[46px] cursor-pointer items-center justify-center gap-[7px] border-r-[3px] border-ink px-2 font-display text-[12px] font-black uppercase tracking-wide last:border-r-0',
|
||||
active ? 'bg-ink text-yellow' : 'bg-paper text-ink hover:bg-yellow-soft',
|
||||
)}
|
||||
>
|
||||
{live ? <span className="h-[9px] w-[9px] flex-none border-2 border-ink bg-orange" aria-hidden="true" /> : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ function PaymentConfirmationView({ token }: { token: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
|
||||
<Panel>
|
||||
<PanelHeader>{t('publicForm.confirmation.title')}</PanelHeader>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
@@ -273,7 +273,7 @@ function MecenatInfoStep({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
|
||||
<FormLogo url={logoUrl} />
|
||||
<Panel>
|
||||
<PanelHeader>{t('publicForm.mecenat.title')}</PanelHeader>
|
||||
@@ -333,7 +333,7 @@ function CheckoutRetryPanel({ token, logoUrl }: { token: string; logoUrl: string
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
|
||||
<FormLogo url={logoUrl} />
|
||||
<Panel>
|
||||
<PanelHeader>{t('publicForm.retry.title')}</PanelHeader>
|
||||
@@ -406,7 +406,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
|
||||
|
||||
if (volunteerId) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
|
||||
<FormLogo url={logoUrl} />
|
||||
<Panel>
|
||||
<PanelHeader>{t('publicForm.success.title')}</PanelHeader>
|
||||
@@ -437,7 +437,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
|
||||
|
||||
if (!config.data.is_open) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
|
||||
<FormLogo url={logoUrl} />
|
||||
<Panel>
|
||||
<PanelHeader>{t('publicForm.closed.title')}</PanelHeader>
|
||||
@@ -524,7 +524,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[560px] flex-col gap-4 p-4">
|
||||
<div className="mx-auto flex max-w-[560px] flex-col gap-4 p-3 sm:p-4">
|
||||
<FormLogo url={logoUrl} />
|
||||
<Panel>
|
||||
<PanelHeader>{t('publicForm.title')}</PanelHeader>
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import { useStats } from '@/api/hooks';
|
||||
import type { EditionStats, StatsAttendanceCounts } from '@/api/schema';
|
||||
import {
|
||||
BarList,
|
||||
CumulativeLineChart,
|
||||
DataTable,
|
||||
GroupedColumns,
|
||||
Legend,
|
||||
Meter,
|
||||
SERIES,
|
||||
ShareBar,
|
||||
TableToggle,
|
||||
} from '@/components/charts';
|
||||
import { ErrorNote, Loading } from '@/components/state';
|
||||
import { HelpTip, Panel, PanelHeader, StatCard } from '@/components/ui';
|
||||
import { t } from '@/i18n';
|
||||
import { foldAccents } from '@/lib/planning';
|
||||
import { longDayLabel, naiveMinutes, shortDayLabel } from '@/lib/time';
|
||||
import { clockLabel } from '@/lib/slotTimeline';
|
||||
|
||||
const fr = (n: number, digits = 0) =>
|
||||
n.toLocaleString('fr-FR', { maximumFractionDigits: digits, minimumFractionDigits: 0 });
|
||||
const euros = (n: number) =>
|
||||
n.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 2 });
|
||||
const pct = (part: number, whole: number) => (whole > 0 ? Math.round((part / whole) * 100) : 0);
|
||||
/** Ex. « 12 sept. » — dates d'inscription (pas des jours de festival). */
|
||||
const calendarDay = (date: string) =>
|
||||
new Date(`${date}T12:00:00`).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
|
||||
|
||||
/**
|
||||
* Statistiques d'une édition (COORDINATION) : inscriptions (nombre,
|
||||
* évolution), réponses au formulaire, cotisations, et besoins (postes ×
|
||||
* créneaux) face aux bénévoles qui ont répondu — disponibilités déclarées,
|
||||
* vœux de poste, affectations. Tout est calculé côté serveur
|
||||
* (`app/api/stats.py`), par journée de festival comme la grille.
|
||||
*/
|
||||
export function StatsPage() {
|
||||
const stats = useStats();
|
||||
if (stats.isLoading) return <Loading />;
|
||||
if (stats.error) return <ErrorNote error={stats.error} />;
|
||||
if (!stats.data) return null;
|
||||
const s = stats.data;
|
||||
|
||||
const idealSlots = s.needs.by_position.reduce((t2, p) => t2 + p.ideal_slots, 0);
|
||||
const filled = s.needs.by_position.reduce((t2, p) => t2 + p.filled, 0);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[1180px] flex-col gap-5 p-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
tone="yellow"
|
||||
value={fr(s.registrations.total)}
|
||||
label={t('stats.kpi.registered', { count: s.registrations.last_7_days })}
|
||||
/>
|
||||
<StatCard value={euros(s.payments.total_collected)} label={t('stats.kpi.collected')} />
|
||||
<StatCard
|
||||
value={`${pct(filled, idealSlots)} %`}
|
||||
label={t('stats.kpi.coverage', { assigned: fr(filled), ideal: fr(idealSlots) })}
|
||||
/>
|
||||
<StatCard
|
||||
value={fr(s.needs.volunteers_with_assignment)}
|
||||
label={t('stats.kpi.withAssignment', { total: fr(s.registrations.total) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pendant le festival, la présence en direct passe en tête. */}
|
||||
{s.attendance.live_shifts > 0 ? <AttendanceSection stats={s} /> : null}
|
||||
|
||||
{s.registrations.total === 0 ? (
|
||||
<Panel>
|
||||
<p className="p-4 font-mono text-[12px]">{t('stats.empty')}</p>
|
||||
</Panel>
|
||||
) : (
|
||||
<>
|
||||
<RegistrationsSection stats={s} />
|
||||
<div className="grid min-w-0 gap-5 lg:grid-cols-2">
|
||||
<AnswersSection stats={s} />
|
||||
<PaymentsSection stats={s} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<NeedsSection stats={s} />
|
||||
{s.attendance.live_shifts === 0 ? <AttendanceSection stats={s} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, help, children }: { title: string; help?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Panel className="min-w-0">
|
||||
<PanelHeader className="flex items-center gap-[6px]">
|
||||
{title}
|
||||
{help ? <HelpTip text={help} /> : null}
|
||||
</PanelHeader>
|
||||
<div className="flex min-w-0 flex-col gap-3 p-4">{children}</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function SubTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="font-display text-[11px] font-extrabold uppercase tracking-wide">{children}</h3>
|
||||
);
|
||||
}
|
||||
|
||||
function RegistrationsSection({ stats }: { stats: EditionStats }) {
|
||||
const points = stats.registrations.by_day;
|
||||
return (
|
||||
<Section title={t('stats.registrations.title')} help={t('stats.registrations.help')}>
|
||||
<CumulativeLineChart
|
||||
points={points}
|
||||
dateLabel={calendarDay}
|
||||
valueLabel={t('stats.registrations.cumulative')}
|
||||
dailyLabel={t('stats.registrations.daily')}
|
||||
/>
|
||||
<TableToggle label={t('stats.showTable')}>
|
||||
<DataTable
|
||||
head={[t('stats.col.date'), t('stats.registrations.daily'), t('stats.registrations.cumulative')]}
|
||||
rows={points
|
||||
.filter((p) => p.count > 0)
|
||||
.map((p) => [calendarDay(p.date), fr(p.count), fr(p.cumulative)])}
|
||||
/>
|
||||
</TableToggle>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function AnswersSection({ stats }: { stats: EditionStats }) {
|
||||
const a = stats.answers;
|
||||
const fields: Array<[string, number]> = [
|
||||
['email', a.email],
|
||||
['phone', a.phone],
|
||||
['available', a.available],
|
||||
['positionWishes', a.position_wishes],
|
||||
['artistWishes', a.artist_wishes],
|
||||
['companionWishes', a.companion_wishes],
|
||||
['companionNote', a.companion_note],
|
||||
['photo', a.photo],
|
||||
['comment', a.comment],
|
||||
];
|
||||
const rows = fields.map(([key, count]) => ({
|
||||
key,
|
||||
label: t(`stats.answers.${key}`),
|
||||
value: pct(count, a.total),
|
||||
valueLabel: `${pct(count, a.total)} %`,
|
||||
tip: `${fr(count)} / ${fr(a.total)} (${pct(count, a.total)} %)`,
|
||||
}));
|
||||
const artists = [...stats.artist_wishes].sort((x, y) => y.count - x.count);
|
||||
return (
|
||||
<Section title={t('stats.answers.title')} help={t('stats.answers.help')}>
|
||||
<BarList rows={rows} max={100} />
|
||||
<TableToggle label={t('stats.showTable')}>
|
||||
<DataTable
|
||||
head={[t('stats.col.answer'), t('stats.col.volunteers'), t('stats.col.share')]}
|
||||
rows={fields.map(([key, count]) => [t(`stats.answers.${key}`), fr(count), `${pct(count, a.total)} %`])}
|
||||
/>
|
||||
</TableToggle>
|
||||
|
||||
{artists.length > 0 ? (
|
||||
<>
|
||||
<SubTitle>{t('stats.artists.title')}</SubTitle>
|
||||
<BarList
|
||||
rows={artists.map((ar) => ({
|
||||
key: ar.id,
|
||||
label: `${ar.name} · ${shortDayLabel(ar.date)}`,
|
||||
value: ar.count,
|
||||
valueLabel: fr(ar.count),
|
||||
}))}
|
||||
/>
|
||||
<TableToggle label={t('stats.showTable')}>
|
||||
<DataTable
|
||||
head={[t('stats.col.artist'), t('stats.col.day'), t('stats.col.volunteers')]}
|
||||
rows={artists.map((ar) => [ar.name, longDayLabel(ar.date), fr(ar.count)])}
|
||||
/>
|
||||
</TableToggle>
|
||||
</>
|
||||
) : null}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentsSection({ stats }: { stats: EditionStats }) {
|
||||
const p = stats.payments;
|
||||
return (
|
||||
<Section title={t('stats.payments.title')} help={t('stats.payments.help')}>
|
||||
<ShareBar
|
||||
segments={[
|
||||
{ label: t('stats.payments.online'), value: p.online, color: SERIES.blue },
|
||||
{ label: t('stats.payments.onSite'), value: p.on_site, color: SERIES.orange },
|
||||
{ label: t('stats.payments.none'), value: p.none, color: SERIES.muted },
|
||||
]}
|
||||
/>
|
||||
|
||||
<SubTitle>{t('stats.payments.byOption')}</SubTitle>
|
||||
{p.by_option.length === 0 ? (
|
||||
<p className="font-mono text-[11px] opacity-60">{t('stats.payments.noOnline')}</p>
|
||||
) : (
|
||||
<DataTable
|
||||
head={[t('stats.col.paymentType'), t('stats.col.volunteers'), t('stats.col.amount')]}
|
||||
rows={[
|
||||
...p.by_option.map((o) => [
|
||||
// « mécénat » seulement si le libellé ne le dit pas déjà.
|
||||
o.mecenat && !foldAccents(o.label).toLowerCase().includes('mecenat')
|
||||
? `${o.label} · ${t('stats.payments.mecenatTag')}`
|
||||
: o.label,
|
||||
fr(o.count),
|
||||
euros(o.amount_total),
|
||||
]),
|
||||
[<b key="t">{t('stats.payments.total')}</b>, <b key="c">{fr(p.online)}</b>, <b key="a">{euros(p.total_collected)}</b>],
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{p.mecenat_count > 0 ? (
|
||||
<p className="font-mono text-[11px]">
|
||||
{t('stats.payments.mecenat', { count: fr(p.mecenat_count), amount: euros(p.mecenat_total) })}
|
||||
</p>
|
||||
) : null}
|
||||
{p.pending_unfinished > 0 ? (
|
||||
<p className="border-2 border-ink bg-yellow-soft p-[8px] font-mono text-[11px] leading-relaxed">
|
||||
{t('stats.payments.unfinished', { count: fr(p.pending_unfinished) })}
|
||||
</p>
|
||||
) : null}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function NeedsSection({ stats }: { stats: EditionStats }) {
|
||||
const n = stats.needs;
|
||||
if (n.by_day.length === 0 && n.by_position.every((p) => p.shifts === 0)) {
|
||||
return (
|
||||
<Section title={t('stats.needs.title')}>
|
||||
<p className="font-mono text-[11px] opacity-60">{t('stats.needs.empty')}</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
const series = [
|
||||
{ label: t('stats.needs.ideal'), color: SERIES.blue },
|
||||
{ label: t('stats.needs.assigned'), color: SERIES.aqua },
|
||||
];
|
||||
return (
|
||||
<Section title={t('stats.needs.title')} help={t('stats.needs.help')}>
|
||||
<SubTitle>{t('stats.needs.byDay')}</SubTitle>
|
||||
<Legend items={series} />
|
||||
<GroupedColumns
|
||||
unit={t('stats.unit.hours')}
|
||||
series={series}
|
||||
groups={n.by_day.map((d) => ({
|
||||
key: d.date,
|
||||
label: shortDayLabel(d.date),
|
||||
values: [d.ideal_hours, d.assigned_hours],
|
||||
}))}
|
||||
/>
|
||||
<TableToggle label={t('stats.showTable')}>
|
||||
<DataTable
|
||||
head={[
|
||||
t('stats.col.day'),
|
||||
t('stats.col.shifts'),
|
||||
t('stats.needs.min'),
|
||||
t('stats.needs.ideal'),
|
||||
t('stats.needs.assigned'),
|
||||
]}
|
||||
rows={n.by_day.map((d) => [
|
||||
longDayLabel(d.date),
|
||||
fr(d.shifts),
|
||||
fr(d.min_hours, 1),
|
||||
fr(d.ideal_hours, 1),
|
||||
fr(d.assigned_hours, 1),
|
||||
])}
|
||||
/>
|
||||
</TableToggle>
|
||||
|
||||
<SubTitle>{t('stats.needs.byPosition')}</SubTitle>
|
||||
<div className="overflow-x-auto">
|
||||
<DataTable
|
||||
head={[
|
||||
t('stats.col.position'),
|
||||
t('stats.col.shifts'),
|
||||
t('stats.col.minSlots'),
|
||||
t('stats.col.idealSlots'),
|
||||
t('stats.col.filled'),
|
||||
t('stats.col.wishes'),
|
||||
t('stats.col.wishesPerSlot'),
|
||||
]}
|
||||
rows={n.by_position
|
||||
.filter((p) => p.shifts > 0 || p.wishes > 0)
|
||||
.map((p) => [
|
||||
p.name,
|
||||
fr(p.shifts),
|
||||
fr(p.min_slots),
|
||||
fr(p.ideal_slots),
|
||||
<span key="m" className="flex justify-end">
|
||||
<Meter
|
||||
value={p.filled}
|
||||
max={p.ideal_slots}
|
||||
label={`${fr(p.filled)} / ${fr(p.ideal_slots)}`}
|
||||
extra={p.assigned > p.filled ? `+${fr(p.assigned - p.filled)}` : undefined}
|
||||
title={
|
||||
p.assigned > p.filled
|
||||
? t('stats.needs.filledWithExtra', {
|
||||
filled: fr(p.filled),
|
||||
ideal: fr(p.ideal_slots),
|
||||
extra: fr(p.assigned - p.filled),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</span>,
|
||||
fr(p.wishes),
|
||||
p.ideal_slots > 0 ? fr(p.wishes / p.ideal_slots, 2) : '—',
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">{t('stats.needs.wishesHint')}</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function attendanceSegments(c: StatsAttendanceCounts) {
|
||||
return [
|
||||
{ label: t('stats.attendance.present'), value: c.present, color: SERIES.aqua },
|
||||
{ label: t('stats.attendance.absent'), value: c.absent, color: SERIES.orange },
|
||||
{ label: t('stats.attendance.unmarked'), value: c.unmarked, color: SERIES.muted },
|
||||
];
|
||||
}
|
||||
|
||||
/** Ligne de tableau : attendus, présents, absents, non pointés, et part de
|
||||
* présents parmi les bénévoles déjà pointés. */
|
||||
function attendanceCells(c: StatsAttendanceCounts) {
|
||||
const marked = c.present + c.absent;
|
||||
return [
|
||||
fr(c.assigned),
|
||||
fr(c.present),
|
||||
c.absent ? <b key="a">{fr(c.absent)}</b> : '0',
|
||||
fr(c.unmarked),
|
||||
<span key="m" className="flex justify-end">
|
||||
<Meter
|
||||
value={c.present}
|
||||
max={marked}
|
||||
label={marked ? `${pct(c.present, marked)} %` : '—'}
|
||||
title={t('stats.attendance.rateTip', { present: fr(c.present), marked: fr(marked) })}
|
||||
/>
|
||||
</span>,
|
||||
];
|
||||
}
|
||||
|
||||
function AttendanceSection({ stats }: { stats: EditionStats }) {
|
||||
const a = stats.attendance;
|
||||
const head = [
|
||||
t('stats.col.position'),
|
||||
t('stats.attendance.expected'),
|
||||
t('stats.attendance.present'),
|
||||
t('stats.attendance.absent'),
|
||||
t('stats.attendance.unmarked'),
|
||||
t('stats.attendance.rate'),
|
||||
];
|
||||
const liveRows = a.by_position.filter((p) => p.live_shifts > 0);
|
||||
const pastRows = a.by_position.filter((p) => p.past.assigned > 0);
|
||||
return (
|
||||
<Section title={t('stats.attendance.title')} help={t('stats.attendance.help')}>
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<SubTitle>{t('stats.attendance.live')}</SubTitle>
|
||||
<span className="ml-auto font-mono text-[10px] opacity-60">
|
||||
{t('stats.attendance.asOf', { time: clockLabel(naiveMinutes(a.as_of)) })}
|
||||
</span>
|
||||
</div>
|
||||
{a.live_shifts === 0 ? (
|
||||
<p className="font-mono text-[11px] opacity-60">{t('stats.attendance.noLive')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-mono text-[11px]">
|
||||
{t('stats.attendance.liveSummary', { shifts: fr(a.live_shifts), expected: fr(a.live.assigned) })}
|
||||
</p>
|
||||
<ShareBar segments={attendanceSegments(a.live)} />
|
||||
<div className="overflow-x-auto">
|
||||
<DataTable
|
||||
head={head}
|
||||
rows={liveRows.map((p) => [
|
||||
`${p.name} · ${t('stats.attendance.shiftsCount', { count: p.live_shifts })}`,
|
||||
...attendanceCells(p.live),
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SubTitle>{t('stats.attendance.past')}</SubTitle>
|
||||
{a.past.assigned === 0 ? (
|
||||
<p className="font-mono text-[11px] opacity-60">{t('stats.attendance.noPast')}</p>
|
||||
) : (
|
||||
<>
|
||||
<ShareBar segments={attendanceSegments(a.past)} />
|
||||
<div className="overflow-x-auto">
|
||||
<DataTable
|
||||
head={head}
|
||||
rows={[
|
||||
...pastRows.map((p) => [p.name, ...attendanceCells(p.past)]),
|
||||
[<b key="t">{t('stats.payments.total')}</b>, ...attendanceCells(a.past)],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{a.past.unmarked > 0 ? (
|
||||
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
|
||||
{t('stats.attendance.unmarkedHint', { count: fr(a.past.unmarked) })}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,36 @@
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useVolunteer, useVolunteerPlanning } from '@/api/hooks';
|
||||
import { useDayParts, useEdition, useVolunteer, useVolunteerPlanning } from '@/api/hooks';
|
||||
import type { VolunteerPlanningEntry } from '@/api/schema';
|
||||
import { ErrorNote, Loading } from '@/components/state';
|
||||
import { NowLine } from '@/components/NowLine';
|
||||
import { VolunteerAvatar } from '@/components/VolunteerAvatar';
|
||||
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
|
||||
import {
|
||||
buildRibbon,
|
||||
clockLabel,
|
||||
countdownLabel,
|
||||
durationLabel,
|
||||
messageDateLabel,
|
||||
overlapOf,
|
||||
slotStatus,
|
||||
type SlotStatus,
|
||||
} from '@/lib/slotTimeline';
|
||||
import { longDayLabel, naiveMinutes } from '@/lib/time';
|
||||
import { useNowMinutes } from '@/lib/useNowMinutes';
|
||||
import { t } from '@/i18n';
|
||||
import { volunteerInitials } from '@/lib/volunteer';
|
||||
|
||||
/**
|
||||
* Contenu de l'écran bénévole (planning : poste, jour, horaire, lieu,
|
||||
* responsable et téléphone à contacter, repère « MAINTENANT ») — extrait de
|
||||
* `MobileVolunteerPage` pour être également montré à un compte RÉFÉRENT
|
||||
* (`LEAD`) qui n'est actuellement désigné référent d'aucun poste : dans ce
|
||||
* cas il doit voir sa vue de bénévole classique plutôt qu'un écran référent
|
||||
* vide (voir `MobileLeadPage.tsx`).
|
||||
* Contenu de l'écran bénévole (maquette `design/Planning bénévole
|
||||
* mobile.dc.html`) : le créneau en cours ou le prochain mis en avant (compte
|
||||
* à rebours, lieu, référent à appeler), puis le planning par journée de
|
||||
* festival (§ 3 — une nuit reste sur le jour où elle a commencé), chacune
|
||||
* avec sa frise horaire et ses créneaux (terminé / en cours / à venir,
|
||||
* chevauchements signalés). Pensé pour le téléphone ; sur un écran plus
|
||||
* large, la même colonne, centrée. Extrait de `MobileVolunteerPage` pour
|
||||
* être également montré à un compte RÉFÉRENT (`LEAD`) qui n'est
|
||||
* actuellement désigné référent d'aucun poste (voir `MobileLeadPage.tsx`).
|
||||
*/
|
||||
export function VolunteerHomeView({
|
||||
volunteerId,
|
||||
@@ -26,13 +42,20 @@ export function VolunteerHomeView({
|
||||
}) {
|
||||
const meVolunteer = useVolunteer(volunteerId);
|
||||
const planning = useVolunteerPlanning(volunteerId);
|
||||
const nowMinutes = useNowMinutes();
|
||||
const dayParts = useDayParts();
|
||||
const edition = useEdition();
|
||||
const now = useNowMinutes();
|
||||
const boundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
|
||||
// Hooks appelés inconditionnellement avant les retours anticipés ci-dessous.
|
||||
const slots = useMemo(
|
||||
() =>
|
||||
[...(planning.data ?? [])].sort(
|
||||
(a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at),
|
||||
),
|
||||
(planning.data ?? [])
|
||||
.map((entry) => ({
|
||||
entry,
|
||||
start: naiveMinutes(entry.starts_at),
|
||||
end: naiveMinutes(entry.ends_at),
|
||||
}))
|
||||
.sort((a, b) => a.start - b.start),
|
||||
[planning.data],
|
||||
);
|
||||
|
||||
@@ -41,91 +64,368 @@ export function VolunteerHomeView({
|
||||
|
||||
const firstName = meVolunteer.data?.first_name ?? fallbackName ?? '';
|
||||
const initials = meVolunteer.data
|
||||
? `${meVolunteer.data.first_name[0]}${meVolunteer.data.last_name[0]}`
|
||||
? volunteerInitials(meVolunteer.data)
|
||||
: (firstName[0] ?? '?');
|
||||
|
||||
const totalHours =
|
||||
Math.round(
|
||||
slots.reduce((sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)) / 60, 0) *
|
||||
10,
|
||||
) / 10;
|
||||
|
||||
// Le repère « maintenant » se place juste avant le premier créneau pas
|
||||
// encore commencé (le prochain à venir) ; en fin de liste si tous sont déjà
|
||||
// passés.
|
||||
const nowIndex = slots.findIndex((s) => naiveMinutes(s.starts_at) > nowMinutes);
|
||||
const nowInsertAt = nowIndex === -1 ? slots.length : nowIndex;
|
||||
const days = new Map<string, Slot[]>();
|
||||
for (const slot of slots) {
|
||||
const day = festivalDay(slot.entry.starts_at, boundary);
|
||||
days.set(day, [...(days.get(day) ?? []), slot]);
|
||||
}
|
||||
const totalMinutes = slots.reduce((sum, s) => sum + s.end - s.start, 0);
|
||||
const featured = slots.find((s) => now < s.end) ?? null;
|
||||
|
||||
return (
|
||||
<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="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="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]">
|
||||
{t('mobileVol.greeting', { name: firstName.toUpperCase() })}
|
||||
</div>
|
||||
<div className="flex items-center gap-[14px] bg-ink p-4 text-cream sm:px-6 sm:py-6">
|
||||
<VolunteerAvatar
|
||||
volunteerId={volunteerId}
|
||||
hasPhoto={meVolunteer.data?.has_photo ?? false}
|
||||
initials={initials}
|
||||
className="h-[76px] w-[76px] !border-yellow !bg-orange text-[24px]"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-display text-[30px] font-black leading-none tracking-tight">
|
||||
{t('mobileVol.greeting', { name: firstName })}
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
{t('mobileVol.slots', { count: slots.length })}
|
||||
</div>
|
||||
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
|
||||
{t('mobileVol.hours', { count: totalHours })}
|
||||
<div className="mt-[6px] font-display text-[14px] font-semibold leading-snug text-sand-dark">
|
||||
{slots.length
|
||||
? t(slots.length > 1 ? 'mobileVol.summary.other' : 'mobileVol.summary.one', {
|
||||
count: slots.length,
|
||||
duration: durationLabel(totalMinutes),
|
||||
days: days.size,
|
||||
})
|
||||
: t('mobileVol.summary.none')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-[14px] p-4 sm:p-6">
|
||||
{slots.map((entry, i) => (
|
||||
<Fragment key={entry.assignment_id}>
|
||||
{i === nowInsertAt ? <NowLine /> : null}
|
||||
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
|
||||
<span className="font-display text-[15px] font-black">
|
||||
{longDayLabel(entry.starts_at)}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[16px] font-bold text-yellow">
|
||||
{hourRangeLabel(entry.starts_at, entry.ends_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-[11px]">
|
||||
<div className="font-display text-[23px] font-black leading-tight tracking-tight">
|
||||
{entry.position_name.toUpperCase()}
|
||||
</div>
|
||||
{entry.position_location ? (
|
||||
<div className="mt-[9px] border-t-2 border-ink pt-[9px] font-mono text-[12px]">
|
||||
{t('mobileVol.location', { location: entry.position_location })}
|
||||
</div>
|
||||
) : null}
|
||||
{entry.referent_name ? (
|
||||
<div className="mt-[9px] font-mono text-[12px]">
|
||||
{t('mobileVol.referent', { name: entry.referent_name })}
|
||||
</div>
|
||||
) : null}
|
||||
{entry.referent_phone ? (
|
||||
<div className="mt-[3px] font-mono text-[12px]">
|
||||
{t('mobileVol.phone', { phone: entry.referent_phone })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{slots.length ? (
|
||||
<div className="px-[14px] pt-4 sm:px-6">
|
||||
{featured ? (
|
||||
<FeaturedSlot slot={featured} all={slots} now={now} boundary={boundary} />
|
||||
) : (
|
||||
<div className="border-[3px] border-ink bg-green p-4 font-display text-[19px] font-black leading-snug text-white shadow-brutal-lg">
|
||||
{t('mobileVol.allDone', { name: firstName })}
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-[22px] px-[14px] pb-7 pt-[22px] sm:px-6">
|
||||
{[...days.entries()].map(([day, daySlots]) => (
|
||||
<DaySection
|
||||
key={day}
|
||||
day={day}
|
||||
slots={daySlots}
|
||||
all={slots}
|
||||
now={now}
|
||||
featuredId={featured?.entry.assignment_id ?? null}
|
||||
/>
|
||||
))}
|
||||
{nowInsertAt === slots.length && slots.length > 0 ? <NowLine /> : null}
|
||||
{slots.length === 0 ? (
|
||||
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
|
||||
{t('mobileVol.noSlots')}
|
||||
</div>
|
||||
) : null}
|
||||
{edition.data?.volunteer_message ? (
|
||||
<div className="border-[3px] border-ink bg-blue p-3">
|
||||
<div className="font-display text-[12px] font-black leading-none tracking-wider">
|
||||
{t('mobileVol.orgMessage.title')}
|
||||
</div>
|
||||
<div className="mt-[7px] whitespace-pre-line break-words font-display text-[14px] font-semibold leading-normal [text-wrap:pretty]">
|
||||
{edition.data.volunteer_message}
|
||||
</div>
|
||||
{edition.data.volunteer_message_updated_at ? (
|
||||
<div className="mt-2 font-mono text-[10.5px] font-medium uppercase leading-none">
|
||||
{t('mobileVol.orgMessage.signature', {
|
||||
when: messageDateLabel(edition.data.volunteer_message_updated_at, now),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
entry: VolunteerPlanningEntry;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Premier autre créneau du bénévole qui chevauche `slot`, et sur quelle
|
||||
* plage. */
|
||||
function findOverlap(slot: Slot, all: Slot[]) {
|
||||
for (const other of all) {
|
||||
if (other.entry.assignment_id === slot.entry.assignment_id) continue;
|
||||
const range = overlapOf(slot, other);
|
||||
if (range) return { other, range };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function FeaturedSlot({
|
||||
slot,
|
||||
all,
|
||||
now,
|
||||
boundary,
|
||||
}: {
|
||||
slot: Slot;
|
||||
all: Slot[];
|
||||
now: number;
|
||||
boundary: string;
|
||||
}) {
|
||||
const running = slotStatus(slot.start, slot.end, now) === 'now';
|
||||
const overlap = findOverlap(slot, all.filter((s) => now < s.end));
|
||||
const { entry } = slot;
|
||||
return (
|
||||
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||
<div
|
||||
className={cx(
|
||||
'flex items-center gap-2 border-b-[3px] border-ink px-3 py-[9px]',
|
||||
running ? 'bg-orange' : 'bg-yellow',
|
||||
)}
|
||||
>
|
||||
<span className="font-display text-[12px] font-black tracking-wider">
|
||||
{t(running ? 'mobileVol.featured.now' : 'mobileVol.featured.next')}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[12px] font-bold">
|
||||
{countdownLabel(slot.start, slot.end, now)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-[13px] pb-[14px] pt-[13px]">
|
||||
<div className="font-display text-[13px] font-bold opacity-70">
|
||||
{longDayLabel(`${festivalDay(entry.starts_at, boundary)}T12:00`)}
|
||||
</div>
|
||||
<div className="mt-[5px] break-words font-display text-[30px] font-black leading-[1.05] tracking-tight">
|
||||
{entry.position_name.toUpperCase()}
|
||||
</div>
|
||||
<div className="mt-2 font-mono text-[24px] font-bold leading-none">
|
||||
{clockLabel(slot.start)} → {clockLabel(slot.end)}
|
||||
</div>
|
||||
{entry.position_location || entry.position_message ? (
|
||||
<div className="mt-[11px] flex flex-col gap-1 border-t-2 border-ink pt-[10px]">
|
||||
{entry.position_location ? (
|
||||
<div className="font-display text-[14px] font-bold leading-snug">{entry.position_location}</div>
|
||||
) : null}
|
||||
{entry.position_message ? <PositionMessage entry={entry} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{overlap ? (
|
||||
<div className="mt-[11px] border-2 border-ink bg-yellow-soft px-[10px] py-2 font-display text-[12.5px] font-bold leading-snug">
|
||||
{t('mobileVol.featured.overlap', {
|
||||
position: overlap.other.entry.position_name,
|
||||
start: clockLabel(overlap.range.start),
|
||||
end: clockLabel(overlap.range.end),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{entry.referent_name ? <ReferentContact entry={entry} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Message du poste (consignes du référent). */
|
||||
function PositionMessage({ entry }: { entry: VolunteerPlanningEntry }) {
|
||||
return (
|
||||
<div className="whitespace-pre-line break-words font-mono text-[12px] font-medium leading-snug opacity-75">
|
||||
{entry.position_message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReferentContact({ entry }: { entry: VolunteerPlanningEntry }) {
|
||||
const content = (
|
||||
<>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-mono text-[10.5px] font-medium leading-snug opacity-70">
|
||||
{t('mobileVol.featured.referent')}
|
||||
</span>
|
||||
<span className="block font-display text-[15px] font-extrabold leading-tight">
|
||||
{entry.referent_name}
|
||||
</span>
|
||||
</span>
|
||||
{entry.referent_phone ? (
|
||||
<span className="flex-none bg-ink px-3 py-[10px] font-display text-[13px] font-black leading-none text-yellow">
|
||||
{t('mobileVol.featured.call')}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const className =
|
||||
'mt-3 flex min-h-[58px] items-center gap-[10px] border-[3px] border-ink bg-cream px-3 py-[6px]';
|
||||
return entry.referent_phone ? (
|
||||
<a href={`tel:${entry.referent_phone.replace(/\s+/g, '')}`} className={className}>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<div className={className}>{content}</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BAR_COLOR: Record<SlotStatus, string> = {
|
||||
done: 'bg-sand-mid',
|
||||
now: 'bg-orange',
|
||||
next: 'bg-ink',
|
||||
};
|
||||
const LANE_PX = 18;
|
||||
|
||||
function DaySection({
|
||||
day,
|
||||
slots,
|
||||
all,
|
||||
now,
|
||||
featuredId,
|
||||
}: {
|
||||
day: string;
|
||||
slots: Slot[];
|
||||
all: Slot[];
|
||||
now: number;
|
||||
/** Créneau déjà mis en avant plus haut (son message y figure déjà). */
|
||||
featuredId: string | null;
|
||||
}) {
|
||||
const ribbon = buildRibbon(slots, now);
|
||||
const minutes = slots.reduce((sum, s) => sum + s.end - s.start, 0);
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2 border-b-[3px] border-ink pb-[6px]">
|
||||
<span className="font-display text-[18px] font-black leading-none tracking-tight">
|
||||
{longDayLabel(`${day}T12:00`)}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[12px] font-semibold leading-none">
|
||||
{t(slots.length > 1 ? 'mobileVol.day.other' : 'mobileVol.day.one', {
|
||||
count: slots.length,
|
||||
duration: durationLabel(minutes),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative mb-1 mt-[10px]"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
height: ribbon.lanes * LANE_PX,
|
||||
// Une graduation par heure.
|
||||
backgroundImage: `repeating-linear-gradient(90deg, transparent 0 calc(100% / ${ribbon.hours} - 1px), #CFC7B4 calc(100% / ${ribbon.hours} - 1px) calc(100% / ${ribbon.hours}))`,
|
||||
}}
|
||||
>
|
||||
{ribbon.bars.map((bar, i) => (
|
||||
<span
|
||||
key={slots[i].entry.assignment_id}
|
||||
className={cx('absolute h-[14px] border-2 border-ink', BAR_COLOR[slotStatus(slots[i].start, slots[i].end, now)])}
|
||||
style={{ left: `${bar.left}%`, width: `${bar.width}%`, top: bar.lane * LANE_PX }}
|
||||
/>
|
||||
))}
|
||||
{ribbon.now !== null ? (
|
||||
<span className="absolute -bottom-1 -top-1 w-[3px] bg-orange" style={{ left: `${ribbon.now}%` }} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="relative mb-[10px] h-[10px] font-mono text-[9.5px] font-medium leading-none opacity-65" aria-hidden="true">
|
||||
{ribbon.ticks.map((tick, i) => (
|
||||
<span
|
||||
key={tick.label + i}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: `${tick.left}%`,
|
||||
transform: tick.left === 0 ? undefined : tick.left >= 100 ? 'translateX(-100%)' : 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
{tick.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-[9px]">
|
||||
{slots.map((slot) => (
|
||||
<SlotCard
|
||||
key={slot.entry.assignment_id}
|
||||
slot={slot}
|
||||
all={all}
|
||||
now={now}
|
||||
showMessage={slot.entry.assignment_id !== featuredId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotCard({
|
||||
slot,
|
||||
all,
|
||||
now,
|
||||
showMessage,
|
||||
}: {
|
||||
slot: Slot;
|
||||
all: Slot[];
|
||||
now: number;
|
||||
showMessage: boolean;
|
||||
}) {
|
||||
const status = slotStatus(slot.start, slot.end, now);
|
||||
const overlap = status === 'done' ? null : findOverlap(slot, all);
|
||||
const { entry } = slot;
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'border-[3px] border-ink',
|
||||
status === 'now' ? 'bg-orange-pale' : 'bg-paper',
|
||||
status === 'done' && 'opacity-55',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-stretch">
|
||||
<div
|
||||
className={cx(
|
||||
'flex w-[76px] flex-none flex-col items-center justify-center gap-[5px] border-r-[3px] border-ink py-[10px] font-mono text-[16px] font-bold leading-none',
|
||||
status === 'now' ? 'bg-orange text-ink' : status === 'done' ? 'bg-sand-dark text-ink' : 'bg-ink text-yellow',
|
||||
)}
|
||||
>
|
||||
<span>{clockLabel(slot.start)}</span>
|
||||
<span className="h-[9px] w-[2px] bg-current opacity-60" aria-hidden="true" />
|
||||
<span>{clockLabel(slot.end)}</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 px-[11px] py-[10px]">
|
||||
<div className="flex items-start gap-[6px]">
|
||||
<span className="min-w-0 flex-1 break-words font-display text-[18px] font-black leading-tight">
|
||||
{entry.position_name}
|
||||
</span>
|
||||
{status === 'done' ? (
|
||||
<span className="flex-none border-2 border-ink px-[6px] py-[3px] font-display text-[10px] font-black leading-none">
|
||||
{t('mobileVol.slot.done')}
|
||||
</span>
|
||||
) : null}
|
||||
{status === 'now' ? (
|
||||
<span className="flex-none border-2 border-ink bg-orange px-[6px] py-[3px] font-display text-[10px] font-black leading-none">
|
||||
{t('mobileVol.slot.now')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{entry.position_location ? (
|
||||
<div className="mt-1 font-display text-[13px] font-semibold leading-snug">{entry.position_location}</div>
|
||||
) : null}
|
||||
<div className="mt-[2px] font-mono text-[11px] font-medium leading-snug opacity-70">
|
||||
{durationLabel(slot.end - slot.start)}
|
||||
{entry.referent_name ? t('mobileVol.slot.referent', { name: entry.referent_name }) : null}
|
||||
</div>
|
||||
{showMessage && status !== 'done' && entry.position_message ? (
|
||||
<div className="mt-[6px] border-l-[3px] border-ink pl-2">
|
||||
<PositionMessage entry={entry} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{overlap ? (
|
||||
<div className="border-t-[3px] border-ink bg-yellow-soft px-[11px] py-2 font-display text-[12.5px] font-bold leading-snug">
|
||||
{t('mobileVol.slot.overlap', {
|
||||
position: overlap.other.entry.position_name,
|
||||
start: clockLabel(overlap.range.start),
|
||||
end: clockLabel(overlap.range.end),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user