Compare commits
61
Commits
f565feb3bc
...
main
@@ -29,6 +29,12 @@ SMTP_FROM=Festival des bénévoles <no-reply@example.test>
|
||||
# Alternative OVH si le 587/STARTTLS échoue : SMTP_PORT=465, SMTP_SSL=true,
|
||||
# SMTP_STARTTLS=false
|
||||
|
||||
# Formulaire public d'inscription (/inscription). Vide en dev ⇒ vérification
|
||||
# hCaptcha désactivée (voir app/forms/captcha.py) — aucun compte requis pour
|
||||
# développer en local. Couple site/secret d'un même compte https://hcaptcha.com.
|
||||
HCAPTCHA_SECRET_KEY=
|
||||
HCAPTCHA_SITE_KEY=
|
||||
|
||||
# Frontend (service `frontend` du docker-compose).
|
||||
# mock (défaut) : jeu de démonstration en mémoire, aucun backend requis.
|
||||
# http : API réelle — renseigner alors VITE_EDITION_ID avec l'UUID
|
||||
|
||||
+13
-70
@@ -1,6 +1,5 @@
|
||||
# CI/CD Gitea Actions : tests à chaque push, puis sur `main` seulement,
|
||||
# build des images (registre de conteneurs intégré à Gitea) et déploiement
|
||||
# SSH sur le VPS.
|
||||
# CI/CD Gitea Actions : à chaque push sur `main`, build des images
|
||||
# (registre de conteneurs intégré à Gitea) puis déploiement SSH sur le VPS.
|
||||
#
|
||||
# Nécessite un runner Gitea Actions (act_runner, exécuteur "docker") attaché
|
||||
# au dépôt ou à l'organisation.
|
||||
@@ -19,77 +18,22 @@
|
||||
# DEPLOY_PATH dossier du projet sur le VPS (ex. /opt/benevoles)
|
||||
# VITE_EDITION_ID UUID de l'édition de production, à renseigner APRÈS
|
||||
# la toute première mise en service (voir deploy/README.md)
|
||||
# VITE_HCAPTCHA_SITE_KEY clé de site hCaptcha (publique) — figée au build
|
||||
# du frontend, voir app/api/forms.py et deploy/README.md.
|
||||
# Vide ⇒ formulaire public sans vérification anti-robot.
|
||||
|
||||
name: ci-cd
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-cd-${{ gitea.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
backend-test:
|
||||
runs-on: ubuntu-latest
|
||||
container: python:3.12-slim
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: benevoles
|
||||
POSTGRES_PASSWORD: benevoles
|
||||
POSTGRES_DB: benevoles_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
env:
|
||||
TEST_DATABASE_URL: postgresql+psycopg://benevoles:benevoles@postgres:5432/benevoles_test
|
||||
DATABASE_URL: postgresql+psycopg://benevoles:benevoles@postgres:5432/benevoles_test
|
||||
steps:
|
||||
- name: Installer git et node
|
||||
run: apt-get update && apt-get install -y --no-install-recommends git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
- name: Installer uv
|
||||
run: pip install --no-cache-dir uv
|
||||
- name: Installer les dépendances
|
||||
working-directory: backend
|
||||
run: uv sync
|
||||
- name: Lint
|
||||
working-directory: backend
|
||||
run: uv run ruff check .
|
||||
- name: Migrer la base de test
|
||||
working-directory: backend
|
||||
run: uv run alembic upgrade head
|
||||
- name: Vérifier migration ↔ modèle
|
||||
working-directory: backend
|
||||
run: uv run alembic check
|
||||
- name: Tests
|
||||
working-directory: backend
|
||||
run: uv run pytest -q
|
||||
|
||||
frontend-test:
|
||||
runs-on: ubuntu-latest
|
||||
container: node:20-alpine
|
||||
steps:
|
||||
- name: Installer git et node
|
||||
run: apk add --no-cache git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
- name: Installer les dépendances
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
- name: Typecheck
|
||||
working-directory: frontend
|
||||
run: npm run typecheck
|
||||
- name: Lint
|
||||
working-directory: frontend
|
||||
run: npm run lint
|
||||
- name: Build
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
- name: Smoke test
|
||||
working-directory: frontend
|
||||
run: npm run smoke
|
||||
|
||||
build-backend:
|
||||
needs: backend-test
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
container: docker:27
|
||||
steps:
|
||||
@@ -106,8 +50,7 @@ jobs:
|
||||
docker push "$IMAGE:latest"
|
||||
|
||||
build-frontend:
|
||||
needs: frontend-test
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
needs: build-backend # séquentiel pour ne pas saturer le CPU
|
||||
runs-on: ubuntu-latest
|
||||
container: docker:27
|
||||
steps:
|
||||
@@ -122,6 +65,7 @@ jobs:
|
||||
docker build \
|
||||
--build-arg VITE_API_MODE=http \
|
||||
--build-arg VITE_EDITION_ID="${{ vars.VITE_EDITION_ID }}" \
|
||||
--build-arg VITE_HCAPTCHA_SITE_KEY="${{ vars.VITE_HCAPTCHA_SITE_KEY }}" \
|
||||
-f frontend/Dockerfile.prod \
|
||||
-t "$IMAGE:${{ gitea.sha }}" -t "$IMAGE:latest" frontend
|
||||
docker push "$IMAGE:${{ gitea.sha }}"
|
||||
@@ -129,7 +73,6 @@ jobs:
|
||||
|
||||
deploy:
|
||||
needs: [build-backend, build-frontend]
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
container: alpine:3.20
|
||||
steps:
|
||||
@@ -153,4 +96,4 @@ jobs:
|
||||
docker compose -f docker-compose.prod.yml run --rm backend uv run alembic upgrade head
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
docker image prune -f
|
||||
EOF
|
||||
EOF
|
||||
@@ -23,3 +23,5 @@ dist/
|
||||
|
||||
# Claude Code (worktrees, agents locaux)
|
||||
.claude/
|
||||
|
||||
TODO
|
||||
+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,21 +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 — 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
|
||||
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). `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
|
||||
@@ -105,21 +136,34 @@ 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`. À la création d'un
|
||||
`Position`, un `Shift` à 0/0 est généré par jour de l'édition et par
|
||||
`DayPart` ; `POST /editions/{id}/positions/{pid}/generate-shifts` recrée les
|
||||
manquants (après ajout d'un créneau type ou extension des dates).
|
||||
- **`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.
|
||||
(peut finir le lendemain), `min_staff`, `ideal_staff`. **Aucun créneau
|
||||
n'est généré automatiquement à la création d'un `Position`** : la
|
||||
coordination crée elle-même ceux dont elle a besoin, à l'horaire de son
|
||||
choix (`POST /editions/{id}/shifts`, sans lien à un `DayPart`,
|
||||
`day_part_id: null`) — écran « Postes & créneaux » (`ConfigGrid.tsx`), un
|
||||
formulaire heure de début / heure de fin par jour. Repartir des créneaux
|
||||
types reste possible en option, à la demande :
|
||||
`POST /editions/{id}/positions/{pid}/generate-shifts` génère un `Shift` à
|
||||
0/0 par jour de l'édition et par `DayPart` manquant (bouton « Générer les
|
||||
créneaux », idempotent — n'écrase jamais un créneau déjà généré, même
|
||||
modifié depuis).
|
||||
- **`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
|
||||
@@ -130,10 +174,339 @@ 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`,
|
||||
`used_at`.
|
||||
- **`BadgeTemplate`** : recto/verso d'une édition (une ligne, créée à la
|
||||
demande). `front_image`/`front_image_content_type` (fond du recto),
|
||||
`back_image`/`back_image_content_type` (verso — **le même pour tous les
|
||||
bénévoles**). Images stockées en base (`LargeBinary`), pas de volume de
|
||||
fichiers à gérer. **Le format du badge n'est pas fixe** :
|
||||
`front_image_width_mm`/`front_image_height_mm` sont déduits des pixels de
|
||||
l'image du recto à l'import (résolution supposée 300 dpi — voir
|
||||
`app/badges/pdf.py:image_size_mm`), donc le badge prend la forme de
|
||||
l'image importée ; `NULL` tant qu'aucune image n'est importée, l'éditeur
|
||||
et la génération retombant alors sur le format CR80 par défaut (85,6 ×
|
||||
54 mm, `DEFAULT_BADGE_WIDTH_MM`/`HEIGHT_MM`). Le verso est ajusté (sans
|
||||
déformation, `preserveAspectRatio=True`) à ce même format.
|
||||
- **`BadgeField`** : un champ positionné sur le **recto 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 ; 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 (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
|
||||
bénévole ; une feuille identifie un poste pour la journée) : une page par
|
||||
poste et par jour, avec son référent (`Position.lead_account_id`, nom +
|
||||
téléphone — « à désigner » si aucun) en en-tête, puis les créneaux du jour
|
||||
avec les bénévoles affectés et leur téléphone, pour affichage sur place.
|
||||
`POST /editions/{id}/position-sheets/generate` (COORDINATION,
|
||||
`{position_ids?}` — absent = tous). 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, 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 →
|
||||
`<a download>` généré côté client, voir `BadgesPage.tsx`).
|
||||
- **Reçus fiscaux mécénat** (PDF, `app/mecenat/pdf.py` + `GET
|
||||
/editions/{id}/volunteers/mecenat-receipts`, COORDINATION) : un cerfa
|
||||
11580*05 officiel (`app/mecenat/cerfa-mecenat-particuliers.pdf`, PDF sans
|
||||
champ AcroForm — les informations sont donc dessinées par-dessus la page 2
|
||||
du modèle, aux coordonnées mesurées sur ce document précis) par bénévole
|
||||
ayant payé un type de paiement « mécénat » (repérés par
|
||||
`Volunteer.fee_payment_status == PAID` et `mecenat_address` renseigné —
|
||||
voir `Paiement de la cotisation` plus bas), regroupés dans un seul PDF
|
||||
(2 pages par reçu, comme le cerfa). Seuls l'encadré « Donateur » (nom,
|
||||
prénom, adresse), le montant (en chiffres **et en toutes lettres**,
|
||||
`app/mecenat/number_words.py:amount_in_words` — conversion française pure,
|
||||
testée séparément des coordonnées PDF vu le nombre d'exceptions
|
||||
orthographiques : accord de « quatre-vingts »/« cents », « et onze »
|
||||
irrégulier à 71) et la date du versement sont complétés. L'encadré
|
||||
« Organisme bénéficiaire » (page 1), les cases articles 200/978 et la
|
||||
signature (page 2) sont **pré-remplis dans le modèle PDF lui-même** par
|
||||
l'association (pas en base) : changer d'association = remplacer le
|
||||
fichier, en gardant la même mise en page (les coordonnées de l'overlay
|
||||
n'ont été mesurées que sur ce document). **Numéro d'ordre** du reçu
|
||||
(case en haut de la page 1) : `Volunteer.mecenat_receipt_number`, unique,
|
||||
`AAAA` (année du paiement) + rang sur 5 chiffres (`202600001`), une seule
|
||||
numérotation pour toutes les éditions, repartant à 1 chaque année —
|
||||
attribué à la confirmation du paiement (`_promote_pending`) ou, pour un
|
||||
bénévole qui n'en a pas encore, au premier export (dans l'ordre des
|
||||
paiements), puis **jamais réattribué** (un reçu regénéré garde son
|
||||
numéro). `app/mecenat/receipts.py:assign_receipt_number`, sérialisé par
|
||||
un verrou `pg_advisory_xact_lock` (webhook et sondage peuvent confirmer
|
||||
deux paiements à la fois).
|
||||
`pypdf` (nouvelle dépendance) fusionne l'overlay ReportLab sur la page du
|
||||
modèle. Bouton « Reçus mécénat (PDF) » sur l'écran de configuration des
|
||||
bénévoles, même mécanique de téléchargement que l'export Excel ;
|
||||
422 `mecenat.no_receipts` si aucun bénévole ne qualifie.
|
||||
- **`FormTemplate`** : configuration du formulaire public d'inscription
|
||||
d'une édition (une ligne, créée à la demande). `is_open` coupe les
|
||||
nouvelles soumissions sans effacer la configuration. `access_token`
|
||||
(aléatoire, `secrets.token_urlsafe(16)` à la création) protège l'URL du
|
||||
formulaire public : celle-ci est `/inscription/{access_token}`, jamais
|
||||
`/inscription` seul — sans le jeton, impossible de deviner ou de tomber
|
||||
sur le lien (`GET .../form/public`, `GET .../form/logo` et
|
||||
`POST .../form/public/submit` l'exigent en plus de `edition_id`,
|
||||
comparaison en temps constant via `secrets.compare_digest`, 404 générique
|
||||
sinon). `POST .../form/regenerate-token` (COORDINATION) le renouvelle —
|
||||
l'ancien lien cesse aussitôt de fonctionner, à repartager. Les endpoints
|
||||
liés à une soumission en attente de paiement (`submit_mecenat_info`,
|
||||
`get_pending_status`, `retry_pending_payment`, le webhook SumUp) ne sont
|
||||
pas concernés : déjà protégés par le jeton propre à chaque soumission. `sumup_merchant_code`/
|
||||
`sumup_api_key` sont les identifiants du compte marchand SumUp pour le
|
||||
paiement de la cotisation (`app/sumup/`) — `sumup_api_key` est un secret,
|
||||
jamais renvoyé par l'API (`FormTemplateRead.sumup_api_key_set: bool` à la
|
||||
place ; chaîne vide en écriture ⇒ l'efface).
|
||||
- **`FormPaymentOption`** : un type de paiement de la cotisation (ex.
|
||||
« Cotisation classique », « Mécénat ») — `label` + `amount` (montant
|
||||
fixe), `order`, `requires_mecenat_info` (voir plus bas), `free_amount`
|
||||
(**montant libre** : le bénévole saisit lui-même son montant sur le
|
||||
formulaire public — `payment_amount` à la soumission, virgule ou point,
|
||||
2 décimales max — et `amount` en devient le **minimum** ; plafond
|
||||
10 000 € contre une faute de frappe, 422 `form.invalid_free_amount`
|
||||
sinon, `app/api/forms.py:_free_amount`. Indépendant de la case mécénat,
|
||||
mais pensé pour elle : un don libre avec reçu fiscal). Liste libre par
|
||||
édition, autant que la coordination veut (comme les créneaux types
|
||||
`DayPart`), gérée depuis l'écran « Inscriptions » (bloc « Types de
|
||||
paiement »). CRUD `GET/POST/PATCH/DELETE /editions/{id}/form/payment-options`
|
||||
(COORDINATION). Unique (`edition_id`, `label`) — 409
|
||||
`form.duplicate_payment_option_label` sinon ; montant `> 0` requis — 422
|
||||
`form.invalid_sumup_amount` sinon. Le bénévole en choisit au plus un à
|
||||
l'inscription (voir `Paiement de la cotisation` plus bas) ; supprimer une
|
||||
option n'affecte jamais les bénévoles qui l'avaient déjà choisie (copie
|
||||
figée sur `Volunteer`).
|
||||
- **`FormField`** : un champ activé sur le formulaire (`field_type` ∈
|
||||
`EMAIL` / `PHONE` / `POSITION_WISHES` / `ARTIST_WISHES` / `COMPANION_WISH`
|
||||
/ `AVAILABLE` / `WILLING_TO_PAY_FEE` / `PHOTO` / `COMMENT`, au plus un par
|
||||
édition — `PUT .../form/fields` remplace tout). **Nom et prénom sont
|
||||
toujours collectés**, indispensables à la création du `Volunteer` : ils ne
|
||||
sont pas dans cette liste, contrairement aux neuf autres. **Un champ activé
|
||||
est obligatoire** à la soumission (422 `import.row.missing_required_field`
|
||||
sinon) — `EMAIL`/`PHONE` non vides, `AVAILABLE` coché ; le formulaire
|
||||
public n'est plus une simple collecte d'infos facultatives. Deux règles
|
||||
propres à ce formulaire (pas à la fiche bénévole classique, qui reste sans
|
||||
limite) : au plus **deux** vœux de poste, et au plus **un** artiste
|
||||
souhaité par jour de festival (`app/api/forms.py:_check_one_artist_wish_per_day`).
|
||||
`GET/PUT/PATCH /editions/{id}/form[...]` (COORDINATION) configurent ; le
|
||||
reste des endpoints sous ce routeur est public (**aucune authentification**) :
|
||||
`GET .../form/public` et `POST .../form/public/submit` (protégés par
|
||||
hCaptcha, `app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` — désactivée si
|
||||
absente, comme `EMAIL_ENABLED=false` — et un filet de sécurité global par
|
||||
édition, `app/auth/rate_limit.py:form_submission_limiter`), plus, pour le
|
||||
paiement en ligne (voir plus bas), `POST .../form/public/pending/{token}
|
||||
/mecenat-info`, `GET .../form/public/pending/{token}`,
|
||||
`POST .../form/public/pending/{token}/retry-payment` et
|
||||
`POST .../form/public/sumup-webhook` (appelé par SumUp lui-même, donc sans
|
||||
hCaptcha possible). Un bénévole créé directement (sans paiement en ligne)
|
||||
apparaît immédiatement dans la liste de la coordination, comme n'importe
|
||||
quel autre bénévole.
|
||||
- **Paiement de la cotisation (SumUp)** — deux chemins selon le choix du
|
||||
bénévole (le formulaire public n'affiche ce choix, à la place de la case
|
||||
`WILLING_TO_PAY_FEE` toute seule, que si `sumup_enabled` sur
|
||||
`PublicFormConfig` est vrai, c.-à-d. identifiants SumUp renseignés **et**
|
||||
au moins un type de paiement configuré ; sinon la case reste une simple
|
||||
déclaration à cocher, obligatoire comme les autres, comme avant SumUp) :
|
||||
- **« Je paierai sur place »** (pas de paiement en ligne) : le `Volunteer`
|
||||
est créé tout de suite, comme n'importe quelle autre soumission.
|
||||
- **Un type de paiement en ligne choisi** : **rien n'est compté en base
|
||||
tant que le paiement n'est pas confirmé** — la soumission crée une
|
||||
ligne `PendingFormSubmission` (mêmes champs que le formulaire, plus
|
||||
volatils) à la place du `Volunteer`, pour ne jamais compter en base
|
||||
quelqu'un qui a rempli le formulaire sans aller au bout du paiement.
|
||||
Si l'option choisie porte `requires_mecenat_info` (ex. « Mécénat ») :
|
||||
`FormSubmissionResult.requires_mecenat_info=true` — le front-end
|
||||
enchaîne sur une étape d'adresse postale
|
||||
(`POST .../pending/{token}/mecenat-info` : `address`/`city`/
|
||||
`postal_code`/`country`, tous obligatoires) avant de créer le checkout,
|
||||
pour permettre l'édition d'un reçu fiscal (cerfa) plus tard ; ces
|
||||
coordonnées vivent sur `Volunteer.mecenat_address`/`mecenat_city`/
|
||||
`mecenat_postal_code`/`mecenat_country` une fois le bénévole créé.
|
||||
Sinon, le checkout est créé tout de suite. Dans les deux cas, le
|
||||
checkout hébergé (`app/sumup/client.py:create_checkout`, API SumUp
|
||||
`POST /v0.1/checkouts`, `hosted_checkout.enabled=true`, montant et
|
||||
libellé de l'option choisie, `redirect_url` pointant vers
|
||||
`/inscription?paiement={pending_id}`) renvoie son URL dans
|
||||
`FormSubmissionResult.sumup_checkout_url` — le navigateur y est
|
||||
entièrement redirigé (`window.location.href`, quitte le site). Un
|
||||
échec de l'appel API SumUp (réseau, identifiants invalides…) ne perd
|
||||
jamais les informations déjà saisies : `sumup_checkout_url` vaut alors
|
||||
`None` et le front-end propose de réessayer
|
||||
(`POST .../pending/{token}/retry-payment`, qui ouvre un nouveau
|
||||
checkout — un `checkout_reference` n'est réutilisable qu'une fois côté
|
||||
SumUp).
|
||||
|
||||
Au retour du bénévole sur `/inscription?paiement={token}`, la page de
|
||||
confirmation interroge en direct `GET .../form/public/pending/{token}`
|
||||
(toutes les 3 s, jusqu'à 40 fois) : cet endpoint relit toujours l'état
|
||||
réel du checkout auprès de SumUp (`get_checkout`) à chaque appel — jamais
|
||||
seulement la dernière valeur connue en base — et **c'est cette confirmation
|
||||
qui crée enfin le `Volunteer`** (`app/api/forms.py:_promote_pending`),
|
||||
résolution des vœux de poste/artiste y compris (silencieusement filtrés
|
||||
s'ils ont entretemps disparu — le paiement a déjà eu lieu, rien ne doit
|
||||
plus faire échouer la création). Le webhook SumUp
|
||||
(`POST .../form/public/sumup-webhook`, `event_type:
|
||||
CHECKOUT_STATUS_CHANGED` + `id`) fait la même chose de son côté,
|
||||
indépendamment (pas de dépendance stricte à ce que le navigateur reste
|
||||
ouvert) ; les deux chemins partagent la même fonction d'application du
|
||||
résultat, idempotente. Le libellé et le montant de l'option choisie sont
|
||||
recopiés **au moment du choix** sur `Volunteer.fee_payment_label`/
|
||||
`fee_amount` — une copie figée, jamais recalculée si l'option est éditée
|
||||
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`, 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
|
||||
(`_prune_stale_pending`, nettoyage paresseux à chaque soumission/relance,
|
||||
même principe que `app/auth/rate_limit.py`). Visible par la coordination
|
||||
sur la fiche du bénévole (`ConfigVolunteers.tsx`, libellé + montant +
|
||||
statut — jamais l'état de la case à cocher elle-même), en lecture seule
|
||||
(pas de bascule manuelle).
|
||||
|
||||
Conventions techniques :
|
||||
|
||||
@@ -142,8 +515,8 @@ Conventions techniques :
|
||||
type ENUM natif PostgreSQL.
|
||||
- Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les
|
||||
`edition_id` et sur les liens vers `volunteer`, `shift`, `position`,
|
||||
`artist`, `account`. La suppression complète d'un bénévole (RGPD) efface
|
||||
donc ses `Availability`, `Assignment`, vœux (artistes, postes, compagnons —
|
||||
`artist`, `account`. La suppression complète d'un bénévole efface
|
||||
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 +
|
||||
@@ -168,8 +541,12 @@ 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
|
||||
HS256 (`Authorization: Bearer <jwt>`, TTL `session_ttl_hours`) à l'arrivée.
|
||||
@@ -209,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
|
||||
@@ -222,7 +618,7 @@ référent d'un poste (voir `Position.lead_account_id` plus haut).
|
||||
|
||||
Envoi par **SMTP** (auto-hébergé). `EMAIL_ENABLED=false` (défaut dev/tests) ⇒
|
||||
les emails sont seulement journalisés (`app.email`, INFO, **sans destinataire
|
||||
ni corps** — RGPD). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
|
||||
ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
|
||||
`PUBLIC_BASE_URL` (pour les liens). Ne jamais mettre de vraies valeurs dans
|
||||
`config.py` : elles vivent dans `.env` (non versionné).
|
||||
|
||||
@@ -235,6 +631,36 @@ ni corps** — RGPD). 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
|
||||
de `frontend/tailwind.config.js`, mise en page par tableau et styles en
|
||||
ligne, sans police web ni CSS externe, pour rester lisible dans le plus de
|
||||
clients mail possible). Construite par `build_message`
|
||||
(`app/email/sender.py`), pour **tous** les emails envoyés, sans exception.
|
||||
Logo de l'édition en en-tête si elle en a importé un pour son formulaire
|
||||
public (`app/email/branding.py:edition_logo`, `FormTemplate.logo`) ; à
|
||||
défaut, un bandeau texte avec le sujet. Intégré en pièce jointe « inline »
|
||||
(Content-ID, `LOGO_CID`) plutôt que via une URL vers `GET .../form/logo` :
|
||||
cette route exige désormais le `access_token` du formulaire public (§ 2,
|
||||
`FormTemplate`) — une URL externe dans un email l'aurait exposé en clair à
|
||||
quiconque lit la source du message, pas seulement à son destinataire.
|
||||
|
||||
- **Lien magique** : `POST .../auth/magic-link` envoie l'email contenant
|
||||
`PUBLIC_BASE_URL` + `magic_link_path` + `?token=`. Sans SMTP, le jeton (non
|
||||
@@ -242,11 +668,39 @@ ni corps** — RGPD). 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`
|
||||
(paiement confirmé, voir § 2 `Paiement de la cotisation`) — un email de
|
||||
confirmation part en tâche de fond (`BackgroundTasks`, best effort, comme
|
||||
les autres). Deux modèles (`render_registration_confirmation`/
|
||||
`render_registration_paid_confirmation`, `app/email/templates.py`) : le
|
||||
second ajoute le libellé et le montant payés, et tient lieu de **reçu de
|
||||
paiement** — l'API SumUp Hosted Checkout utilisée ici ne documente pas de
|
||||
mécanisme pour que SumUp envoie elle-même un reçu au payeur (son
|
||||
« envoi automatique de reçu » vise le lecteur de carte/point de vente, pas
|
||||
ce mode d'intégration), donc l'appli ne compte pas dessus.
|
||||
Paiement « mécénat » (adresse postale saisie) : ce même email porte en
|
||||
pièce jointe le cerfa 11580*05 rempli du bénévole
|
||||
(`app/mecenat/receipts.py:receipt_for` + `generate_mecenat_receipts_pdf`,
|
||||
le même rendu que l'export coordination — organisme bénéficiaire et
|
||||
signature pré-remplis dans le modèle). Généré dans la tâche de fond
|
||||
(`_send_paid_confirmation`) ; un échec de génération envoie l'email sans
|
||||
pièce jointe plutôt que rien.
|
||||
|
||||
---
|
||||
|
||||
@@ -255,29 +709,52 @@ ni corps** — RGPD). 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).
|
||||
- Cumul : `> 8 h 00` strict (8 h 00 pile passe).
|
||||
- Repos : `< 8 h 00` strict.
|
||||
- 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
|
||||
tenant sur le jour où elle a commencé — grille des besoins, écran « Postes
|
||||
& créneaux », feuilles de poste, chaînage du solveur. À la saisie d'un
|
||||
créneau sur la carte d'un jour (`ConfigGrid.tsx`,
|
||||
`shiftDatesForFestivalDay`), une heure de début avant cette borne désigne
|
||||
la nuit qui suit (00:00 sur le samedi ⇒ dimanche 00:00), pas le petit
|
||||
matin du même jour calendaire.
|
||||
- Créneaux jointifs (`end_at == start_at`) : **pas** un chevauchement.
|
||||
- Une violation `BLOCKING` empêche l'enregistrement, sans dérogation.
|
||||
- Une violation `WARNING` est retournée à l'appelant mais n'empêche rien
|
||||
@@ -300,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
|
||||
@@ -328,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
|
||||
@@ -340,22 +816,60 @@ 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
|
||||
passent malgré tout en tête (ce sont eux le résultat d'une vraie optimisation
|
||||
jointe, pas juste un score local).
|
||||
|
||||
**Colonnes de la grille des besoins** — une colonne représente un **créneau**
|
||||
distinct (`starts_at` + `ends_at`), pas seulement une heure de début : deux
|
||||
créneaux qui démarrent en même temps mais durent différemment ont chacun leur
|
||||
colonne (`buildLattice` / `LatticeColumn` dans `frontend/src/lib/planning.ts`).
|
||||
Largeur adaptative par jour (`columnWidthPx`) : peu de créneaux dans une
|
||||
journée ⇒ colonnes larges, beaucoup de créneaux ⇒ largeur compacte avec
|
||||
défilement horizontal — jamais de colonnes étroites par défaut.
|
||||
**Colonnes de la grille des besoins** (`GrillePage.tsx`) — un vrai axe des
|
||||
temps : une colonne = 30 min, bornée par jour à la plage réellement utilisée
|
||||
(du créneau le plus matinal au plus tardif, arrondi à la demi-heure
|
||||
englobante — pas les 24 h complètes). Les créneaux gardent des horaires
|
||||
libres (`ShiftCreate`, § 2) ; ils sont replacés dans cette grille fixe en
|
||||
fusionnant les cases qu'ils couvrent, arrondis à la tranche englobante s'ils
|
||||
ne tombent pas pile sur un multiple de 30 min (`buildTimeGrid` /
|
||||
`placeInTimeGrid` dans `frontend/src/lib/planning.ts`). **Chaque cellule
|
||||
reçoit sa position de grille explicitement** (`gridColumn`/`gridRow`
|
||||
calculés dans `GrillePage.tsx`, pas seulement `gridColumn: span N`) : un
|
||||
poste dont les créneaux ne totalisent pas exactement le nombre de tranches
|
||||
d'un jour (deux créneaux adjacents mais non alignés sur 30 min, désormais
|
||||
courant depuis la création à la main) ne doit jamais décaler les lignes
|
||||
suivantes — bug corrigé une fois (postes manquants au-delà du premier), un
|
||||
positionnement implicite (auto-flow) recréerait la même classe de bug.
|
||||
Vérifié par `scripts/smoke.tsx:checkGridLayout` (inspecte les `grid-row`
|
||||
rendus, pas seulement l'absence de crash — un décalage CSS n'est pas visible
|
||||
dans un simple rendu SSR). Hiérarchie de `z-index` volontairement large et
|
||||
sans égalité entre les éléments sticky de la grille (coins figés 20/21 >
|
||||
en-têtes jour/heure 12/11 > libellés de poste 10) : deux éléments sticky à
|
||||
égalité de `z-index` peuvent se chevaucher de façon imprévisible pendant un
|
||||
défilement selon le navigateur. L'infobulle au survol d'une cellule (détail
|
||||
du créneau) est en `position: fixed`, positionnée par JS
|
||||
(`lib/useTooltipPosition.ts`, réutilisé par `HelpTip`) plutôt qu'ancrée en
|
||||
`absolute` sur la cellule : un ancrage `absolute` la découpait au bord de la
|
||||
zone de défilement (`overflow-auto`) et la coinçait sous les en-têtes figés
|
||||
quel que soit son propre `z-index` (un enfant ne dépasse jamais le contexte
|
||||
d'empilement de son parent) — bug corrigé une fois (infobulle invisible,
|
||||
« sous tout »), un ancrage `absolute`/`z-index` recréerait la même classe de
|
||||
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 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -365,7 +879,7 @@ défilement horizontal — jamais de colonnes étroites par défaut.
|
||||
|
||||
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,
|
||||
@@ -378,15 +892,18 @@ défilement horizontal — jamais de colonnes étroites par défaut.
|
||||
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 · constructeur de formulaire · pointage sur site ·
|
||||
fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)*
|
||||
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. Le pointage présent / absent par le référent est en place depuis
|
||||
l'étape 58 — `Assignment.attendance`, § 2.)*
|
||||
|
||||
---
|
||||
|
||||
@@ -412,18 +929,26 @@ fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)*
|
||||
│ │ ├── 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,
|
||||
│ │ │ # assignments, solver, matrix, notifications
|
||||
│ │ │ # positions, shifts,
|
||||
│ │ │ # assignments, solver, matrix, notifications,
|
||||
│ │ │ # badges, position_sheets, forms, stats
|
||||
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
|
||||
│ │ ├── email/ # SMTP (sender) + rendu des emails (templates)
|
||||
│ │ ├── email/ # SMTP (sender) + rendu texte (templates) et HTML (html.py) + logo (branding.py)
|
||||
│ │ ├── importing/ # import CSV/Excel (parser, text, service)
|
||||
│ │ ├── exporting/ # export bénévoles en Excel (xlsx.py, openpyxl)
|
||||
│ │ ├── models/ # tables SQLModel
|
||||
│ │ ├── validation/ # moteur pur (models, engine) + loader (base)
|
||||
│ │ ├── solver/ # suggestions OR-Tools (models, engine, loader)
|
||||
│ │ ├── badges/ # génération PDF des planches (pdf.py, ReportLab)
|
||||
│ │ ├── sheets/ # génération PDF des feuilles de poste (pdf.py)
|
||||
│ │ ├── mecenat/ # reçus fiscaux cerfa (pdf.py, pypdf + ReportLab) + le cerfa officiel
|
||||
│ │ ├── forms/ # hCaptcha (captcha.py) — voir api/forms.py
|
||||
│ │ ├── sumup/ # client API SumUp (client.py) — paiement de la cotisation
|
||||
│ │ ├── bootstrap.py # instance vierge : édition + 1er compte
|
||||
│ │ ├── seed.py # jeu de démonstration (python -m app.seed)
|
||||
│ │ ├── i18n/
|
||||
@@ -466,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.
|
||||
@@ -496,10 +1038,13 @@ Aucune dépendance locale requise hors Docker.
|
||||
- Secrets/variables Actions requis (voir `deploy/README.md`) : secrets
|
||||
`REGISTRY_TOKEN`, `SSH_KEY` ; variables `REGISTRY_HOST`,
|
||||
`REGISTRY_NAMESPACE`, `REGISTRY_USER`, `SSH_HOST`, `SSH_USER`,
|
||||
`DEPLOY_PATH`, `VITE_EDITION_ID`.
|
||||
`DEPLOY_PATH`, `VITE_EDITION_ID`, `VITE_HCAPTCHA_SITE_KEY` (optionnelle —
|
||||
clé de site hCaptcha, publique, figée au build du frontend comme
|
||||
`VITE_EDITION_ID` ; vide ⇒ formulaire public sans vérification anti-robot).
|
||||
- Le VPS porte un fichier `.env` (non versionné) avec `POSTGRES_USER`,
|
||||
`POSTGRES_PASSWORD`, `POSTGRES_DB`, `JWT_SECRET`, `PUBLIC_BASE_URL` et les
|
||||
variables `EMAIL_ENABLED` / `SMTP_*` (voir `deploy/README.md`).
|
||||
`POSTGRES_PASSWORD`, `POSTGRES_DB`, `JWT_SECRET`, `PUBLIC_BASE_URL`,
|
||||
`HCAPTCHA_SECRET_KEY` (optionnelle, secret hCaptcha — voir § 2, `FormField`)
|
||||
et les variables `EMAIL_ENABLED` / `SMTP_*` (voir `deploy/README.md`).
|
||||
- `backend` et `frontend` n'écoutent que sur `127.0.0.1` (voir
|
||||
`docker-compose.prod.yml`) : `deploy/Caddyfile.benevoles` est le bloc à
|
||||
ajouter au Caddyfile du serveur pour les exposer publiquement (`/api/*` vers
|
||||
@@ -530,7 +1075,59 @@ Aucune dépendance locale requise hors Docker.
|
||||
| 17 | Vœu de compagnon (`VolunteerCompanionWish`, 0 à N, à sens unique) : fiche bénévole, pris en compte par le solveur (bonus) et le classement par défaut | à valider |
|
||||
| 18 | Gestion des référents depuis la fiche bénévole : `PATCH /accounts` (rôle LEAD ↔ VOLUNTEER), `lead_account_id` vérifié (doit être un compte référent de l'édition) | à valider |
|
||||
| 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend, limiteur de tentatives sur `/auth/login` et `/auth/magic-link` | à valider |
|
||||
| 20 | Planches de badges bénévoles (PDF) : recto personnalisable (fond + champs nom/poste(s)/catégorie/rôle, éditeur glisser-déposer), verso fixe (même image pour tous), écran « Badges » dédié (`/badges`, COORDINATION) | à valider |
|
||||
| 21 | Formulaire public d'inscription (`/inscription/{access_token}`, sans authentification — voir étape 27) : champs activables par la coordination (email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir, vœu de compagnon en texte libre, disponibilité/cotisation déclaratives, photo, commentaire), captcha hCaptcha, ouverture/fermeture, lien à partager, écran de configuration dédié (`/inscriptions`, COORDINATION) — les bénévoles créés rejoignent directement la liste | à valider |
|
||||
| 22 | Feuilles de poste (PDF, complément des badges) : une page par poste et par jour avec le référent, les créneaux, les bénévoles affectés et leur téléphone — bouton depuis la grille des besoins (COORDINATION) ; photo du bénévole affichée sur son écran et sur l'écran référent (en face du nom de chaque membre de l'équipe) | à valider |
|
||||
| 23 | Créneaux : plus de génération automatique à la création d'un poste — création manuelle à horaire libre (`ConfigGrid.tsx`), génération depuis les créneaux types conservée en option (« Générer les créneaux ») ; grille des besoins redessinée en vrai axe des temps (une colonne = 30 min, bornée à la plage utilisée par jour, cases fusionnées pour chaque créneau — `buildTimeGrid`/`placeInTimeGrid`) | à valider |
|
||||
| 24 | Corrections de stabilité de la grille des besoins (suite à l'étape 23) : positionnement explicite (`gridColumn`/`gridRow`) de chaque cellule (un poste aux créneaux non alignés sur 30 min ne décale plus les postes suivants) ; hiérarchie de `z-index` sans égalité entre les en-têtes figés et isolation de l'infobulle de survol dans son propre contexte d'empilement (ne recouvrait plus la ligne « Affectés / idéal » pendant un défilement) | à valider |
|
||||
| 25 | Paiement de la cotisation via l'API SumUp (remplace le lien statique Yapla) : plusieurs types de paiement configurables par édition (`FormPaymentOption` — libellé + montant, ex. « Cotisation classique », « Mécénat »), inscription en deux temps pour un paiement en ligne (`PendingFormSubmission` : le `Volunteer` n'est créé qu'à la confirmation du paiement, jamais avant), étape d'adresse postale pour un type « mécénat » (cerfa), page de confirmation en direct au retour de SumUp avec relance possible après échec, tous les champs activés désormais obligatoires, statut (`PENDING`/`PAID`/`FAILED`/`EXPIRED`) tenu à jour par webhook et visible (libellé + montant + statut) sur la fiche du bénévole, email de confirmation d'inscription à la création du bénévole (tenant lieu de reçu quand un paiement est confirmé), mise en forme HTML au thème de l'appli (logo de l'édition en en-tête) pour tous les emails envoyés | à valider |
|
||||
| 26 | Export Excel de la liste des bénévoles (`GET /editions/{id}/volunteers/export`, COORDINATION, `app/exporting/xlsx.py`) : coordonnées, vœux (résolus en noms lisibles), cotisation et mécénat — bouton dédié sur l'écran de configuration des bénévoles | à valider |
|
||||
| 27 | Lien d'inscription protégé par jeton (`FormTemplate.access_token`, voir § 2) : l'URL du formulaire public devient `/inscription/{access_token}` (`/inscription` seul ne mène plus nulle part), bouton « Régénérer » sur l'écran de configuration (`/inscriptions`) pour révoquer un lien qui aurait fuité | à valider |
|
||||
| 28 | Reçus fiscaux mécénat (`GET /editions/{id}/volunteers/mecenat-receipts`, COORDINATION, `app/mecenat/pdf.py`) : un cerfa 11580*05 officiel par bénévole ayant payé en mécénat, donateur/montant (chiffres et toutes lettres en français, `number_words.py`)/date complétés par-dessus le modèle (`pypdf` + ReportLab), organisme bénéficiaire volontairement laissé vierge — bouton dédié sur l'écran de configuration des bénévoles | à valider |
|
||||
| 29 | Jeu de démonstration : 8 bénévoles avec une cotisation classique payée, 4 avec une cotisation mécénat payée (adresse complète) — `app/seed.py:_add_demo_payments` — pour que l'export Excel et les reçus fiscaux mécénat (étape 28) aient de quoi montrer quelque chose d'emblée | à valider |
|
||||
| 30 | Infobulles (`HelpTip` et détail de cellule de la grille des besoins) repositionnées en `position: fixed` via JS (`lib/useTooltipPosition.ts`), bornées à l'intérieur de l'écran et basculant de côté si la place manque — corrige un centrage `absolute` qui les faisait déborder de l'écran près d'un bord, et une isolation `z-index` qui rendait celle de la grille invisible (coincée sous les en-têtes figés) | à valider |
|
||||
| 31 | Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une URL (fuyait le `access_token` du formulaire public à tout destinataire — régression de l'étape 27, aucun test ne couvrait le rendu HTML du logo), sujet retiré du log de remise des emails (pouvait porter le nom d'un bénévole via un modèle personnalisé), image du formulaire public revalidée par décodage réel (`PIL`, pas seulement le `Content-Type` déclaré) avant stockage — évite qu'une fausse image fasse échouer la génération des badges/feuilles de poste, export Excel des bénévoles protégé contre l'injection de formule (CWE-1236, `app/exporting/xlsx.py:_safe_text`), sondage du statut de paiement en attente limité en fréquence par jeton (`pending_status_limiter`, évite un abus de l'API SumUp), `pyjwt` mis à jour (CVE listées non exploitables ici — HS256 à secret statique uniquement, jamais de JWKS — mais autant ne pas rester sur une version vulnérable) | à valider |
|
||||
| 32 | Formulaire public : astérisque rouge sur les champs obligatoires (`RequiredMark`, `FieldLabel required`) et légende « * champ obligatoire » | à valider |
|
||||
| 33 | Cerfa mécénat rempli envoyé en pièce jointe de l'email de confirmation une fois le paiement confirmé (`build_message(attachments=…)`, `app/mecenat/receipts.py`) ; modèle cerfa pré-rempli avec l'association et sa signature | à valider |
|
||||
| 34 | Numéro d'ordre unique sur chaque reçu fiscal mécénat (`Volunteer.mecenat_receipt_number`, `202600001`…, migration `0020`), attribué au paiement ou au premier export puis figé | à valider |
|
||||
| 35 | Type de paiement à montant libre (`FormPaymentOption.free_amount`, migration `0021`) : case « Montant libre » sur l'écran Inscriptions, montant configuré = minimum, saisie du montant sur le formulaire public (validée côté client et serveur, 10 000 € max) — pensé pour le mécénat | à valider |
|
||||
| 36 | Écran Inscriptions : état « Actif / Inactif » des types de paiement (`SumUpStatus`, `FormBuilderPage.tsx`) — sans identifiants SumUp ou sans type de paiement, le formulaire public n'affiche qu'une case « cotisation » (ni choix du type, ni étape mécénat, ni paiement en ligne), ce que rien ne signalait | à valider |
|
||||
| 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/`).
|
||||
|
||||
+9
-1
@@ -12,7 +12,15 @@ ENV TZ=Europe/Paris \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_PROJECT_ENVIRONMENT=/app/.venv \
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
PATH="/app/.venv/bin:$PATH" \
|
||||
# Le runner CI a une bande passante limitée : à 50 téléchargements uv en
|
||||
# parallèle (défaut), chacun n'obtient qu'une fraction du débit et finit
|
||||
# par dépasser même un timeout généreux ("network timeout" sur de gros
|
||||
# paquets comme sqlalchemy/reportlab). On réduit la concurrence pour que
|
||||
# chaque téléchargement avance réellement, et on garde une marge large
|
||||
# sur le délai par requête.
|
||||
UV_CONCURRENT_DOWNLOADS=4 \
|
||||
UV_HTTP_TIMEOUT=600
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+111
-3
@@ -1,10 +1,20 @@
|
||||
"""Helpers partagés par les routers CRUD."""
|
||||
"""Helpers partagés par les routers CRUD.
|
||||
|
||||
`resolve_owned_ids`/`set_artists`/`set_wished_positions` sont utilisés à la
|
||||
fois par `volunteers.py` (fiche éditée par la coordination) et `forms.py`
|
||||
(vœux saisis depuis le formulaire public) — un seul endroit pour la
|
||||
validation « ces ids appartiennent bien à cette édition ». Idem pour
|
||||
`read_photo` (photo d'un bénévole)."""
|
||||
|
||||
import io
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy import delete
|
||||
from sqlmodel import Session
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.models import Artist, Position, VolunteerArtist, VolunteerPositionWish, local_now
|
||||
|
||||
|
||||
def owned_or_404(instance, edition_id: uuid.UUID):
|
||||
@@ -23,3 +33,101 @@ def replace_links(
|
||||
)
|
||||
for row in pairs:
|
||||
session.add(link_model(**row))
|
||||
|
||||
|
||||
def resolve_owned_ids(
|
||||
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID], code: str
|
||||
) -> list[uuid.UUID]:
|
||||
"""Ordre d'entrée conservé, doublons retirés ; 422 si un id est étranger."""
|
||||
wanted = list(dict.fromkeys(ids))
|
||||
if wanted:
|
||||
found = set(
|
||||
session.exec(
|
||||
select(model.id).where(
|
||||
model.edition_id == edition_id,
|
||||
model.id.in_(wanted),
|
||||
)
|
||||
)
|
||||
)
|
||||
if len(found) != len(wanted):
|
||||
raise HTTPException(status_code=422, detail={"code": code})
|
||||
return wanted
|
||||
|
||||
|
||||
def set_artists(session: Session, volunteer, artist_ids: list[uuid.UUID]) -> None:
|
||||
ids = resolve_owned_ids(
|
||||
session, Artist, volunteer.edition_id, artist_ids, "artist.unknown"
|
||||
)
|
||||
replace_links(
|
||||
session,
|
||||
VolunteerArtist,
|
||||
filter_column=VolunteerArtist.volunteer_id,
|
||||
filter_value=volunteer.id,
|
||||
pairs=[{"volunteer_id": volunteer.id, "artist_id": aid} for aid in ids],
|
||||
)
|
||||
|
||||
|
||||
def set_wished_positions(
|
||||
session: Session, volunteer, position_ids: list[uuid.UUID]
|
||||
) -> None:
|
||||
ids = resolve_owned_ids(
|
||||
session, Position, volunteer.edition_id, position_ids, "position.unknown"
|
||||
)
|
||||
replace_links(
|
||||
session,
|
||||
VolunteerPositionWish,
|
||||
filter_column=VolunteerPositionWish.volunteer_id,
|
||||
filter_value=volunteer.id,
|
||||
pairs=[{"volunteer_id": volunteer.id, "position_id": pid} for pid in ids],
|
||||
)
|
||||
|
||||
|
||||
MAX_PHOTO_BYTES = 8 * 1024 * 1024
|
||||
ALLOWED_PHOTO_TYPES = {"image/png", "image/jpeg"}
|
||||
|
||||
|
||||
async def read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None:
|
||||
"""Validation partagée par le formulaire public (inscription immédiate
|
||||
ou en attente de paiement) et l'ajout d'une photo par la coordination
|
||||
depuis la fiche (`volunteers.py:set_volunteer_photo`) — `None` si aucun
|
||||
fichier fourni. `Content-Type` est
|
||||
un en-tête fourni par l'appelant, jamais vérifié par lui-même (ce
|
||||
endpoint est public, sans authentification) : un fichier qui ne se
|
||||
décode pas comme une vraie image malgré un `Content-Type` correct
|
||||
(`image/png`/`image/jpeg` revendiqué) est rejeté ici plutôt que de
|
||||
planter plus tard la génération des badges/feuilles de poste
|
||||
(`app/badges/pdf.py`/`app/sheets/pdf.py`, qui lisent ces octets sans
|
||||
filet) — même validation que l'import d'image des badges
|
||||
(`app/api/badges.py:_pixel_size`, réservé à la coordination)."""
|
||||
if photo is None or not photo.filename:
|
||||
return None
|
||||
content_type = photo.content_type or ""
|
||||
if content_type not in ALLOWED_PHOTO_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "form.photo_type_not_supported"}
|
||||
)
|
||||
content = await photo.read()
|
||||
if len(content) > MAX_PHOTO_BYTES:
|
||||
raise HTTPException(status_code=413, detail={"code": "form.photo_too_large"})
|
||||
if not content:
|
||||
return None
|
||||
try:
|
||||
with Image.open(io.BytesIO(content)) as img:
|
||||
img.verify()
|
||||
except (UnidentifiedImageError, OSError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "form.photo_type_not_supported"}
|
||||
) from exc
|
||||
return content, content_type
|
||||
|
||||
|
||||
def set_message(target: object, field: str, value: str | None) -> None:
|
||||
"""Message affiché aux bénévoles (`Edition.volunteer_message`,
|
||||
`Position.message`) : espaces de bord retirés, vide = plus de message.
|
||||
`{field}_updated_at` n'est daté que si le texte change vraiment — il dit
|
||||
aux bénévoles de quand date le message."""
|
||||
text = (value or "").strip() or None
|
||||
if text == getattr(target, field):
|
||||
return
|
||||
setattr(target, field, text)
|
||||
setattr(target, f"{field}_updated_at", local_now() if text else None)
|
||||
|
||||
@@ -5,6 +5,7 @@ import datetime as dt
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.models import DayPart, Edition, Position, Shift
|
||||
from app.validation.engine import day_boundary
|
||||
|
||||
|
||||
def _dates(edition: Edition) -> list[dt.date]:
|
||||
@@ -12,10 +13,11 @@ def _dates(edition: Edition) -> list[dt.date]:
|
||||
return [edition.start_date + dt.timedelta(days=i) for i in range(span + 1)]
|
||||
|
||||
|
||||
def _window(day: dt.date, part: DayPart) -> tuple[dt.datetime, dt.datetime]:
|
||||
start = dt.datetime.combine(day, part.start_time)
|
||||
def _window(day: dt.date, part: DayPart, boundary: dt.time) -> tuple[dt.datetime, dt.datetime]:
|
||||
actual_day = day + dt.timedelta(days=1) if part.start_time < boundary else day
|
||||
start = dt.datetime.combine(actual_day, part.start_time)
|
||||
end_day = (
|
||||
day if part.end_time > part.start_time else day + dt.timedelta(days=1)
|
||||
actual_day if part.end_time > part.start_time else actual_day + dt.timedelta(days=1)
|
||||
)
|
||||
return start, dt.datetime.combine(end_day, part.end_time)
|
||||
|
||||
@@ -28,6 +30,7 @@ def generate_missing_shifts(
|
||||
day_parts = list(
|
||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||
)
|
||||
boundary = day_boundary(day_parts)
|
||||
existing = {
|
||||
(s.day_part_id, s.starts_at.date())
|
||||
for s in session.exec(
|
||||
@@ -37,9 +40,9 @@ def generate_missing_shifts(
|
||||
created = 0
|
||||
for day in _dates(edition):
|
||||
for part in day_parts:
|
||||
if (part.id, day) in existing:
|
||||
start, end = _window(day, part, boundary)
|
||||
if (part.id, start.date()) in existing:
|
||||
continue
|
||||
start, end = _window(day, part)
|
||||
session.add(
|
||||
Shift(
|
||||
edition_id=edition.id,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+51
-2
@@ -25,11 +25,13 @@ from app.api.schemas import (
|
||||
from app.auth.deps import get_current_account
|
||||
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
|
||||
from app.auth.passwords import MIN_LENGTH, hash_password, verify_password
|
||||
from app.auth.permanent_link import PREFIX as PERMANENT_PREFIX
|
||||
from app.auth.permanent_link import account_from_permanent_token
|
||||
from app.auth.rate_limit import login_limiter, magic_link_limiter
|
||||
from app.auth.tokens import create_access_token, hash_token
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.email import render_magic_link, send
|
||||
from app.email import edition_logo, render_magic_link, send
|
||||
from app.models import Account, MagicLinkToken, local_now
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
@@ -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()
|
||||
@@ -143,7 +167,9 @@ def request_magic_link(
|
||||
link=build_magic_link_url(raw),
|
||||
minutes=settings.magic_link_ttl_minutes,
|
||||
)
|
||||
background.add_task(send, account.email, subject, body)
|
||||
background.add_task(
|
||||
send, account.email, subject, body, logo=edition_logo(session, edition_id)
|
||||
)
|
||||
if not settings.email_enabled:
|
||||
# Aide au dev sans SMTP : le jeton (non nominatif) est journalisé.
|
||||
logger.info(
|
||||
@@ -160,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())
|
||||
@@ -170,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,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,434 @@
|
||||
"""Planches de badges bénévoles : configuration du recto/verso (COORDINATION)
|
||||
et génération du PDF.
|
||||
|
||||
Chaque face (recto, verso) combine une image de fond (la même pour tous les
|
||||
bénévoles) et des champs positionnés (nom, poste(s) affectés, catégorie,
|
||||
rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py` pour la
|
||||
composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
|
||||
`BadgeField`) pour le modèle.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import io
|
||||
import re
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy import delete
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import (
|
||||
BadgeFieldRead,
|
||||
BadgeFieldsSet,
|
||||
BadgeGenerateRequest,
|
||||
BadgeSizeUpdate,
|
||||
BadgeTemplateRead,
|
||||
)
|
||||
from app.auth.deps import require_coordinator
|
||||
from app.badges.pdf import (
|
||||
DEFAULT_BADGE_HEIGHT_MM,
|
||||
DEFAULT_BADGE_WIDTH_MM,
|
||||
BadgeFieldSpec,
|
||||
BadgeSpec,
|
||||
BadgeTable,
|
||||
BadgeTableSection,
|
||||
generate_badge_sheets_pdf,
|
||||
image_size_mm,
|
||||
)
|
||||
from app.db import get_session
|
||||
from app.i18n import t
|
||||
from app.models import (
|
||||
Account,
|
||||
Assignment,
|
||||
BadgeFace,
|
||||
BadgeField,
|
||||
BadgeFieldType,
|
||||
BadgeTemplate,
|
||||
DayPart,
|
||||
Edition,
|
||||
Position,
|
||||
Role,
|
||||
Shift,
|
||||
Volunteer,
|
||||
)
|
||||
from app.validation.engine import day_boundary, festival_day
|
||||
|
||||
router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"])
|
||||
|
||||
_MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
||||
_ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg"}
|
||||
_HEX_COLOR = re.compile(r"#[0-9a-fA-F]{6}")
|
||||
# Champs centrés verticalement dans leur cadre (les autres partent du haut).
|
||||
_MIDDLE_ALIGNED = {BadgeFieldType.FREE_TEXT, BadgeFieldType.ROLE}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Configuration (recto/verso, champs) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _get_or_create_template(session: Session, edition_id: uuid.UUID) -> BadgeTemplate:
|
||||
template = session.exec(
|
||||
select(BadgeTemplate).where(BadgeTemplate.edition_id == edition_id)
|
||||
).first()
|
||||
if template is None:
|
||||
template = BadgeTemplate(edition_id=edition_id)
|
||||
session.add(template)
|
||||
session.commit()
|
||||
session.refresh(template)
|
||||
return template
|
||||
|
||||
|
||||
def _fields(session: Session, edition_id: uuid.UUID) -> list[BadgeField]:
|
||||
return list(
|
||||
session.exec(
|
||||
select(BadgeField)
|
||||
.where(BadgeField.edition_id == edition_id)
|
||||
# Ordre de dessin (voir `BadgeField.z_order`) ; à égalité, la
|
||||
# photo dessous, pour ne jamais masquer un texte.
|
||||
.order_by(
|
||||
BadgeField.face,
|
||||
BadgeField.z_order,
|
||||
(BadgeField.field_type == BadgeFieldType.PHOTO).desc(),
|
||||
BadgeField.field_type,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _template_read(template: BadgeTemplate, fields: list[BadgeField]) -> BadgeTemplateRead:
|
||||
return BadgeTemplateRead(
|
||||
edition_id=template.edition_id,
|
||||
has_front_image=template.front_image is not None,
|
||||
has_back_image=template.back_image is not None,
|
||||
badge_width_mm=template.front_image_width_mm or DEFAULT_BADGE_WIDTH_MM,
|
||||
badge_height_mm=template.front_image_height_mm or DEFAULT_BADGE_HEIGHT_MM,
|
||||
fields=fields,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/template", response_model=BadgeTemplateRead)
|
||||
def get_template(
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> BadgeTemplateRead:
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
return _template_read(template, _fields(session, edition.id))
|
||||
|
||||
|
||||
async def _read_image(file: UploadFile) -> tuple[bytes, str]:
|
||||
content_type = file.content_type or ""
|
||||
if content_type not in _ALLOWED_CONTENT_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "badge.image_type_not_supported"}
|
||||
)
|
||||
content = await file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail={"code": "badge.empty_image"})
|
||||
if len(content) > _MAX_IMAGE_BYTES:
|
||||
raise HTTPException(status_code=413, detail={"code": "badge.image_too_large"})
|
||||
return content, content_type
|
||||
|
||||
|
||||
def _pixel_size(content: bytes) -> tuple[int, int]:
|
||||
try:
|
||||
with Image.open(io.BytesIO(content)) as img:
|
||||
return img.size
|
||||
except UnidentifiedImageError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "badge.image_unreadable"}
|
||||
) from exc
|
||||
|
||||
|
||||
@router.put("/template/front-image", response_model=BadgeTemplateRead)
|
||||
async def set_front_image(
|
||||
file: UploadFile = File(...),
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> BadgeTemplateRead:
|
||||
"""Le format du badge (`front_image_width_mm`/`height_mm`) est dérivé de
|
||||
cette image (voir `app/badges/pdf.py:image_size_mm`) : le badge prend
|
||||
désormais la forme de l'image importée plutôt qu'un format fixe imposé."""
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
content, content_type = await _read_image(file)
|
||||
width_px, height_px = _pixel_size(content)
|
||||
width_mm, height_mm = image_size_mm(width_px, height_px)
|
||||
template.front_image = content
|
||||
template.front_image_content_type = content_type
|
||||
template.front_image_width_mm = width_mm
|
||||
template.front_image_height_mm = height_mm
|
||||
session.add(template)
|
||||
session.commit()
|
||||
return _template_read(template, _fields(session, edition.id))
|
||||
|
||||
|
||||
@router.put("/template/back-image", response_model=BadgeTemplateRead)
|
||||
async def set_back_image(
|
||||
file: UploadFile = File(...),
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> BadgeTemplateRead:
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
content, content_type = await _read_image(file)
|
||||
template.back_image = content
|
||||
template.back_image_content_type = content_type
|
||||
session.add(template)
|
||||
session.commit()
|
||||
return _template_read(template, _fields(session, edition.id))
|
||||
|
||||
|
||||
@router.get("/template/front-image")
|
||||
def get_front_image(
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
if template.front_image is None:
|
||||
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
|
||||
return Response(
|
||||
content=template.front_image,
|
||||
media_type=template.front_image_content_type or "application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/template/back-image")
|
||||
def get_back_image(
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
if template.back_image is None:
|
||||
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
|
||||
return Response(
|
||||
content=template.back_image,
|
||||
media_type=template.back_image_content_type or "application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/template/fields", response_model=list[BadgeFieldRead])
|
||||
def set_fields(
|
||||
payload: BadgeFieldsSet,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[BadgeField]:
|
||||
# Au plus un champ de chaque type par face ; le même sur les deux faces
|
||||
# est permis.
|
||||
seen: set[tuple[BadgeFace, BadgeFieldType]] = set()
|
||||
for f in payload.fields:
|
||||
if (f.face, f.field_type) in seen:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"code": "badge.duplicate_field_type",
|
||||
"params": {"field_type": f.field_type},
|
||||
},
|
||||
)
|
||||
seen.add((f.face, f.field_type))
|
||||
if f.bg_color is not None and not _HEX_COLOR.fullmatch(f.bg_color):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"code": "badge.invalid_color", "params": {"color": f.bg_color}},
|
||||
)
|
||||
|
||||
session.execute(delete(BadgeField).where(BadgeField.edition_id == edition.id))
|
||||
for f in payload.fields:
|
||||
session.add(BadgeField(edition_id=edition.id, **f.model_dump()))
|
||||
session.commit()
|
||||
return _fields(session, edition.id)
|
||||
|
||||
|
||||
@router.put("/template/size", response_model=BadgeTemplateRead)
|
||||
def set_size(
|
||||
payload: BadgeSizeUpdate,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> BadgeTemplateRead:
|
||||
"""Redimensionne le badge indépendamment de l'image (ex. une image
|
||||
importée à basse résolution donne un format minuscule à 300 dpi
|
||||
supposés — l'utilisateur l'agrandit ici). Écrase la taille déduite de
|
||||
l'image ; ré-importer l'image la recalcule à nouveau depuis ses pixels
|
||||
(`set_front_image`)."""
|
||||
if payload.badge_width_mm <= 0 or payload.badge_height_mm <= 0:
|
||||
raise HTTPException(status_code=422, detail={"code": "badge.invalid_size"})
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
template.front_image_width_mm = payload.badge_width_mm
|
||||
template.front_image_height_mm = payload.badge_height_mm
|
||||
session.add(template)
|
||||
session.commit()
|
||||
return _template_read(template, _fields(session, edition.id))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Génération du PDF #
|
||||
# --------------------------------------------------------------------------- #
|
||||
AssignmentRow = tuple[Position, Shift]
|
||||
|
||||
|
||||
def _role_label(volunteer_id: uuid.UUID, lead_volunteer_ids: set[uuid.UUID]) -> str:
|
||||
if volunteer_id in lead_volunteer_ids:
|
||||
return t("badge.role.referent")
|
||||
return t("badge.role.volunteer")
|
||||
|
||||
|
||||
def _category_label(category) -> str:
|
||||
return t(f"position.category.{category.value.lower()}")
|
||||
|
||||
|
||||
def _day_label(day: dt.date) -> str:
|
||||
"""En-tête d'un jour dans le tableau des créneaux : « Samedi 11 »."""
|
||||
return t("badge.table.day_label", weekday=t(f"badge.weekday.{day.weekday()}"), day=day.day)
|
||||
|
||||
|
||||
def _positions_table(assignments: list[AssignmentRow], boundary: dt.time) -> BadgeTable | None:
|
||||
"""Créneaux affectés regroupés par jour de festival (une soirée qui
|
||||
passe minuit reste sur le jour où elle a commencé — `festival_day`) :
|
||||
le jour en en-tête, puis une ligne horaire / poste par créneau, dans
|
||||
l'ordre chronologique. `None` sans aucun créneau (rien à dessiner)."""
|
||||
by_day: dict[dt.date, list[tuple[str, str]]] = defaultdict(list)
|
||||
for position, shift in assignments:
|
||||
by_day[festival_day(shift.starts_at, boundary)].append(
|
||||
(f"{shift.starts_at:%H:%M}–{shift.ends_at:%H:%M}", position.name)
|
||||
)
|
||||
if not by_day:
|
||||
return None
|
||||
return BadgeTable(
|
||||
sections=tuple(
|
||||
BadgeTableSection(title=_day_label(day), rows=tuple(rows))
|
||||
for day, rows in sorted(by_day.items())
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _field_text(
|
||||
field: BadgeField,
|
||||
volunteer: Volunteer,
|
||||
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]],
|
||||
lead_volunteer_ids: set[uuid.UUID],
|
||||
) -> str:
|
||||
"""Texte d'un champ — `POSITIONS` n'en a pas : c'est un tableau
|
||||
(`_positions_table`)."""
|
||||
if field.field_type == BadgeFieldType.FULL_NAME:
|
||||
# Prénom puis nom, chacun sur sa ligne.
|
||||
return f"{volunteer.first_name}\n{volunteer.last_name}"
|
||||
if field.field_type == BadgeFieldType.PHOTO:
|
||||
# Une image, pas un texte (rien d'imprimé sans photo).
|
||||
return ""
|
||||
if field.field_type == BadgeFieldType.FREE_TEXT:
|
||||
# Texte commun saisi dans l'éditeur, remplacé pour un bénévole par
|
||||
# le « Texte du badge » de sa fiche s'il en a un.
|
||||
return volunteer.badge_text or field.text or ""
|
||||
assignments = assignments_by_volunteer.get(volunteer.id, [])
|
||||
if field.field_type == BadgeFieldType.CATEGORY:
|
||||
categories = sorted(
|
||||
{_category_label(position.category) for position, _shift in assignments}
|
||||
)
|
||||
return ", ".join(categories)
|
||||
if field.field_type == BadgeFieldType.ROLE:
|
||||
return _role_label(volunteer.id, lead_volunteer_ids)
|
||||
return ""
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
def generate_badges(
|
||||
payload: BadgeGenerateRequest,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
fields = _fields(session, edition.id)
|
||||
if not fields:
|
||||
raise HTTPException(status_code=422, detail={"code": "badge.no_fields"})
|
||||
|
||||
query = select(Volunteer).where(Volunteer.edition_id == edition.id)
|
||||
if payload.volunteer_ids is not None:
|
||||
query = query.where(Volunteer.id.in_(payload.volunteer_ids))
|
||||
volunteers = list(session.exec(query.order_by(Volunteer.last_name, Volunteer.first_name)))
|
||||
if not volunteers:
|
||||
raise HTTPException(status_code=422, detail={"code": "badge.no_volunteers"})
|
||||
|
||||
volunteer_ids = [v.id for v in volunteers]
|
||||
rows = session.exec(
|
||||
select(Assignment.volunteer_id, Position, Shift)
|
||||
.join(Shift, Shift.id == Assignment.shift_id)
|
||||
.join(Position, Position.id == Shift.position_id)
|
||||
.where(Assignment.volunteer_id.in_(volunteer_ids))
|
||||
.order_by(Shift.starts_at)
|
||||
).all()
|
||||
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]] = defaultdict(list)
|
||||
for volunteer_id, position, shift in rows:
|
||||
assignments_by_volunteer[volunteer_id].append((position, shift))
|
||||
|
||||
boundary = day_boundary(
|
||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||
)
|
||||
lead_volunteer_ids = {
|
||||
account.volunteer_id
|
||||
for account in session.exec(
|
||||
select(Account).where(
|
||||
Account.edition_id == edition.id,
|
||||
Account.role == Role.LEAD,
|
||||
Account.volunteer_id.in_(volunteer_ids),
|
||||
)
|
||||
)
|
||||
if account.volunteer_id is not None
|
||||
}
|
||||
|
||||
def spec(field: BadgeField, volunteer: Volunteer) -> BadgeFieldSpec:
|
||||
return BadgeFieldSpec(
|
||||
text=_field_text(field, volunteer, assignments_by_volunteer, lead_volunteer_ids),
|
||||
x_mm=field.x_mm,
|
||||
y_mm=field.y_mm,
|
||||
width_mm=field.width_mm,
|
||||
height_mm=field.height_mm,
|
||||
font_size=field.font_size,
|
||||
align=field.align.value,
|
||||
bold=field.bold,
|
||||
color=field.color,
|
||||
is_photo=field.field_type == BadgeFieldType.PHOTO,
|
||||
image=volunteer.photo if field.field_type == BadgeFieldType.PHOTO else None,
|
||||
table=_positions_table(assignments_by_volunteer.get(volunteer.id, []), boundary)
|
||||
if field.field_type == BadgeFieldType.POSITIONS
|
||||
else None,
|
||||
bg_color=field.bg_color,
|
||||
auto_fit=field.auto_fit,
|
||||
valign="MIDDLE" if field.field_type in _MIDDLE_ALIGNED else "TOP",
|
||||
photo_shape=field.photo_shape.value,
|
||||
)
|
||||
|
||||
front_fields = [f for f in fields if f.face == BadgeFace.FRONT]
|
||||
back_fields = [f for f in fields if f.face == BadgeFace.BACK]
|
||||
# Un badge par exemplaire demandé sur la fiche (`Volunteer.badge_count`),
|
||||
# à la suite les uns des autres.
|
||||
badges = [
|
||||
badge
|
||||
for volunteer in volunteers
|
||||
for badge in [
|
||||
BadgeSpec(
|
||||
fields=tuple(spec(field, volunteer) for field in front_fields),
|
||||
back_fields=tuple(spec(field, volunteer) for field in back_fields),
|
||||
)
|
||||
]
|
||||
* volunteer.badge_count
|
||||
]
|
||||
|
||||
pdf_bytes = generate_badge_sheets_pdf(
|
||||
badges,
|
||||
template.front_image,
|
||||
template.back_image,
|
||||
badge_width_mm=template.front_image_width_mm or DEFAULT_BADGE_WIDTH_MM,
|
||||
badge_height_mm=template.front_image_height_mm or DEFAULT_BADGE_HEIGHT_MM,
|
||||
)
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": 'attachment; filename="badges.pdf"'},
|
||||
)
|
||||
@@ -3,6 +3,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.api._common import set_message
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import EditionRead, EditionUpdate
|
||||
from app.auth.deps import current_account_in_edition, require_coordinator
|
||||
@@ -27,7 +28,10 @@ def update_edition(
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Edition:
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if "volunteer_message" in changes:
|
||||
set_message(edition, "volunteer_message", changes.pop("volunteer_message"))
|
||||
for field, value in changes.items():
|
||||
setattr(edition, field, value)
|
||||
if edition.end_date < edition.start_date:
|
||||
raise HTTPException(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 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)
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Feuilles de poste (PDF) : une page par poste et par jour, listant les
|
||||
créneaux du jour avec les bénévoles affectés et leur téléphone — à imprimer
|
||||
et afficher sur place. Complément des badges (`app/api/badges.py`) : un
|
||||
badge identifie un bénévole, une feuille identifie un poste pour la
|
||||
journée. Voir `app/sheets/pdf.py` pour la composition du PDF.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import PositionSheetsRequest
|
||||
from app.auth.deps import require_coordinator
|
||||
from app.db import get_session
|
||||
from app.i18n import t
|
||||
from app.models import Account, Artist, Assignment, DayPart, Edition, Position, Shift, Volunteer
|
||||
from app.sheets.pdf import (
|
||||
PositionDaySheet,
|
||||
SheetArtist,
|
||||
SheetFooter,
|
||||
SheetShift,
|
||||
SheetVolunteer,
|
||||
generate_position_sheets_pdf,
|
||||
)
|
||||
from app.validation.engine import day_boundary, festival_day
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/editions/{edition_id}/position-sheets", tags=["position-sheets"]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
def generate_position_sheets(
|
||||
payload: PositionSheetsRequest,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
query = select(Position).where(Position.edition_id == edition.id)
|
||||
if payload.position_ids is not None:
|
||||
query = query.where(Position.id.in_(payload.position_ids))
|
||||
positions = list(session.exec(query.order_by(Position.name)))
|
||||
if not positions:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "position_sheets.no_positions"}
|
||||
)
|
||||
|
||||
position_ids = [p.id for p in positions]
|
||||
shifts = list(
|
||||
session.exec(
|
||||
select(Shift)
|
||||
.where(Shift.position_id.in_(position_ids))
|
||||
.order_by(Shift.starts_at)
|
||||
)
|
||||
)
|
||||
shift_ids = [s.id for s in shifts]
|
||||
|
||||
volunteers_by_shift: dict[uuid.UUID, list[Volunteer]] = defaultdict(list)
|
||||
if shift_ids:
|
||||
rows = session.exec(
|
||||
select(Assignment.shift_id, Volunteer)
|
||||
.join(Volunteer, Volunteer.id == Assignment.volunteer_id)
|
||||
.where(Assignment.shift_id.in_(shift_ids))
|
||||
).all()
|
||||
for shift_id, volunteer in rows:
|
||||
volunteers_by_shift[shift_id].append(volunteer)
|
||||
for vs in volunteers_by_shift.values():
|
||||
vs.sort(key=lambda v: (v.last_name, v.first_name))
|
||||
|
||||
# « Jour » = journée de festival (CLAUDE.md § 3), pas date du calendrier :
|
||||
# une soirée qui passe minuit (23h–00h, 00h–01h…) reste sur une seule
|
||||
# feuille, celle du jour où elle a commencé.
|
||||
boundary = day_boundary(
|
||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||
)
|
||||
|
||||
# Un jour sans aucun besoin réel (créneau 0/0 généré automatiquement,
|
||||
# jamais rempli) n'apparaît pas — évite des pages « personne affecté »
|
||||
# pour des créneaux qui ne représentent aucun besoin.
|
||||
shifts_by_position_day: dict[tuple[uuid.UUID, dt.date], list[Shift]] = defaultdict(list)
|
||||
for shift in shifts:
|
||||
undefined_need = shift.min_staff == 0 and shift.ideal_staff == 0
|
||||
if undefined_need and not volunteers_by_shift.get(shift.id):
|
||||
continue
|
||||
day = festival_day(shift.starts_at, boundary)
|
||||
shifts_by_position_day[(shift.position_id, day)].append(shift)
|
||||
|
||||
Referent = aliased(Volunteer)
|
||||
referent_rows = session.exec(
|
||||
select(Position.id, Referent)
|
||||
.join(Account, Account.id == Position.lead_account_id, isouter=True)
|
||||
.join(Referent, Referent.id == Account.volunteer_id, isouter=True)
|
||||
.where(Position.id.in_(position_ids))
|
||||
).all()
|
||||
referent_by_position: dict[uuid.UUID, Volunteer] = {
|
||||
pid: referent for pid, referent in referent_rows if referent is not None
|
||||
}
|
||||
|
||||
# Programmation du jour, tous postes confondus — voir `SheetArtist`.
|
||||
artists_by_date: dict[dt.date, list[Artist]] = defaultdict(list)
|
||||
for artist in session.exec(
|
||||
select(Artist)
|
||||
.where(Artist.edition_id == edition.id)
|
||||
.order_by(Artist.start_time)
|
||||
):
|
||||
artists_by_date[artist.performance_date].append(artist)
|
||||
|
||||
positions_by_id = {p.id: p for p in positions}
|
||||
|
||||
def _sheet(position_id: uuid.UUID, date: dt.date, day_shifts: list[Shift]) -> PositionDaySheet:
|
||||
referent = referent_by_position.get(position_id)
|
||||
return PositionDaySheet(
|
||||
position_name=positions_by_id[position_id].name,
|
||||
position_location=positions_by_id[position_id].location,
|
||||
date=date,
|
||||
referent_name=f"{referent.first_name} {referent.last_name}" if referent else None,
|
||||
referent_phone=referent.phone if referent else None,
|
||||
artists=tuple(
|
||||
SheetArtist(name=a.name, start_time=a.start_time, end_time=a.end_time)
|
||||
for a in artists_by_date.get(date, [])
|
||||
),
|
||||
shifts=tuple(
|
||||
SheetShift(
|
||||
starts_at=s.starts_at,
|
||||
ends_at=s.ends_at,
|
||||
min_staff=s.min_staff,
|
||||
ideal_staff=s.ideal_staff,
|
||||
volunteers=tuple(
|
||||
SheetVolunteer(
|
||||
name=f"{v.first_name} {v.last_name}", phone=v.phone, photo=v.photo
|
||||
)
|
||||
for v in volunteers_by_shift.get(s.id, [])
|
||||
),
|
||||
)
|
||||
for s in sorted(day_shifts, key=lambda s: s.starts_at)
|
||||
),
|
||||
)
|
||||
|
||||
sheets = [
|
||||
_sheet(position_id, date, day_shifts)
|
||||
for (position_id, date), day_shifts in sorted(
|
||||
shifts_by_position_day.items(),
|
||||
key=lambda item: (positions_by_id[item[0][0]].name, item[0][1]),
|
||||
)
|
||||
]
|
||||
if not sheets:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "position_sheets.no_shifts"}
|
||||
)
|
||||
|
||||
# Message de l'organisation (le même que sur l'écran bénévole) en bas de
|
||||
# chaque page.
|
||||
footer = (
|
||||
SheetFooter(title=t("position_sheets.footer_title"), message=edition.volunteer_message)
|
||||
if edition.volunteer_message
|
||||
else None
|
||||
)
|
||||
pdf_bytes = generate_position_sheets_pdf(sheets, footer)
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": 'attachment; filename="feuilles-de-poste.pdf"'},
|
||||
)
|
||||
@@ -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 (
|
||||
@@ -58,9 +58,9 @@ def create_position(
|
||||
_check_lead_account(session, edition, payload.lead_account_id)
|
||||
position = Position(edition_id=edition.id, **payload.model_dump())
|
||||
session.add(position)
|
||||
session.flush()
|
||||
# Un créneau à besoin 0/0 par jour et par créneau type de l'édition.
|
||||
generate_missing_shifts(session, edition, position)
|
||||
# Pas de créneaux générés automatiquement : la coordination choisit
|
||||
# elle-même ceux dont elle a besoin (créés à la main, ou en repartant des
|
||||
# créneaux types via `generate_shifts` ci-dessous).
|
||||
session.commit()
|
||||
session.refresh(position)
|
||||
return position
|
||||
@@ -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()
|
||||
|
||||
+459
-29
@@ -7,6 +7,13 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models import (
|
||||
AssignmentSource,
|
||||
AttendanceStatus,
|
||||
BadgeAlign,
|
||||
BadgeFace,
|
||||
BadgeFieldType,
|
||||
BadgePhotoShape,
|
||||
FeePaymentStatus,
|
||||
FormFieldType,
|
||||
PositionCategory,
|
||||
Role,
|
||||
)
|
||||
@@ -29,23 +36,34 @@ class EditionRead(ApiModel):
|
||||
start_date: dt.date
|
||||
end_date: dt.date
|
||||
retention_days: int | None
|
||||
volunteer_message: str | None
|
||||
volunteer_message_updated_at: dt.datetime | None
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
# Plafond d'un message (organisation, poste) : quelques paragraphes, pas un
|
||||
# document.
|
||||
MESSAGE_MAX_LENGTH = 2000
|
||||
|
||||
|
||||
class EditionUpdate(ApiModel):
|
||||
name: str | None = None
|
||||
year: int | None = None
|
||||
start_date: dt.date | None = None
|
||||
end_date: dt.date | None = None
|
||||
retention_days: int | None = None
|
||||
# Vide ou blanc = plus de message.
|
||||
volunteer_message: str | None = Field(default=None, max_length=MESSAGE_MAX_LENGTH)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Volunteer #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class VolunteerCreate(ApiModel):
|
||||
first_name: str
|
||||
last_name: str
|
||||
# Facultatifs à l'ajout manuel par la coordination (fiche complétée plus
|
||||
# tard) ; le formulaire public et l'import les exigent toujours.
|
||||
first_name: str = ""
|
||||
last_name: str = ""
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
comment: str | None = None
|
||||
@@ -67,6 +85,16 @@ class VolunteerUpdate(ApiModel):
|
||||
artist_ids: list[uuid.UUID] | None = None
|
||||
wished_position_ids: list[uuid.UUID] | None = None
|
||||
wished_companion_ids: list[uuid.UUID] | None = None
|
||||
# Issus du formulaire public (`app/api/forms.py`), éditables ensuite
|
||||
# depuis la fiche — voir CLAUDE.md § 2 (Volunteer).
|
||||
companion_wish_note: str | None = None
|
||||
# Texte libre du badge (champ `FREE_TEXT`), saisi par la coordination.
|
||||
badge_text: str | None = None
|
||||
# Exemplaires du badge sur les planches (1 à 100 ; garde-fou contre une
|
||||
# faute de frappe qui imprimerait des centaines de pages).
|
||||
badge_count: int | None = Field(default=None, ge=1, le=100)
|
||||
self_declared_available: bool | None = None
|
||||
willing_to_pay_fee: bool | None = None
|
||||
|
||||
|
||||
class VolunteerRead(ApiModel):
|
||||
@@ -80,6 +108,31 @@ class VolunteerRead(ApiModel):
|
||||
artist_ids: list[uuid.UUID]
|
||||
wished_position_ids: list[uuid.UUID]
|
||||
wished_companion_ids: list[uuid.UUID]
|
||||
has_photo: bool
|
||||
companion_wish_note: str | None
|
||||
badge_text: str | None
|
||||
badge_count: int
|
||||
self_declared_available: bool
|
||||
willing_to_pay_fee: bool
|
||||
# Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) —
|
||||
# `fee_payment_label`/`fee_amount` : copie figée du type de paiement
|
||||
# choisi (pas la config actuelle, qui peut avoir changé depuis).
|
||||
# `fee_payment_status` reste `None` tant qu'aucun checkout n'a jamais été
|
||||
# créé pour ce bénévole.
|
||||
fee_payment_label: str | None
|
||||
fee_amount: float | None
|
||||
fee_payment_status: FeePaymentStatus | None
|
||||
fee_paid_at: dt.datetime | None
|
||||
# Transaction SumUp ayant réglé la cotisation (code du tableau de bord
|
||||
# SumUp + identifiant technique) — `None` tant que non payée.
|
||||
fee_transaction_code: str | None
|
||||
fee_transaction_id: str | None
|
||||
# Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un
|
||||
# type de paiement « mécénat » (voir `FormPaymentOption.requires_mecenat_info`).
|
||||
mecenat_address: str | None
|
||||
mecenat_city: str | None
|
||||
mecenat_postal_code: str | None
|
||||
mecenat_country: str | None
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
@@ -100,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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -146,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):
|
||||
@@ -155,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
|
||||
|
||||
@@ -226,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 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -275,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)
|
||||
@@ -334,6 +374,77 @@ class MatrixResponse(ApiModel):
|
||||
positions: list[MatrixPosition]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Badges (planches PDF) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class BadgeFieldIn(ApiModel):
|
||||
face: BadgeFace = BadgeFace.FRONT
|
||||
field_type: BadgeFieldType
|
||||
x_mm: float
|
||||
y_mm: float
|
||||
width_mm: float
|
||||
height_mm: float
|
||||
font_size: float = 10.0
|
||||
align: BadgeAlign = BadgeAlign.LEFT
|
||||
bold: bool = False
|
||||
color: str = "#000000"
|
||||
# `FREE_TEXT` seulement (voir `BadgeField.text`), ignoré sinon.
|
||||
text: str | None = None
|
||||
# Fond de la zone (`#rrggbb`), `None` = transparent — voir `BadgeField`.
|
||||
bg_color: str | None = None
|
||||
auto_fit: bool = False
|
||||
# `PHOTO` seulement, ignoré sinon.
|
||||
photo_shape: BadgePhotoShape = BadgePhotoShape.RECT
|
||||
# Ordre d'empilement sur la face (voir `BadgeField.z_order`).
|
||||
z_order: int = 0
|
||||
|
||||
|
||||
class BadgeFieldsSet(ApiModel):
|
||||
"""Remplace l'intégralité des champs du recto et du verso — au plus un
|
||||
par (`face`, `field_type`) (décocher un champ dans l'éditeur retire sa
|
||||
ligne)."""
|
||||
|
||||
fields: list[BadgeFieldIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BadgeFieldRead(BadgeFieldIn):
|
||||
id: uuid.UUID
|
||||
edition_id: uuid.UUID
|
||||
|
||||
|
||||
class BadgeTemplateRead(ApiModel):
|
||||
edition_id: uuid.UUID
|
||||
has_front_image: bool
|
||||
has_back_image: bool
|
||||
# Dérivée de l'image du recto (voir `app/badges/pdf.py:image_size_mm`) ;
|
||||
# format CR80 par défaut tant qu'aucune image n'est importée.
|
||||
badge_width_mm: float
|
||||
badge_height_mm: float
|
||||
fields: list[BadgeFieldRead]
|
||||
|
||||
|
||||
class BadgeSizeUpdate(ApiModel):
|
||||
"""Ajuste la taille du badge indépendamment d'un nouvel import d'image —
|
||||
ex. agrandir proportionnellement une image importée trop petite (voir
|
||||
`app/api/badges.py:set_size`). Écrase la taille déduite de l'image."""
|
||||
|
||||
badge_width_mm: float
|
||||
badge_height_mm: float
|
||||
|
||||
|
||||
class BadgeGenerateRequest(ApiModel):
|
||||
# None/absent = tous les bénévoles de l'édition.
|
||||
volunteer_ids: list[uuid.UUID] | None = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Feuilles de poste (PDF) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class PositionSheetsRequest(ApiModel):
|
||||
# None/absent = tous les postes de l'édition.
|
||||
position_ids: list[uuid.UUID] | None = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Authentification et comptes #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -403,3 +514,322 @@ class PlanningNotificationResult(ApiModel):
|
||||
queued: int
|
||||
skipped_no_email: int
|
||||
recipients: list[uuid.UUID] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TestEmailRequest(ApiModel):
|
||||
email: str
|
||||
# Modèle du récapitulatif de planning en cours d'édition (mêmes
|
||||
# variables que `PlanningNotificationRequest`) ; absent = modèle par
|
||||
# défaut.
|
||||
subject: str | None = None
|
||||
body: str | None = None
|
||||
# Bénévole dont les données remplissent les variables ; absent = le
|
||||
# premier (par nom) qui a au moins un créneau.
|
||||
volunteer_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class TestEmailResult(ApiModel):
|
||||
"""`status` : `sent` / `disabled` / `connection_failed` / `rejected` —
|
||||
voir `app/email/sender.py:deliver_test`. `detail` : message du serveur
|
||||
SMTP en cas d'échec."""
|
||||
|
||||
status: str
|
||||
detail: str | None = None
|
||||
smtp_host: str
|
||||
smtp_from: str
|
||||
# Bénévole dont les données ont rempli le modèle (`None` : exemple
|
||||
# fictif, l'édition n'a encore aucun bénévole).
|
||||
example_volunteer_id: uuid.UUID | None = None
|
||||
example_volunteer_name: str
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Formulaire public d'inscription #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class FormFieldsSet(ApiModel):
|
||||
"""Remplace l'intégralité des champs activés du formulaire — nom/prénom
|
||||
toujours collectés, pas dans cette liste (voir `FormFieldType`)."""
|
||||
|
||||
fields: list[FormFieldType] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FormTemplateRead(ApiModel):
|
||||
edition_id: uuid.UUID
|
||||
is_open: bool
|
||||
fields: list[FormFieldType]
|
||||
has_logo: bool
|
||||
description: str | None
|
||||
sumup_merchant_code: str | None
|
||||
# `sumup_api_key` (secret) n'est jamais renvoyé — seulement s'il est
|
||||
# défini, comme `has_logo` pour le logo.
|
||||
sumup_api_key_set: bool
|
||||
# Secret nécessaire pour atteindre le formulaire public
|
||||
# (`/inscription/<access_token>`, voir CLAUDE.md § 2, `FormTemplate`) —
|
||||
# exposé seulement ici (COORDINATION), jamais par `PublicFormConfig`.
|
||||
access_token: str
|
||||
|
||||
|
||||
class FormTemplateUpdate(ApiModel):
|
||||
"""`None`/absent = inchangé pour chaque champ (`exclude_unset`) — permet
|
||||
de changer `is_open` sans toucher `description`/`sumup_*`, etc. Chaîne
|
||||
vide sur `sumup_api_key` = efface le secret enregistré."""
|
||||
|
||||
is_open: bool | None = None
|
||||
description: str | None = None
|
||||
sumup_merchant_code: str | None = None
|
||||
sumup_api_key: str | None = None
|
||||
|
||||
|
||||
class FormPaymentOptionRead(ApiModel):
|
||||
id: uuid.UUID
|
||||
edition_id: uuid.UUID
|
||||
label: str
|
||||
amount: float
|
||||
order: int
|
||||
requires_mecenat_info: bool
|
||||
free_amount: bool
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
class FormPaymentOptionCreate(ApiModel):
|
||||
label: str
|
||||
amount: float
|
||||
order: int = 0
|
||||
requires_mecenat_info: bool = False
|
||||
free_amount: bool = False
|
||||
|
||||
|
||||
class FormPaymentOptionUpdate(ApiModel):
|
||||
label: str | None = None
|
||||
amount: float | None = None
|
||||
order: int | None = None
|
||||
requires_mecenat_info: bool | None = None
|
||||
free_amount: bool | None = None
|
||||
|
||||
|
||||
class PublicPositionOption(ApiModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
|
||||
|
||||
class PublicArtistOption(ApiModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
performance_date: dt.date
|
||||
|
||||
|
||||
class PublicPaymentOption(ApiModel):
|
||||
"""Type de paiement proposé au bénévole sur le formulaire public — un
|
||||
montant n'est pas un secret, contrairement aux identifiants SumUp."""
|
||||
|
||||
id: uuid.UUID
|
||||
label: str
|
||||
# Montant fixe, ou minimum si `free_amount`.
|
||||
amount: float
|
||||
requires_mecenat_info: bool
|
||||
free_amount: bool
|
||||
|
||||
|
||||
class PublicFormConfig(ApiModel):
|
||||
"""Tout ce qu'il faut pour afficher le formulaire public — jamais plus :
|
||||
ni les bénévoles déjà inscrits, ni les créneaux/affectations de
|
||||
l'édition (voir `app/api/forms.py:get_public_form`)."""
|
||||
|
||||
is_open: bool
|
||||
fields: list[FormFieldType]
|
||||
positions: list[PublicPositionOption]
|
||||
artists: list[PublicArtistOption]
|
||||
has_logo: bool
|
||||
description: str | None
|
||||
# `True` si l'édition a configuré SumUp (identifiants marchand + au
|
||||
# moins un type de paiement) — utilisé par le front-end pour afficher le
|
||||
# choix de paiement près de `WILLING_TO_PAY_FEE`, sans jamais exposer les
|
||||
# identifiants eux-mêmes.
|
||||
sumup_enabled: bool
|
||||
payment_options: list[PublicPaymentOption]
|
||||
|
||||
|
||||
class FormSubmissionResult(ApiModel):
|
||||
"""Trois formes possibles, selon le choix de paiement (voir
|
||||
`app/api/forms.py:submit_public_form`) :
|
||||
- aucun paiement en ligne choisi (case désactivée, ou « je paierai sur
|
||||
place ») : `volunteer_id` seul, le bénévole est déjà en base ;
|
||||
- paiement en ligne, type « mécénat » : `pending_token` +
|
||||
`requires_mecenat_info=True`, rien en base encore — direction l'étape
|
||||
d'adresse (`submit_mecenat_info`) ;
|
||||
- paiement en ligne, type simple : `pending_token` + `sumup_checkout_url`,
|
||||
direction la page de paiement SumUp. Toujours rien en base tant que le
|
||||
paiement n'est pas confirmé (voir `PendingFormSubmission`)."""
|
||||
|
||||
volunteer_id: uuid.UUID | None = None
|
||||
pending_token: uuid.UUID | None = None
|
||||
requires_mecenat_info: bool = False
|
||||
sumup_checkout_url: str | None = None
|
||||
|
||||
|
||||
class MecenatInfoSubmit(ApiModel):
|
||||
"""Étape d'adresse postale pour un type de paiement « mécénat »
|
||||
(`POST .../form/public/pending/{token}/mecenat-info`) — tous obligatoires,
|
||||
nécessaires à l'édition d'un reçu fiscal (cerfa)."""
|
||||
|
||||
address: str
|
||||
city: str
|
||||
postal_code: str
|
||||
country: str
|
||||
|
||||
|
||||
class PendingSubmissionStatus(ApiModel):
|
||||
"""Réponse de `GET .../form/public/pending/{token}` — interrogée en
|
||||
direct par la page de confirmation jusqu'à ce que le statut ne soit plus
|
||||
`PENDING`. Relit toujours l'état réel auprès de SumUp (jamais seulement
|
||||
la dernière valeur connue), comme le webhook."""
|
||||
|
||||
status: FeePaymentStatus
|
||||
label: str
|
||||
amount: float
|
||||
volunteer_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class SumUpWebhookPayload(ApiModel):
|
||||
"""Corps envoyé par SumUp à `return_url` — voir la doc SumUp sur les
|
||||
webhooks. Le seul événement existant à ce jour."""
|
||||
|
||||
event_type: str
|
||||
id: str
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Statistiques d'une édition (`GET /editions/{id}/stats`, app/api/stats.py) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class StatsRegistrationDay(ApiModel):
|
||||
date: dt.date
|
||||
count: int
|
||||
cumulative: int
|
||||
|
||||
|
||||
class StatsRegistrations(ApiModel):
|
||||
"""Bénévoles en base, par jour d'ajout (`Volunteer.created_at`) — jours
|
||||
sans inscription inclus (à 0), pour une courbe sans trou."""
|
||||
|
||||
total: int
|
||||
last_7_days: int
|
||||
by_day: list[StatsRegistrationDay]
|
||||
|
||||
|
||||
class StatsAnswers(ApiModel):
|
||||
"""Nombre de bénévoles ayant renseigné chaque information (sur `total`)."""
|
||||
|
||||
total: int
|
||||
email: int
|
||||
phone: int
|
||||
photo: int
|
||||
comment: int
|
||||
companion_note: int
|
||||
available: int
|
||||
position_wishes: int
|
||||
artist_wishes: int
|
||||
companion_wishes: int
|
||||
|
||||
|
||||
class StatsPaymentOption(ApiModel):
|
||||
label: str
|
||||
mecenat: bool
|
||||
count: int
|
||||
amount_total: float
|
||||
|
||||
|
||||
class StatsPayments(ApiModel):
|
||||
"""Mode de paiement (même règle que `lib/fee.ts:feeMode`) : `online`
|
||||
(paiement en ligne confirmé), `on_site`, `none`. `pending_unfinished` :
|
||||
formulaires remplis dont le paiement en ligne n'a jamais abouti — aucun
|
||||
bénévole créé pour eux (`PendingFormSubmission` non promue)."""
|
||||
|
||||
online: int
|
||||
on_site: int
|
||||
none: int
|
||||
pending_unfinished: int
|
||||
total_collected: float
|
||||
mecenat_count: int
|
||||
mecenat_total: float
|
||||
by_option: list[StatsPaymentOption]
|
||||
|
||||
|
||||
class StatsArtistWish(ApiModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
date: dt.date
|
||||
count: int
|
||||
|
||||
|
||||
class StatsNeedDay(ApiModel):
|
||||
"""Par journée de festival (`validation/engine.py:festival_day`) : heures
|
||||
× personnes demandées (effectif minimal / idéal), heures déjà affectées."""
|
||||
|
||||
date: dt.date
|
||||
shifts: int
|
||||
min_hours: float
|
||||
ideal_hours: float
|
||||
assigned_hours: float
|
||||
|
||||
|
||||
class StatsNeedPosition(ApiModel):
|
||||
"""Places (personnes × créneaux) demandées et pourvues pour un poste, et
|
||||
nombre de bénévoles qui l'ont mis dans leurs vœux."""
|
||||
|
||||
position_id: uuid.UUID
|
||||
name: str
|
||||
shifts: int
|
||||
min_slots: int
|
||||
ideal_slots: int
|
||||
assigned: int
|
||||
# Places pourvues, plafonnées créneau par créneau à l'effectif idéal : un
|
||||
# sureffectif ne compense jamais un trou ailleurs.
|
||||
filled: int
|
||||
wishes: int
|
||||
|
||||
|
||||
class StatsNeeds(ApiModel):
|
||||
by_day: list[StatsNeedDay]
|
||||
by_position: list[StatsNeedPosition]
|
||||
volunteers_with_assignment: int
|
||||
|
||||
|
||||
class StatsAttendanceCounts(ApiModel):
|
||||
"""Pointage (`Assignment.attendance`) d'un ensemble d'affectations :
|
||||
`assigned` = `present` + `absent` + `unmarked`."""
|
||||
|
||||
assigned: int = 0
|
||||
present: int = 0
|
||||
absent: int = 0
|
||||
unmarked: int = 0
|
||||
|
||||
|
||||
class StatsAttendancePosition(ApiModel):
|
||||
position_id: uuid.UUID
|
||||
name: str
|
||||
# Créneaux terminés (`ends_at <= as_of`) et créneaux en cours.
|
||||
past: StatsAttendanceCounts
|
||||
live: StatsAttendanceCounts
|
||||
live_shifts: int
|
||||
|
||||
|
||||
class StatsAttendance(ApiModel):
|
||||
"""Présents / absents pointés par les référents, par poste : sur les
|
||||
créneaux déjà terminés et sur ceux en cours à `as_of` (heure du calcul,
|
||||
locale). Les créneaux à venir ne comptent pas."""
|
||||
|
||||
as_of: dt.datetime
|
||||
past: StatsAttendanceCounts
|
||||
live: StatsAttendanceCounts
|
||||
live_shifts: int
|
||||
by_position: list[StatsAttendancePosition]
|
||||
|
||||
|
||||
class EditionStats(ApiModel):
|
||||
registrations: StatsRegistrations
|
||||
answers: StatsAnswers
|
||||
payments: StatsPayments
|
||||
artist_wishes: list[StatsArtistWish]
|
||||
needs: StatsNeeds
|
||||
attendance: StatsAttendance
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
+176
-65
@@ -5,11 +5,18 @@ L'email n'est pas unique : plusieurs bénévoles peuvent en partager un même
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api._common import owned_or_404, replace_links
|
||||
from app.api._common import (
|
||||
owned_or_404,
|
||||
read_photo,
|
||||
replace_links,
|
||||
resolve_owned_ids,
|
||||
set_artists,
|
||||
set_wished_positions,
|
||||
)
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import (
|
||||
VolunteerCreate,
|
||||
@@ -23,11 +30,14 @@ from app.auth.deps import (
|
||||
require_staff,
|
||||
)
|
||||
from app.db import get_session
|
||||
from app.exporting import generate_volunteers_xlsx
|
||||
from app.mecenat import assign_receipt_number, generate_mecenat_receipts_pdf, receipt_for
|
||||
from app.models import (
|
||||
Account,
|
||||
Artist,
|
||||
Assignment,
|
||||
Edition,
|
||||
FeePaymentStatus,
|
||||
Position,
|
||||
Role,
|
||||
Shift,
|
||||
@@ -93,74 +103,20 @@ def _companion_ids(session: Session, volunteer_id: uuid.UUID) -> list[uuid.UUID]
|
||||
|
||||
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
|
||||
return VolunteerRead(
|
||||
**volunteer.model_dump(),
|
||||
**volunteer.model_dump(
|
||||
exclude={"photo", "photo_content_type", "fee_checkout_id"}
|
||||
),
|
||||
artist_ids=_artist_ids(session, volunteer.id),
|
||||
wished_position_ids=_wished_position_ids(session, volunteer.id),
|
||||
wished_companion_ids=_companion_ids(session, volunteer.id),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_owned_ids(
|
||||
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID], code: str
|
||||
) -> list[uuid.UUID]:
|
||||
"""Ordre d'entrée conservé, doublons retirés ; 422 si un id est étranger."""
|
||||
wanted = list(dict.fromkeys(ids))
|
||||
if wanted:
|
||||
found = set(
|
||||
session.exec(
|
||||
select(model.id).where(
|
||||
model.edition_id == edition_id,
|
||||
model.id.in_(wanted),
|
||||
)
|
||||
)
|
||||
)
|
||||
if len(found) != len(wanted):
|
||||
raise HTTPException(status_code=422, detail={"code": code})
|
||||
return wanted
|
||||
|
||||
|
||||
def _set_artists(
|
||||
session: Session, volunteer: Volunteer, artist_ids: list[uuid.UUID]
|
||||
) -> None:
|
||||
ids = _resolve_owned_ids(
|
||||
session, Artist, volunteer.edition_id, artist_ids, "artist.unknown"
|
||||
)
|
||||
replace_links(
|
||||
session,
|
||||
VolunteerArtist,
|
||||
filter_column=VolunteerArtist.volunteer_id,
|
||||
filter_value=volunteer.id,
|
||||
pairs=[
|
||||
{"volunteer_id": volunteer.id, "artist_id": aid} for aid in ids
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _set_wished_positions(
|
||||
session: Session, volunteer: Volunteer, position_ids: list[uuid.UUID]
|
||||
) -> None:
|
||||
ids = _resolve_owned_ids(
|
||||
session,
|
||||
Position,
|
||||
volunteer.edition_id,
|
||||
position_ids,
|
||||
"position.unknown",
|
||||
)
|
||||
replace_links(
|
||||
session,
|
||||
VolunteerPositionWish,
|
||||
filter_column=VolunteerPositionWish.volunteer_id,
|
||||
filter_value=volunteer.id,
|
||||
pairs=[
|
||||
{"volunteer_id": volunteer.id, "position_id": pid} for pid in ids
|
||||
],
|
||||
has_photo=volunteer.photo is not None,
|
||||
)
|
||||
|
||||
|
||||
def _set_companions(
|
||||
session: Session, volunteer: Volunteer, companion_ids: list[uuid.UUID]
|
||||
) -> None:
|
||||
ids = _resolve_owned_ids(
|
||||
ids = resolve_owned_ids(
|
||||
session, Volunteer, volunteer.edition_id, companion_ids, "volunteer.unknown"
|
||||
)
|
||||
if volunteer.id in ids:
|
||||
@@ -195,8 +151,8 @@ def create_volunteer(
|
||||
)
|
||||
session.add(volunteer)
|
||||
session.flush()
|
||||
_set_artists(session, volunteer, payload.artist_ids)
|
||||
_set_wished_positions(session, volunteer, payload.wished_position_ids)
|
||||
set_artists(session, volunteer, payload.artist_ids)
|
||||
set_wished_positions(session, volunteer, payload.wished_position_ids)
|
||||
_set_companions(session, volunteer, payload.wished_companion_ids)
|
||||
session.commit()
|
||||
session.refresh(volunteer)
|
||||
@@ -217,6 +173,100 @@ def list_volunteers(
|
||||
return [_read(session, v) for v in volunteers]
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_volunteers(
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
"""Export Excel de la liste des bénévoles — vœux (postes, artistes,
|
||||
compagnons) résolus en noms lisibles, pas en UUID. Déclaré avant
|
||||
`/{volunteer_id}` : un chemin statique doit être enregistré avant le
|
||||
chemin dynamique qui le suivrait sinon FastAPI essaierait de parser
|
||||
« export » comme un UUID."""
|
||||
volunteers_orm = list(
|
||||
session.exec(
|
||||
select(Volunteer)
|
||||
.where(Volunteer.edition_id == edition.id)
|
||||
.order_by(Volunteer.last_name, Volunteer.first_name)
|
||||
)
|
||||
)
|
||||
volunteers = [_read(session, v) for v in volunteers_orm]
|
||||
position_names = {
|
||||
p.id: p.name
|
||||
for p in session.exec(
|
||||
select(Position).where(Position.edition_id == edition.id)
|
||||
)
|
||||
}
|
||||
artist_names = {
|
||||
a.id: a.name
|
||||
for a in session.exec(select(Artist).where(Artist.edition_id == edition.id))
|
||||
}
|
||||
volunteer_names = {
|
||||
v.id: f"{v.first_name} {v.last_name}" for v in volunteers_orm
|
||||
}
|
||||
|
||||
content = generate_volunteers_xlsx(
|
||||
volunteers,
|
||||
position_names=position_names,
|
||||
artist_names=artist_names,
|
||||
volunteer_names=volunteer_names,
|
||||
)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
),
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="benevoles-{edition.year}.xlsx"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/mecenat-receipts")
|
||||
def export_mecenat_receipts(
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
"""Reçus fiscaux (cerfa 11580*05, `app/mecenat/pdf.py`) des bénévoles
|
||||
ayant payé un type de paiement « mécénat » — repérés par
|
||||
`mecenat_address` renseigné (voir CLAUDE.md § 2, Paiement de la
|
||||
cotisation : ce champ n'est rempli que pour ce chemin-là). Déclaré avant
|
||||
`/{volunteer_id}`, comme `/export` juste au-dessus."""
|
||||
volunteers = list(
|
||||
session.exec(
|
||||
select(Volunteer)
|
||||
.where(Volunteer.edition_id == edition.id)
|
||||
.where(Volunteer.fee_payment_status == FeePaymentStatus.PAID)
|
||||
.where(Volunteer.mecenat_address.is_not(None))
|
||||
.order_by(Volunteer.last_name, Volunteer.first_name)
|
||||
)
|
||||
)
|
||||
if not volunteers:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "mecenat.no_receipts"}
|
||||
)
|
||||
|
||||
# Bénévoles payés avant la numérotation (ou jamais exportés) : numéro
|
||||
# attribué maintenant, dans l'ordre des paiements, puis figé.
|
||||
unnumbered = [v for v in volunteers if not v.mecenat_receipt_number]
|
||||
if unnumbered:
|
||||
for v in sorted(unnumbered, key=lambda v: v.fee_paid_at or v.created_at):
|
||||
assign_receipt_number(session, v)
|
||||
session.commit()
|
||||
|
||||
receipts = [receipt_for(v) for v in volunteers]
|
||||
content = generate_mecenat_receipts_pdf(receipts)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="recus-mecenat-{edition.year}.pdf"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{volunteer_id}", response_model=VolunteerRead)
|
||||
def get_volunteer(
|
||||
volunteer_id: uuid.UUID,
|
||||
@@ -267,11 +317,72 @@ 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
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{volunteer_id}/photo")
|
||||
def get_volunteer_photo(
|
||||
volunteer_id: uuid.UUID,
|
||||
edition: Edition = Depends(get_edition),
|
||||
account: Account = Depends(current_account_in_edition),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
"""Photo transmise par le bénévole via le formulaire public
|
||||
(`app/api/forms.py`) — COORDINATION/RÉFÉRENT (ex. écran référent, en
|
||||
face du nom de chaque membre de l'équipe) ou le bénévole lui-même (écran
|
||||
bénévole)."""
|
||||
_ensure_can_read(account, volunteer_id)
|
||||
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||
if volunteer.photo is None:
|
||||
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
|
||||
return Response(
|
||||
content=volunteer.photo,
|
||||
media_type=volunteer.photo_content_type or "application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{volunteer_id}/photo", response_model=VolunteerRead)
|
||||
async def set_volunteer_photo(
|
||||
volunteer_id: uuid.UUID,
|
||||
file: UploadFile = File(...),
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> VolunteerRead:
|
||||
"""Ajout (ou remplacement) de la photo par la coordination, depuis la
|
||||
fiche — pour un bénévole qui n'en a pas envoyé au formulaire public ou
|
||||
ajouté à la main. Même validation que le formulaire (`read_photo`)."""
|
||||
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||
photo = await read_photo(file)
|
||||
if photo is None:
|
||||
raise HTTPException(status_code=400, detail={"code": "volunteer.empty_photo"})
|
||||
volunteer.photo, volunteer.photo_content_type = photo
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
session.refresh(volunteer)
|
||||
return _read(session, volunteer)
|
||||
|
||||
|
||||
@router.delete("/{volunteer_id}/photo", response_model=VolunteerRead)
|
||||
def delete_volunteer_photo(
|
||||
volunteer_id: uuid.UUID,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> VolunteerRead:
|
||||
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||
volunteer.photo = None
|
||||
volunteer.photo_content_type = None
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
session.refresh(volunteer)
|
||||
return _read(session, volunteer)
|
||||
|
||||
|
||||
@router.patch("/{volunteer_id}", response_model=VolunteerRead)
|
||||
def update_volunteer(
|
||||
volunteer_id: uuid.UUID,
|
||||
@@ -289,9 +400,9 @@ def update_volunteer(
|
||||
setattr(volunteer, field, value)
|
||||
session.flush()
|
||||
if artist_ids is not None:
|
||||
_set_artists(session, volunteer, artist_ids)
|
||||
set_artists(session, volunteer, artist_ids)
|
||||
if wished_position_ids is not None:
|
||||
_set_wished_positions(session, volunteer, wished_position_ids)
|
||||
set_wished_positions(session, volunteer, wished_position_ids)
|
||||
if wished_companion_ids is not None:
|
||||
_set_companions(session, volunteer, wished_companion_ids)
|
||||
session.commit()
|
||||
|
||||
@@ -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
|
||||
@@ -60,3 +60,21 @@ login_limiter = RateLimiter(max_attempts=5, window_seconds=60)
|
||||
# trois fois ; au-delà c'est soit un abus, soit un email qui n'arrivera pas
|
||||
# plus vite en insistant.
|
||||
magic_link_limiter = RateLimiter(max_attempts=3, window_seconds=300)
|
||||
# Formulaire public d'inscription (`app/api/forms.py`) : pas d'identité
|
||||
# fiable à clé (n'importe qui peut soumettre des emails différents à chaque
|
||||
# fois, et l'IP réelle du client n'est pas encore transmise par Caddy — voir
|
||||
# plus haut) ; hCaptcha est la défense principale contre les robots, ceci
|
||||
# n'est qu'un filet de sécurité global côté édition contre un emballement
|
||||
# malgré tout (30 inscriptions/5 min est très large pour un usage réel).
|
||||
form_submission_limiter = RateLimiter(max_attempts=30, window_seconds=300)
|
||||
# Interrogation du statut d'une inscription en attente de paiement
|
||||
# (`GET .../form/public/pending/{token}`) : relit toujours l'état réel
|
||||
# auprès de SumUp (voir `app/api/forms.py:get_pending_status`), donc chaque
|
||||
# appel coûte une requête à l'API SumUp de l'édition. Clé par jeton (pas par
|
||||
# édition, contrairement à `form_submission_limiter`) : plusieurs
|
||||
# inscriptions en cours en même temps ne doivent pas s'épuiser mutuellement
|
||||
# leur quota de sondage. 60/5 min couvre largement le sondage légitime (la
|
||||
# page de confirmation interroge toutes les 3 s, jusqu'à 40 fois — voir
|
||||
# `PublicSignupPage.tsx`), tout en bornant un sondage en boucle serrée qui
|
||||
# n'aurait d'autre effet que de solliciter inutilement l'API SumUp.
|
||||
pending_status_limiter = RateLimiter(max_attempts=60, window_seconds=300)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from app.badges.pdf import (
|
||||
DEFAULT_BADGE_HEIGHT_MM,
|
||||
DEFAULT_BADGE_WIDTH_MM,
|
||||
BadgeFieldSpec,
|
||||
BadgeSpec,
|
||||
generate_badge_sheets_pdf,
|
||||
image_size_mm,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_BADGE_HEIGHT_MM",
|
||||
"DEFAULT_BADGE_WIDTH_MM",
|
||||
"BadgeFieldSpec",
|
||||
"BadgeSpec",
|
||||
"generate_badge_sheets_pdf",
|
||||
"image_size_mm",
|
||||
]
|
||||
@@ -0,0 +1,562 @@
|
||||
"""Génération des planches de badges bénévoles (PDF).
|
||||
|
||||
Module isolé, fonction pure (`generate_badge_sheets_pdf`) : aucun accès base
|
||||
ici — voir `app/api/badges.py` pour l'assemblage des données (bénévole →
|
||||
champs résolus en texte).
|
||||
|
||||
Format du badge : dérivé de l'image de fond du recto (pas de format fixe
|
||||
imposé) — voir `image_size_mm`. Tant qu'aucune image n'est importée,
|
||||
`DEFAULT_BADGE_WIDTH_MM`/`DEFAULT_BADGE_HEIGHT_MM` (CR80, 85,6 × 54 mm — la
|
||||
taille standard des badges/cartes d'événement) servent de repère à
|
||||
l'éditeur. Planche : grille sur page A4 portrait. Chaque page de recto est
|
||||
suivie de sa page de verso (même image de fond à chaque emplacement rempli,
|
||||
plus les champs du verso de chaque bénévole) — impression recto-verso
|
||||
classique, retournement sur le bord long : sur la page de verso, chaque
|
||||
badge est placé en miroir horizontal (colonne `cols - 1 - col`, même rangée)
|
||||
pour tomber exactement derrière son recto — voir `_cell_origin`.
|
||||
"""
|
||||
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
from reportlab.lib.colors import HexColor
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
DEFAULT_BADGE_WIDTH_MM = 85.6
|
||||
DEFAULT_BADGE_HEIGHT_MM = 54.0
|
||||
# Minimums pour compter combien de badges tiennent sur la page (marge
|
||||
# d'impression, écart entre deux badges) ; l'espace effectivement laissé est
|
||||
# ensuite réparti à parts égales — voir `_spacing`.
|
||||
_MARGIN_MM = 10.0
|
||||
_GUTTER_MM = 4.0
|
||||
_LINE_HEIGHT_FACTOR = 1.25
|
||||
# Tableau (champ « postes ») : marges intérieures des cellules et épaisseur
|
||||
# des filets.
|
||||
_CELL_PAD_X_MM = 0.8
|
||||
_CELL_PAD_Y_MM = 0.5
|
||||
_TABLE_LINE_WIDTH_PT = 0.4
|
||||
# Réduction automatique de la taille du texte (`auto_fit`) : pas et plancher.
|
||||
_AUTO_FIT_STEP_PT = 0.5
|
||||
_AUTO_FIT_MIN_PT = 4.0
|
||||
# Photo absente ou illisible : initiales sur ce fond, à cette taille (part de
|
||||
# la largeur du cadre).
|
||||
|
||||
# Résolution supposée d'une image de fond importée, pour convertir ses pixels
|
||||
# en millimètres réels (donc en taille de badge) — 300 dpi est la résolution
|
||||
# d'impression standard.
|
||||
ASSUMED_DPI = 300.0
|
||||
|
||||
_PAGE_WIDTH_MM = A4[0] / mm
|
||||
_PAGE_HEIGHT_MM = A4[1] / mm
|
||||
|
||||
|
||||
def image_size_mm(width_px: int, height_px: int, dpi: float = ASSUMED_DPI) -> tuple[float, float]:
|
||||
"""Taille physique (mm) d'une image, en supposant qu'elle a été exportée
|
||||
à `dpi` — c'est cette taille qui devient celle du badge (voir
|
||||
`app/api/badges.py:set_front_image`)."""
|
||||
return (width_px / dpi * 25.4, height_px / dpi * 25.4)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BadgeTableSection:
|
||||
"""Un bloc du tableau : une ligne d'en-tête sur toute la largeur (le
|
||||
jour), puis ses lignes (horaire, poste)."""
|
||||
|
||||
title: str
|
||||
rows: tuple[tuple[str, ...], ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BadgeTable:
|
||||
"""Contenu d'un champ affiché en tableau plutôt qu'en texte libre : les
|
||||
créneaux du bénévole regroupés par jour."""
|
||||
|
||||
sections: tuple[BadgeTableSection, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BadgeFieldSpec:
|
||||
"""Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n`
|
||||
possibles — ex. prénom puis nom), positionné depuis le coin haut-gauche
|
||||
du badge (comme dans l'éditeur visuel du front-end).
|
||||
|
||||
`is_photo` : le champ est la photo du bénévole (`image`, ses octets
|
||||
bruts tels que stockés) plutôt qu'un texte, découpée selon
|
||||
`photo_shape` (`RECT`/`OVAL`). Sans photo (ou photo illisible), rien
|
||||
n'est imprimé : le cadre reste vide.
|
||||
|
||||
`table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`.
|
||||
|
||||
`bg_color` : fond plein de la zone du champ (`None` = transparent ;
|
||||
ignoré pour la photo). `auto_fit` : réduit la taille du texte jusqu'à ce
|
||||
que tout tienne dans le cadre (sinon, les lignes en trop ne sont pas
|
||||
imprimées). `valign` : `TOP` ou `MIDDLE` (centré verticalement)."""
|
||||
|
||||
text: str
|
||||
x_mm: float
|
||||
y_mm: float
|
||||
width_mm: float
|
||||
height_mm: float
|
||||
font_size: float
|
||||
align: str # "LEFT" | "CENTER" | "RIGHT"
|
||||
bold: bool
|
||||
color: str # "#rrggbb"
|
||||
is_photo: bool = False
|
||||
image: bytes | None = None
|
||||
table: BadgeTable | None = None
|
||||
bg_color: str | None = None
|
||||
auto_fit: bool = False
|
||||
valign: str = "TOP" # "TOP" | "MIDDLE"
|
||||
photo_shape: str = "RECT" # "RECT" | "OVAL"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BadgeSpec:
|
||||
"""Un badge = un bénévole, avec ses champs déjà résolus : `fields` pour
|
||||
le recto, `back_fields` pour le verso."""
|
||||
|
||||
fields: tuple[BadgeFieldSpec, ...]
|
||||
back_fields: tuple[BadgeFieldSpec, ...] = ()
|
||||
|
||||
|
||||
def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]:
|
||||
usable_w = _PAGE_WIDTH_MM - 2 * _MARGIN_MM
|
||||
usable_h = _PAGE_HEIGHT_MM - 2 * _MARGIN_MM
|
||||
cols = max(1, int((usable_w + _GUTTER_MM) // (badge_width_mm + _GUTTER_MM)))
|
||||
rows = max(1, int((usable_h + _GUTTER_MM) // (badge_height_mm + _GUTTER_MM)))
|
||||
return cols, rows
|
||||
|
||||
|
||||
def _spacing(count: int, page_mm: float, size_mm: float) -> tuple[float, float]:
|
||||
"""`(marge extérieure, écart entre badges)` sur un axe, en mm : l'espace
|
||||
libre est réparti **à parts égales** — même écart à gauche, entre chaque
|
||||
badge et à droite (resp. en haut, entre les rangées et en bas) — pour une
|
||||
grille centrée et symétrique. Si cet écart passait sous la marge
|
||||
d'impression (`_MARGIN_MM`), la marge est gardée de chaque côté et le
|
||||
reste réparti entre les badges : toujours symétrique. Avant, la grille
|
||||
partait du coin haut-gauche et tout l'espace restant tombait à droite et
|
||||
en bas ; une fois la feuille retournée, le verso se décalait d'autant par
|
||||
rapport au recto (bug corrigé une fois)."""
|
||||
free = page_mm - count * size_mm
|
||||
even = free / (count + 1)
|
||||
if even >= _MARGIN_MM or count == 1:
|
||||
return (even, even) if count > 1 else (free / 2, 0.0)
|
||||
return _MARGIN_MM, (free - 2 * _MARGIN_MM) / (count - 1)
|
||||
|
||||
|
||||
def _cell_origin(
|
||||
index: int,
|
||||
cols: int,
|
||||
badge_width_mm: float,
|
||||
badge_height_mm: float,
|
||||
mirrored: bool = False,
|
||||
) -> tuple[float, float]:
|
||||
"""Coin bas-gauche (repère PDF) de l'emplacement `index` sur la page,
|
||||
en mm depuis le coin bas-gauche de la page.
|
||||
|
||||
`mirrored` : page de verso. Une feuille retournée sur son bord long
|
||||
inverse gauche et droite : le verso du badge de la colonne `col` doit
|
||||
être imprimé en colonne `cols - 1 - col` (même rangée) pour tomber
|
||||
derrière son recto — y compris sur une dernière planche incomplète."""
|
||||
col, row = index % cols, index // cols
|
||||
if mirrored:
|
||||
col = cols - 1 - col
|
||||
rows = _grid_size(badge_width_mm, badge_height_mm)[1]
|
||||
outer_x, gap_x = _spacing(cols, _PAGE_WIDTH_MM, badge_width_mm)
|
||||
outer_y, gap_y = _spacing(rows, _PAGE_HEIGHT_MM, badge_height_mm)
|
||||
x0 = outer_x + col * (badge_width_mm + gap_x)
|
||||
y0 = _PAGE_HEIGHT_MM - outer_y - (row + 1) * badge_height_mm - row * gap_y
|
||||
return x0, y0
|
||||
|
||||
|
||||
def _draw_background(
|
||||
c: canvas.Canvas,
|
||||
image: bytes | None,
|
||||
x0_mm: float,
|
||||
y0_mm: float,
|
||||
badge_width_mm: float,
|
||||
badge_height_mm: float,
|
||||
) -> None:
|
||||
if not image:
|
||||
return
|
||||
c.drawImage(
|
||||
ImageReader(io.BytesIO(image)),
|
||||
x0_mm * mm,
|
||||
y0_mm * mm,
|
||||
width=badge_width_mm * mm,
|
||||
height=badge_height_mm * mm,
|
||||
# Le format du badge est désormais dérivé de l'image du recto (voir
|
||||
# `image_size_mm`), donc déjà au bon ratio ; `True` évite en plus
|
||||
# toute déformation du verso s'il n'a pas exactement le même ratio.
|
||||
preserveAspectRatio=True,
|
||||
anchor="c",
|
||||
mask="auto",
|
||||
)
|
||||
|
||||
|
||||
def _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None:
|
||||
"""Recadre la photo au ratio du cadre (centrée, sans déformation — une
|
||||
photo de téléphone n'a jamais le ratio du cadre) et la réduit à la
|
||||
résolution d'impression : une photo de plusieurs Mo répétée sur 200
|
||||
badges alourdirait inutilement le PDF. Applique l'orientation EXIF (une
|
||||
photo prise en portrait au téléphone est souvent stockée couchée).
|
||||
`None` si l'image est illisible : le badge s'imprime sans photo plutôt
|
||||
que de faire échouer toute la planche."""
|
||||
target = (
|
||||
max(1, round(width_mm / 25.4 * ASSUMED_DPI)),
|
||||
max(1, round(height_mm / 25.4 * ASSUMED_DPI)),
|
||||
)
|
||||
try:
|
||||
with Image.open(io.BytesIO(image)) as img:
|
||||
# JPEG : décodage directement à taille réduite (bien plus rapide
|
||||
# qu'une photo de téléphone décodée en pleine résolution).
|
||||
img.draft("RGB", (target[0] * 2, target[1] * 2))
|
||||
fitted = ImageOps.fit(ImageOps.exif_transpose(img).convert("RGB"), target)
|
||||
except (UnidentifiedImageError, OSError):
|
||||
return None
|
||||
out = io.BytesIO()
|
||||
fitted.save(out, format="JPEG", quality=88)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def _frame_rect(
|
||||
f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""Cadre du champ en points, repère PDF : (gauche, bas, largeur,
|
||||
hauteur). `f.y_mm` est le haut du cadre depuis le HAUT du badge ;
|
||||
reportlab dessine depuis le BAS de la page."""
|
||||
return (
|
||||
(x0_mm + f.x_mm) * mm,
|
||||
(y0_mm + badge_height_mm - f.y_mm - f.height_mm) * mm,
|
||||
f.width_mm * mm,
|
||||
f.height_mm * mm,
|
||||
)
|
||||
|
||||
|
||||
def _shape_path(c: canvas.Canvas, shape: str, x: float, y: float, w: float, h: float):
|
||||
path = c.beginPath()
|
||||
if shape == "OVAL":
|
||||
path.ellipse(x, y, w, h)
|
||||
else:
|
||||
path.rect(x, y, w, h)
|
||||
return path
|
||||
|
||||
|
||||
# Photos déjà recadrées pendant une génération, par (photo, largeur,
|
||||
# hauteur du cadre) : un bénévole a souvent la même photo au recto, au verso
|
||||
# et sur plusieurs exemplaires (`badge_count`) — elle n'est traitée qu'une
|
||||
# fois.
|
||||
PhotoCache = dict[tuple[bytes, float, float], bytes | None]
|
||||
|
||||
|
||||
def _draw_photo(
|
||||
c: canvas.Canvas,
|
||||
f: BadgeFieldSpec,
|
||||
x0_mm: float,
|
||||
y0_mm: float,
|
||||
badge_height_mm: float,
|
||||
cache: PhotoCache,
|
||||
) -> None:
|
||||
x, y, w, h = _frame_rect(f, x0_mm, y0_mm, badge_height_mm)
|
||||
photo = None
|
||||
if f.image:
|
||||
key = (f.image, f.width_mm, f.height_mm)
|
||||
if key not in cache:
|
||||
cache[key] = _fit_photo(f.image, f.width_mm, f.height_mm)
|
||||
photo = cache[key]
|
||||
if photo is None:
|
||||
# Pas de photo (ou illisible) : cadre laissé vide, choix de la
|
||||
# coordination — avant, les initiales étaient imprimées à la place.
|
||||
return
|
||||
c.saveState()
|
||||
if f.photo_shape == "OVAL":
|
||||
# Découpe en ellipse inscrite dans le cadre.
|
||||
c.clipPath(_shape_path(c, "OVAL", x, y, w, h), stroke=0, fill=0)
|
||||
c.drawImage(ImageReader(io.BytesIO(photo)), x, y, width=w, height=h)
|
||||
c.restoreState()
|
||||
|
||||
|
||||
def _draw_zone_background(
|
||||
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||||
) -> None:
|
||||
if not f.bg_color:
|
||||
return
|
||||
c.saveState()
|
||||
c.setFillColor(HexColor(f.bg_color))
|
||||
c.rect(*_frame_rect(f, x0_mm, y0_mm, badge_height_mm), stroke=0, fill=1)
|
||||
c.restoreState()
|
||||
|
||||
|
||||
def wrap_text(text: str, font_name: str, font_size: float, max_width_pt: float) -> list[str]:
|
||||
"""Découpe `text` en lignes qui tiennent dans `max_width_pt` : les `\\n`
|
||||
du texte restent des retours à la ligne, puis chaque ligne trop longue
|
||||
passe à la ligne entre deux mots — ou au milieu d'un mot qui, seul, ne
|
||||
tient pas (nom très long dans un cadre étroit). Sans cela, un prénom +
|
||||
nom ou une ligne de poste avec horaire débordait du cadre du champ."""
|
||||
|
||||
def fits(candidate: str) -> bool:
|
||||
return stringWidth(candidate, font_name, font_size) <= max_width_pt
|
||||
|
||||
lines: list[str] = []
|
||||
for paragraph in text.split("\n"):
|
||||
current = ""
|
||||
# `split(" ")` (et non `split()`) garde les doubles espaces voulus,
|
||||
# ex. entre le nom du poste et son horaire.
|
||||
for word in paragraph.split(" "):
|
||||
candidate = f"{current} {word}" if current else word
|
||||
if fits(candidate):
|
||||
current = candidate
|
||||
continue
|
||||
if current:
|
||||
lines.append(current.rstrip())
|
||||
current = word
|
||||
while current and not fits(current):
|
||||
cut = len(current) - 1
|
||||
while cut > 1 and not fits(current[:cut]):
|
||||
cut -= 1
|
||||
lines.append(current[:cut])
|
||||
current = current[cut:]
|
||||
lines.append(current.rstrip())
|
||||
return lines
|
||||
|
||||
|
||||
def _column_widths(natural: list[float], available: float) -> list[float]:
|
||||
"""Largeurs des colonnes d'un tableau dans `available` : une colonne
|
||||
courte (jour, horaire) garde sa largeur naturelle, les plus longues
|
||||
(poste, référent) se partagent le reste à parts égales et passent à la
|
||||
ligne. S'il reste de la place, la dernière colonne la prend."""
|
||||
widths = [0.0] * len(natural)
|
||||
pending = sorted(range(len(natural)), key=lambda i: natural[i])
|
||||
remaining = available
|
||||
while pending:
|
||||
share = remaining / len(pending)
|
||||
if natural[pending[0]] > share:
|
||||
for i in pending:
|
||||
widths[i] = share
|
||||
return widths
|
||||
i = pending.pop(0)
|
||||
widths[i] = natural[i]
|
||||
remaining -= natural[i]
|
||||
widths[-1] += remaining
|
||||
return widths
|
||||
|
||||
|
||||
_TableLine = tuple[list[list[str]], str, bool, float]
|
||||
|
||||
|
||||
def _table_layout(
|
||||
table: BadgeTable, width_pt: float, font_size: float
|
||||
) -> tuple[list[float], list[_TableLine]]:
|
||||
"""Largeurs des colonnes et lignes du tableau à `font_size` : pour
|
||||
chaque ligne, (texte replié de chaque cellule, police, en-tête de
|
||||
section ?, hauteur en points)."""
|
||||
regular, bold = "Helvetica", "Helvetica-Bold"
|
||||
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
|
||||
line_height = font_size * _LINE_HEIGHT_FACTOR
|
||||
body = [row for section in table.sections for row in section.rows]
|
||||
columns = max(len(row) for row in body) if body else 1
|
||||
natural = [
|
||||
max(stringWidth(row[col], regular, font_size) for row in body) + 2 * pad_x
|
||||
for col in range(columns)
|
||||
] if body else [0.0]
|
||||
widths = _column_widths(natural, width_pt)
|
||||
total_width = sum(widths)
|
||||
|
||||
# Un en-tête n'a qu'une cellule, sur toute la largeur.
|
||||
cells_to_draw = []
|
||||
for section in table.sections:
|
||||
cells_to_draw.append(((section.title,), bold, True))
|
||||
cells_to_draw.extend((row, regular, False) for row in section.rows)
|
||||
|
||||
lines: list[_TableLine] = []
|
||||
for cells, font, is_title in cells_to_draw:
|
||||
cell_widths = [total_width] if is_title else widths
|
||||
wrapped = [
|
||||
wrap_text(text, font, font_size, max(1.0, cell_widths[col] - 2 * pad_x))
|
||||
for col, text in enumerate(cells)
|
||||
]
|
||||
row_height = max(len(w) for w in wrapped) * line_height + 2 * pad_y
|
||||
lines.append((wrapped, font, is_title, row_height))
|
||||
return widths, lines
|
||||
|
||||
|
||||
def _draw_table(
|
||||
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||||
) -> None:
|
||||
"""Tableau dans le cadre du champ : pour chaque section, une ligne
|
||||
d'en-tête en gras sur toute la largeur (le jour), puis ses lignes ;
|
||||
filets à la couleur du champ, texte de chaque cellule replié dans sa
|
||||
colonne (`wrap_text`). Une ligne qui ne tient plus dans la hauteur du
|
||||
cadre n'est pas dessinée, ni les suivantes — comme les lignes d'un champ
|
||||
texte — sauf avec `auto_fit`, qui réduit la taille du texte jusqu'à ce
|
||||
que toutes les lignes tiennent (plancher `_AUTO_FIT_MIN_PT`)."""
|
||||
table = f.table
|
||||
if table is None or not table.sections:
|
||||
return
|
||||
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
|
||||
width_pt, height_pt = f.width_mm * mm, f.height_mm * mm
|
||||
font_size = f.font_size
|
||||
widths, lines_to_draw = _table_layout(table, width_pt, font_size)
|
||||
if f.auto_fit:
|
||||
while (
|
||||
sum(line[3] for line in lines_to_draw) > height_pt + 0.01
|
||||
and font_size - _AUTO_FIT_STEP_PT >= _AUTO_FIT_MIN_PT
|
||||
):
|
||||
font_size -= _AUTO_FIT_STEP_PT
|
||||
widths, lines_to_draw = _table_layout(table, width_pt, font_size)
|
||||
line_height = font_size * _LINE_HEIGHT_FACTOR
|
||||
total_width = sum(widths)
|
||||
|
||||
left = (x0_mm + f.x_mm) * mm
|
||||
top = (y0_mm + badge_height_mm - f.y_mm) * mm
|
||||
bottom_limit = top - height_pt
|
||||
color = HexColor(f.color)
|
||||
c.setFillColor(color)
|
||||
c.setStrokeColor(color)
|
||||
c.setLineWidth(_TABLE_LINE_WIDTH_PT)
|
||||
|
||||
y = top
|
||||
for wrapped, font, is_title, row_height in lines_to_draw:
|
||||
cell_widths = [total_width] if is_title else widths
|
||||
if y - row_height < bottom_limit - 0.01:
|
||||
break # la ligne déborderait du cadre du champ
|
||||
c.setFont(font, font_size)
|
||||
x = left
|
||||
for col, lines in enumerate(wrapped):
|
||||
for i, line in enumerate(lines):
|
||||
baseline = y - pad_y - font_size * 0.85 - i * line_height
|
||||
c.drawString(x + pad_x, baseline, line)
|
||||
x += cell_widths[col]
|
||||
if not is_title:
|
||||
# Séparations de colonnes, sur la hauteur de cette ligne seulement.
|
||||
x = left
|
||||
for width in widths[:-1]:
|
||||
x += width
|
||||
c.line(x, y, x, y - row_height)
|
||||
y -= row_height
|
||||
c.line(left, y, left + total_width, y)
|
||||
|
||||
if y == top:
|
||||
return # pas même le premier en-tête ne tient
|
||||
c.rect(left, y, total_width, top - y, stroke=1, fill=0)
|
||||
|
||||
|
||||
def _lines_that_fit(font_size: float, height_mm: float) -> int:
|
||||
"""Nombre de lignes de texte qui tiennent dans `height_mm` (première
|
||||
ligne de base à 0,85 × la taille sous le haut du cadre, puis un
|
||||
interligne par ligne)."""
|
||||
font_size_mm = font_size / mm
|
||||
first = font_size_mm * 0.85
|
||||
if first > height_mm + 1e-9:
|
||||
return 0
|
||||
return 1 + int((height_mm - first) / (font_size_mm * _LINE_HEIGHT_FACTOR) + 1e-9)
|
||||
|
||||
|
||||
def fit_text(
|
||||
text: str, font_name: str, font_size: float, width_mm: float, height_mm: float,
|
||||
auto_fit: bool,
|
||||
) -> tuple[float, list[str]]:
|
||||
"""Taille retenue et lignes à imprimer d'un champ texte. Sans
|
||||
`auto_fit` : la taille du champ, lignes en trop retirées. Avec : taille
|
||||
réduite par pas de `_AUTO_FIT_STEP_PT` (plancher `_AUTO_FIT_MIN_PT`)
|
||||
jusqu'à ce que toutes les lignes repliées tiennent dans le cadre."""
|
||||
lines = wrap_text(text, font_name, font_size, width_mm * mm)
|
||||
if auto_fit:
|
||||
while (
|
||||
len(lines) > _lines_that_fit(font_size, height_mm)
|
||||
and font_size - _AUTO_FIT_STEP_PT >= _AUTO_FIT_MIN_PT
|
||||
):
|
||||
font_size -= _AUTO_FIT_STEP_PT
|
||||
lines = wrap_text(text, font_name, font_size, width_mm * mm)
|
||||
return font_size, lines[: _lines_that_fit(font_size, height_mm)]
|
||||
|
||||
|
||||
def _draw_text(
|
||||
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||||
) -> None:
|
||||
font_name = "Helvetica-Bold" if f.bold else "Helvetica"
|
||||
font_size, lines = fit_text(
|
||||
f.text, font_name, f.font_size, f.width_mm, f.height_mm, f.auto_fit
|
||||
)
|
||||
if not lines:
|
||||
return
|
||||
c.setFont(font_name, font_size)
|
||||
c.setFillColor(HexColor(f.color))
|
||||
left_x = x0_mm + f.x_mm
|
||||
# `font_size` est en points (unité reportlab) ; le reste du calcul
|
||||
# travaille en mm ⇒ on convertit d'abord (1 pt = 1/mm mm, `mm` étant
|
||||
# ici le facteur de conversion pt/mm de reportlab).
|
||||
font_size_mm = font_size / mm
|
||||
line_height_mm = font_size_mm * _LINE_HEIGHT_FACTOR
|
||||
# `f.y_mm` est depuis le HAUT du badge (repère écran) ; reportlab
|
||||
# dessine depuis le BAS de la page.
|
||||
top_y = y0_mm + badge_height_mm - f.y_mm
|
||||
if f.valign == "MIDDLE":
|
||||
# Hauteur du bloc : interlignes entre les lignes + une hauteur de
|
||||
# caractère pour la dernière.
|
||||
block = (len(lines) - 1) * line_height_mm + font_size_mm
|
||||
top_y -= max(0.0, (f.height_mm - block) / 2)
|
||||
for i, line in enumerate(lines):
|
||||
baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm
|
||||
if f.align == "CENTER":
|
||||
c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, line)
|
||||
elif f.align == "RIGHT":
|
||||
c.drawRightString((left_x + f.width_mm) * mm, baseline_y * mm, line)
|
||||
else:
|
||||
c.drawString(left_x * mm, baseline_y * mm, line)
|
||||
|
||||
|
||||
def _draw_fields(
|
||||
c: canvas.Canvas,
|
||||
fields: tuple[BadgeFieldSpec, ...],
|
||||
x0_mm: float,
|
||||
y0_mm: float,
|
||||
badge_height_mm: float,
|
||||
photos: PhotoCache,
|
||||
) -> None:
|
||||
for f in fields:
|
||||
if f.is_photo:
|
||||
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm, photos)
|
||||
continue
|
||||
_draw_zone_background(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||
if f.table is not None:
|
||||
_draw_table(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||
else:
|
||||
_draw_text(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||
|
||||
|
||||
def generate_badge_sheets_pdf(
|
||||
badges: list[BadgeSpec],
|
||||
front_image: bytes | None,
|
||||
back_image: bytes | None,
|
||||
badge_width_mm: float = DEFAULT_BADGE_WIDTH_MM,
|
||||
badge_height_mm: float = DEFAULT_BADGE_HEIGHT_MM,
|
||||
) -> bytes:
|
||||
"""`badges` : un par bénévole, dans l'ordre d'impression. Retourne les
|
||||
octets du PDF (recto puis verso, planche par planche)."""
|
||||
cols, rows = _grid_size(badge_width_mm, badge_height_mm)
|
||||
per_page = cols * rows
|
||||
pages = [badges[i : i + per_page] for i in range(0, len(badges), per_page)]
|
||||
|
||||
buffer = io.BytesIO()
|
||||
c = canvas.Canvas(buffer, pagesize=A4)
|
||||
photos: PhotoCache = {}
|
||||
for page_badges in pages:
|
||||
for i, badge in enumerate(page_badges):
|
||||
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
|
||||
_draw_background(c, front_image, x0, y0, badge_width_mm, badge_height_mm)
|
||||
_draw_fields(c, badge.fields, x0, y0, badge_height_mm, photos)
|
||||
c.showPage()
|
||||
|
||||
for i, badge in enumerate(page_badges):
|
||||
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm, mirrored=True)
|
||||
_draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm)
|
||||
_draw_fields(c, badge.back_fields, x0, y0, badge_height_mm, photos)
|
||||
c.showPage()
|
||||
c.save()
|
||||
return buffer.getvalue()
|
||||
@@ -49,6 +49,12 @@ class Settings(BaseSettings):
|
||||
# (ou un alias validé). En cas de rejet, mettre SMTP_FROM = SMTP_USER.
|
||||
smtp_from: str = "Festival des bénévoles <no-reply@example.test>"
|
||||
|
||||
# Formulaire public d'inscription (`app/api/forms.py`). Secret hCaptcha
|
||||
# seulement (la clé de site est publique, elle vit côté front-end en
|
||||
# VITE_HCAPTCHA_SITE_KEY, figée au build). None ⇒ vérification
|
||||
# désactivée (dev/tests uniquement, voir `app/forms/captcha.py`).
|
||||
hcaptcha_secret_key: str | None = None
|
||||
|
||||
log_level: str = "INFO"
|
||||
cors_origins: list[str] = ["http://localhost:5173"]
|
||||
|
||||
|
||||
@@ -4,7 +4,21 @@
|
||||
(sans destinataire ni corps — RGPD). Voir CLAUDE.md.
|
||||
"""
|
||||
|
||||
from app.email.branding import edition_logo
|
||||
from app.email.sender import send, send_many
|
||||
from app.email.templates import render_magic_link, render_planning
|
||||
from app.email.templates import (
|
||||
render_magic_link,
|
||||
render_planning,
|
||||
render_registration_confirmation,
|
||||
render_registration_paid_confirmation,
|
||||
)
|
||||
|
||||
__all__ = ["render_magic_link", "render_planning", "send", "send_many"]
|
||||
__all__ = [
|
||||
"edition_logo",
|
||||
"render_magic_link",
|
||||
"render_planning",
|
||||
"render_registration_confirmation",
|
||||
"render_registration_paid_confirmation",
|
||||
"send",
|
||||
"send_many",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Logo d'une édition pour l'en-tête des emails (voir `app/email/html.py`) —
|
||||
`None` tant qu'aucun logo n'a été importé sur le formulaire public
|
||||
(`FormTemplate.logo`, `app/api/forms.py:set_logo`).
|
||||
|
||||
Renvoie les octets (intégrés en pièce jointe « inline », voir
|
||||
`app/email/sender.py:build_message`), jamais une URL : `GET .../form/logo`
|
||||
exige désormais le `access_token` du formulaire public
|
||||
(`app/api/forms.py:_require_access_token`) — une URL externe dans un email
|
||||
aurait exposé ce jeton à quiconque lit la source du message, pas seulement
|
||||
la coordination."""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.models import FormTemplate
|
||||
|
||||
|
||||
def edition_logo(session: Session, edition_id: uuid.UUID) -> tuple[bytes, str] | None:
|
||||
template = session.exec(
|
||||
select(FormTemplate).where(FormTemplate.edition_id == edition_id)
|
||||
).first()
|
||||
if template is None or template.logo is None:
|
||||
return None
|
||||
return template.logo, template.logo_content_type or "image/png"
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Version HTML des emails — même thème que l'appli (couleurs/typographie de
|
||||
`frontend/tailwind.config.js`), logo de l'édition en en-tête si défini.
|
||||
|
||||
Chaque email reste envoyé en `multipart/alternative` (texte + HTML, voir
|
||||
`app/email/sender.py:build_message`) : le texte brut reste la source de
|
||||
vérité (accessibilité, clients sans HTML, RGPD — rien de plus dans le HTML
|
||||
que dans le texte déjà journalisable). Pas de CSS externe ni de police web
|
||||
(rarement chargées par les clients mail) : mise en page par tableau, styles
|
||||
en ligne, polices système en repli du style « Archivo »/« DM Mono »."""
|
||||
|
||||
import html as _html
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
_INK = "#14110E"
|
||||
_CREAM = "#F5EFE1"
|
||||
_PAPER = "#FFFDF7"
|
||||
_YELLOW = "#FFD23F"
|
||||
|
||||
_FONT_DISPLAY = "Arial, Helvetica, sans-serif"
|
||||
_FONT_MONO = "'Courier New', Courier, monospace"
|
||||
|
||||
# Identifiant de la pièce jointe « inline » du logo (voir
|
||||
# `app/email/sender.py:build_message`) — jamais une URL externe avec le
|
||||
# `access_token` du formulaire public en requête : n'importe quel
|
||||
# destinataire d'un email (lien magique, confirmation d'inscription…) aurait
|
||||
# pu lire ce jeton depuis la source du message, pas seulement la coordination.
|
||||
LOGO_CID = "edition-logo"
|
||||
|
||||
|
||||
_P_OPEN = '<p style="margin:0 0 14px;">'
|
||||
|
||||
|
||||
def _paragraphs(body: str, blocks: Mapping[str, str]) -> str:
|
||||
"""`\n\n` sépare les paragraphes, un simple `\n` reste un retour à la
|
||||
ligne — même convention que les modèles texte (`app/email/templates.py`).
|
||||
`blocks` : repère → HTML déjà construit (ex. tableau des créneaux),
|
||||
inséré tel quel à la place du repère, hors de tout paragraphe."""
|
||||
out = []
|
||||
for block in body.strip().split("\n\n"):
|
||||
if not block.strip():
|
||||
continue
|
||||
html = _P_OPEN + _html.escape(block).replace(chr(10), "<br>") + "</p>"
|
||||
for marker, fragment in blocks.items():
|
||||
html = html.replace(_html.escape(marker), f"</p>{fragment}{_P_OPEN}")
|
||||
# Paragraphes vidés par l'insertion d'un bloc (repère seul, ou
|
||||
# entouré de retours à la ligne).
|
||||
html = re.sub(rf"{re.escape(_P_OPEN)}(?:<br>|\s)*</p>", "", html)
|
||||
out.append(html)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
_CELL = f"padding:6px 8px;border:1px solid {_INK};vertical-align:top;"
|
||||
|
||||
|
||||
def render_planning_table(
|
||||
sections: Sequence[tuple[str, Sequence[tuple[str, str, str]]]],
|
||||
headers: tuple[str, str, str],
|
||||
) -> str:
|
||||
"""Tableau des créneaux d'un bénévole, comme sur les badges : le jour
|
||||
en en-tête sur toute la largeur, puis une ligne par créneau (horaire,
|
||||
poste, référent). `sections` = `[(jour, [(horaire, poste, référent)])]`."""
|
||||
head = "".join(
|
||||
f'<th align="left" style="{_CELL}background:{_INK};color:{_CREAM};'
|
||||
f'font-family:{_FONT_DISPLAY};font-size:11px;text-transform:uppercase;">'
|
||||
f"{_html.escape(h)}</th>"
|
||||
for h in headers
|
||||
)
|
||||
rows = [f"<tr>{head}</tr>"]
|
||||
for day, lines in sections:
|
||||
rows.append(
|
||||
f'<tr><td colspan="3" style="{_CELL}background:{_YELLOW};'
|
||||
f'font-family:{_FONT_DISPLAY};font-weight:800;">{_html.escape(day)}</td></tr>'
|
||||
)
|
||||
for time, position, referent in lines:
|
||||
rows.append(
|
||||
f'<tr><td style="{_CELL}white-space:nowrap;font-weight:700;">'
|
||||
f"{_html.escape(time)}</td>"
|
||||
f'<td style="{_CELL}font-family:{_FONT_DISPLAY};font-weight:700;">'
|
||||
f"{_html.escape(position)}</td>"
|
||||
f'<td style="{_CELL}">{_html.escape(referent).replace(chr(10), "<br>")}</td></tr>'
|
||||
)
|
||||
return (
|
||||
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
|
||||
f'style="border-collapse:collapse;margin:0 0 14px;font-size:12px;'
|
||||
f'font-family:{_FONT_MONO};background:{_PAPER};">' + "".join(rows) + "</table>"
|
||||
)
|
||||
|
||||
|
||||
def render_html(
|
||||
*, subject: str, body: str, has_logo: bool, blocks: Mapping[str, str] | None = None
|
||||
) -> str:
|
||||
"""`has_logo` : voir `app/email/branding.py:edition_logo` — `False` tant
|
||||
que l'édition n'a pas importé de logo, l'en-tête retombe alors sur un
|
||||
simple bandeau texte (le sujet, en capitales). `blocks` : voir
|
||||
`_paragraphs`."""
|
||||
if has_logo:
|
||||
header = (
|
||||
f'<img src="cid:{LOGO_CID}" alt="" width="160" '
|
||||
'style="max-height:64px;width:auto;display:block;margin:0 auto;'
|
||||
'border:0;">'
|
||||
)
|
||||
else:
|
||||
header = (
|
||||
f'<span style="font-family:{_FONT_DISPLAY};font-weight:800;'
|
||||
f"text-transform:uppercase;letter-spacing:1px;color:{_YELLOW};"
|
||||
f'font-size:14px;">{_html.escape(subject)}</span>'
|
||||
)
|
||||
return f"""<!doctype html>
|
||||
<html lang="fr">
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="margin:0;padding:24px 16px;background:{_CREAM};">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="520" cellpadding="0" cellspacing="0"
|
||||
style="max-width:520px;width:100%;">
|
||||
<tr>
|
||||
<td style="background:{_INK};padding:18px;text-align:center;
|
||||
border:3px solid {_INK};">
|
||||
{header}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background:{_PAPER};border:3px solid {_INK};
|
||||
border-top:none;padding:22px;font-size:13px;
|
||||
line-height:1.6;color:{_INK};font-family:{_FONT_MONO};">
|
||||
{_paragraphs(body, blocks or {})}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
+109
-14
@@ -8,10 +8,11 @@ Pour diagnostiquer : `python -m app.email.check <destinataire>`."""
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from email.message import EmailMessage
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.email.html import LOGO_CID, render_html
|
||||
|
||||
logger = logging.getLogger("app.email")
|
||||
|
||||
@@ -32,12 +33,59 @@ def _smtp_error_summary(exc: smtplib.SMTPException) -> str:
|
||||
return exc.__class__.__name__
|
||||
|
||||
|
||||
def build_message(to: str, subject: str, body: str) -> EmailMessage:
|
||||
# (octets, content-type, nom de fichier) d'une pièce jointe.
|
||||
Attachment = tuple[bytes, str, str]
|
||||
|
||||
|
||||
def build_message(
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
*,
|
||||
logo: tuple[bytes, str] | None = None,
|
||||
attachments: Sequence[Attachment] = (),
|
||||
html_body: str | None = None,
|
||||
html_blocks: Mapping[str, str] | None = None,
|
||||
) -> EmailMessage:
|
||||
"""`html_body` / `html_blocks` : corps de la version HTML quand il
|
||||
diffère du texte — repères remplacés par des blocs HTML déjà construits
|
||||
(ex. tableau des créneaux, `app/email/html.py:render_planning_table`) ;
|
||||
le texte brut garde sa propre version lisible.
|
||||
|
||||
`logo` = (octets, content-type) (voir `app/email/branding.py:
|
||||
edition_logo`) : ajoute une version HTML au thème de l'appli
|
||||
(`app/email/html.py`) en alternative au texte brut — le texte reste la
|
||||
version de référence, envoyée telle quelle si aucun client ne préfère le
|
||||
HTML. Le logo, s'il y en a un, est intégré en pièce jointe « inline »
|
||||
(Content-ID) référencée par la partie HTML — jamais une URL externe, qui
|
||||
aurait exposé le jeton d'accès du formulaire public à quiconque lit la
|
||||
source du message (voir `app/email/branding.py`). `attachments` : vraies
|
||||
pièces jointes (ex. reçu fiscal mécénat), ajoutées après le logo — le
|
||||
message devient alors `multipart/mixed`."""
|
||||
message = EmailMessage()
|
||||
message["From"] = get_settings().smtp_from
|
||||
message["To"] = to
|
||||
message["Subject"] = subject
|
||||
message.set_content(body)
|
||||
message.add_alternative(
|
||||
render_html(
|
||||
subject=subject,
|
||||
body=html_body if html_body is not None else body,
|
||||
has_logo=logo is not None,
|
||||
blocks=html_blocks,
|
||||
),
|
||||
subtype="html",
|
||||
)
|
||||
if logo is not None:
|
||||
content, content_type = logo
|
||||
maintype, _, subtype = content_type.partition("/")
|
||||
html_part = message.get_payload()[-1]
|
||||
html_part.add_related(
|
||||
content, maintype or "image", subtype or "png", cid=f"<{LOGO_CID}>"
|
||||
)
|
||||
for content, content_type, filename in attachments:
|
||||
maintype, _, subtype = content_type.partition("/")
|
||||
message.add_attachment(content, maintype=maintype, subtype=subtype, filename=filename)
|
||||
return message
|
||||
|
||||
|
||||
@@ -102,19 +150,66 @@ def deliver_many(messages: list[EmailMessage]) -> None:
|
||||
message["From"],
|
||||
_smtp_error_summary(exc),
|
||||
)
|
||||
# RGPD : ni destinataire ni corps, seulement le compte et le sujet.
|
||||
logger.info(
|
||||
"Emails remis : %d/%d — sujet=%r",
|
||||
sent,
|
||||
len(messages),
|
||||
messages[0]["Subject"],
|
||||
)
|
||||
# RGPD : ni destinataire, ni corps, ni sujet — un sujet de récapitulatif
|
||||
# de planning (`app/api/notifications.py`) peut être personnalisé par la
|
||||
# coordination avec le nom du bénévole (`{prenom}`/`{nom}`), ce ne serait
|
||||
# alors plus un simple texte d'interface.
|
||||
logger.info("Emails remis : %d/%d", sent, len(messages))
|
||||
|
||||
|
||||
def send(to: str, subject: str, body: str) -> None:
|
||||
deliver_many([build_message(to, subject, body)])
|
||||
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_many(items: Iterable[tuple[str, str, str]]) -> None:
|
||||
"""`items` = itérable de `(destinataire, sujet, corps)`."""
|
||||
deliver_many([build_message(*item) for item in items])
|
||||
def send(
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
*,
|
||||
logo: tuple[bytes, str] | None = None,
|
||||
attachments: Sequence[Attachment] = (),
|
||||
) -> None:
|
||||
deliver_many([build_message(to, subject, body, logo=logo, attachments=attachments)])
|
||||
|
||||
|
||||
def send_messages(messages: list[EmailMessage]) -> None:
|
||||
"""Remet des messages déjà construits (`build_message`) — ex. le
|
||||
récapitulatif de planning, dont la version HTML porte un tableau."""
|
||||
deliver_many(messages)
|
||||
|
||||
|
||||
def send_many(
|
||||
items: Iterable[tuple[str, str, str]], *, logo: tuple[bytes, str] | None = None
|
||||
) -> None:
|
||||
"""`items` = itérable de `(destinataire, sujet, corps)` — même logo pour
|
||||
tous (un seul appel par édition, voir `app/api/notifications.py`)."""
|
||||
deliver_many([build_message(*item, logo=logo) for item in items])
|
||||
|
||||
@@ -21,6 +21,36 @@ def render_magic_link(*, link: str, minutes: int) -> tuple[str, str]:
|
||||
)
|
||||
|
||||
|
||||
def render_registration_confirmation(*, first_name: str) -> tuple[str, str]:
|
||||
"""Inscription prise en compte, sans paiement en ligne (case désactivée,
|
||||
ou « je paierai sur place ») — voir `render_registration_paid_confirmation`
|
||||
pour le cas d'un paiement confirmé."""
|
||||
return (
|
||||
t("email.registration_confirmation.subject"),
|
||||
t("email.registration_confirmation.body", prenom=first_name),
|
||||
)
|
||||
|
||||
|
||||
def render_registration_paid_confirmation(
|
||||
*, first_name: str, label: str, amount: float, with_mecenat_receipt: bool = False
|
||||
) -> tuple[str, str]:
|
||||
"""Inscription prise en compte **et** paiement en ligne confirmé — envoyé
|
||||
une fois seulement, au moment de la promotion (`app/api/forms.py:
|
||||
_promote_pending`). Sert de reçu (montant, type de paiement) en
|
||||
l'absence de confirmation documentée que l'API SumUp Hosted Checkout en
|
||||
envoie un elle-même. `with_mecenat_receipt` : le cerfa rempli est joint
|
||||
(`_promote_pending`), le corps l'annonce."""
|
||||
body = t(
|
||||
"email.registration_confirmation_paid.body",
|
||||
prenom=first_name,
|
||||
label=label,
|
||||
montant=amount,
|
||||
)
|
||||
if with_mecenat_receipt:
|
||||
body += "\n\n" + t("email.registration_confirmation_paid.mecenat_receipt_note")
|
||||
return t("email.registration_confirmation_paid.subject"), body
|
||||
|
||||
|
||||
def _fill(template: str, values: dict[str, object]) -> str:
|
||||
out = template
|
||||
for key in _PLANNING_PLACEHOLDERS:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.exporting.xlsx import generate_volunteers_xlsx
|
||||
|
||||
__all__ = ["generate_volunteers_xlsx"]
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Export Excel de la liste des bénévoles d'une édition — voir
|
||||
`app/api/volunteers.py:export_volunteers`. Même bibliothèque que l'import
|
||||
(`openpyxl`, `app/importing/parser.py`), en écriture cette fois.
|
||||
|
||||
Texte en dur (en-têtes de colonnes, libellés) : comme `app/sheets/pdf.py` et
|
||||
`app/badges/pdf.py`, un document généré n'est pas un texte d'interface —
|
||||
pas de `app.i18n.t()` ici, contrairement aux réponses de l'API."""
|
||||
|
||||
import io
|
||||
import uuid
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font
|
||||
|
||||
from app.api.schemas import VolunteerRead
|
||||
|
||||
_HEADER = [
|
||||
"Prénom",
|
||||
"Nom",
|
||||
"Email",
|
||||
"Téléphone",
|
||||
"Disponible",
|
||||
"Postes souhaités",
|
||||
"Artistes souhaités",
|
||||
"Compagnons souhaités",
|
||||
"Note de compagnon",
|
||||
"Mode de paiement",
|
||||
"Cotisation choisie",
|
||||
"Montant (€)",
|
||||
"Statut du paiement",
|
||||
"Payée le",
|
||||
"Transaction SumUp (code)",
|
||||
"Transaction SumUp (identifiant)",
|
||||
"Mécénat — adresse",
|
||||
"Mécénat — ville",
|
||||
"Mécénat — code postal",
|
||||
"Mécénat — pays",
|
||||
"Commentaire",
|
||||
"Texte du badge",
|
||||
"Nombre de badges",
|
||||
"Inscrit le",
|
||||
]
|
||||
|
||||
_FEE_STATUS_LABELS = {
|
||||
"PENDING": "En attente",
|
||||
"PAID": "Payée",
|
||||
"FAILED": "Échouée",
|
||||
"EXPIRED": "Expirée",
|
||||
}
|
||||
|
||||
# Caractères qui font d'une cellule Excel/LibreOffice une formule plutôt
|
||||
# qu'un texte littéral — injection de formule (CWE-1236) : la plupart de ces
|
||||
# champs viennent du formulaire public, sans authentification (nom, email,
|
||||
# téléphone, commentaire, note de compagnon, adresse de mécénat…). Un
|
||||
# préfixe `'` force l'interprétation en texte, sans changer la valeur
|
||||
# affichée.
|
||||
_FORMULA_PREFIXES = ("=", "+", "-", "@")
|
||||
|
||||
|
||||
def _safe_text(value: str) -> str:
|
||||
return f"'{value}" if value.startswith(_FORMULA_PREFIXES) else value
|
||||
|
||||
|
||||
def _payment_mode(v: VolunteerRead) -> str:
|
||||
"""En ligne (un checkout SumUp a été créé), sur place (« je participe à
|
||||
la cotisation » sans paiement en ligne), ou rien — même règle que
|
||||
`frontend/src/lib/fee.ts:feeMode`."""
|
||||
if v.fee_payment_status is not None:
|
||||
return "En ligne"
|
||||
if v.willing_to_pay_fee:
|
||||
return "Sur place"
|
||||
return ""
|
||||
|
||||
|
||||
def _yes_no(value: bool) -> str:
|
||||
return "Oui" if value else "Non"
|
||||
|
||||
|
||||
def _join_names(ids: list[uuid.UUID], names: dict[uuid.UUID, str]) -> str:
|
||||
return _safe_text(", ".join(names[i] for i in ids if i in names))
|
||||
|
||||
|
||||
def _fmt_dt(value) -> str:
|
||||
return value.strftime("%d/%m/%Y %H:%M") if value else ""
|
||||
|
||||
|
||||
def generate_volunteers_xlsx(
|
||||
volunteers: list[VolunteerRead],
|
||||
*,
|
||||
position_names: dict[uuid.UUID, str],
|
||||
artist_names: dict[uuid.UUID, str],
|
||||
volunteer_names: dict[uuid.UUID, str],
|
||||
) -> bytes:
|
||||
"""`position_names`/`artist_names`/`volunteer_names` : cartes id → nom
|
||||
construites une fois par l'appelant (pas de requête par bénévole) —
|
||||
voir `app/api/volunteers.py:export_volunteers`."""
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "Bénévoles"
|
||||
sheet.append(_HEADER)
|
||||
for cell in sheet[1]:
|
||||
cell.font = Font(bold=True)
|
||||
|
||||
for v in volunteers:
|
||||
sheet.append(
|
||||
[
|
||||
_safe_text(v.first_name),
|
||||
_safe_text(v.last_name),
|
||||
_safe_text(v.email or ""),
|
||||
_safe_text(v.phone or ""),
|
||||
_yes_no(v.self_declared_available),
|
||||
_join_names(v.wished_position_ids, position_names),
|
||||
_join_names(v.artist_ids, artist_names),
|
||||
_join_names(v.wished_companion_ids, volunteer_names),
|
||||
_safe_text(v.companion_wish_note or ""),
|
||||
_payment_mode(v),
|
||||
_safe_text(v.fee_payment_label or ""),
|
||||
v.fee_amount if v.fee_amount is not None else "",
|
||||
_FEE_STATUS_LABELS.get(v.fee_payment_status, ""),
|
||||
_fmt_dt(v.fee_paid_at),
|
||||
_safe_text(v.fee_transaction_code or ""),
|
||||
_safe_text(v.fee_transaction_id or ""),
|
||||
_safe_text(v.mecenat_address or ""),
|
||||
_safe_text(v.mecenat_city or ""),
|
||||
_safe_text(v.mecenat_postal_code or ""),
|
||||
_safe_text(v.mecenat_country or ""),
|
||||
_safe_text(v.comment or ""),
|
||||
_safe_text(v.badge_text or ""),
|
||||
v.badge_count,
|
||||
_fmt_dt(v.created_at),
|
||||
]
|
||||
)
|
||||
|
||||
for column_cells in sheet.columns:
|
||||
length = max(
|
||||
(len(str(c.value)) for c in column_cells if c.value is not None),
|
||||
default=8,
|
||||
)
|
||||
sheet.column_dimensions[column_cells[0].column_letter].width = min(
|
||||
max(length + 2, 10), 40
|
||||
)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.forms.captcha import verify_hcaptcha
|
||||
|
||||
__all__ = ["verify_hcaptcha"]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Vérification hCaptcha du formulaire public d'inscription.
|
||||
|
||||
Point d'injection unique : `_verify_with_hcaptcha` (remplacé dans les tests,
|
||||
même principe que `app.email.sender.deliver_many`). Sans `HCAPTCHA_SECRET_KEY`
|
||||
configurée (dev/tests), la vérification est désactivée — voir
|
||||
`app/config.py:Settings.hcaptcha_secret_key`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger("app.forms")
|
||||
|
||||
_VERIFY_URL = "https://hcaptcha.com/siteverify"
|
||||
_TIMEOUT_SECONDS = 8.0
|
||||
|
||||
|
||||
def _verify_with_hcaptcha(token: str, secret: str) -> bool:
|
||||
"""Appel réel à l'API hCaptcha. Toute erreur réseau/serveur est traitée
|
||||
comme un échec de vérification (on ne laisse jamais passer par défaut)."""
|
||||
try:
|
||||
resp = httpx.post(
|
||||
_VERIFY_URL,
|
||||
data={"secret": secret, "response": token},
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return bool(resp.json().get("success"))
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("Échec de la vérification hCaptcha : %s", exc.__class__.__name__)
|
||||
return False
|
||||
|
||||
|
||||
def verify_hcaptcha(token: str | None) -> bool:
|
||||
"""`False` si `token` est vide, ou si la vérification hCaptcha échoue.
|
||||
Toujours `True` quand `HCAPTCHA_SECRET_KEY` n'est pas configurée (dev,
|
||||
tests) — voir l'avertissement au démarrage dans `app/main.py`."""
|
||||
secret = get_settings().hcaptcha_secret_key
|
||||
if not secret:
|
||||
return True
|
||||
if not token:
|
||||
return False
|
||||
return _verify_with_hcaptcha(token, secret)
|
||||
@@ -43,16 +43,73 @@
|
||||
"import.row.updated_existing": "Bénévole existant mis à jour.",
|
||||
"import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l'édition.",
|
||||
"validation.SHIFT_OVERLAP": "Chevauchement avec un autre créneau du bénévole.",
|
||||
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées le {date} ({uncovered_minutes} min non couvertes).",
|
||||
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h d'affectation cumulées le {date}.",
|
||||
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux.",
|
||||
"notification.no_recipients": "Aucun bénévole ciblé.",
|
||||
"email.magic_link.subject": "Ton lien de connexion",
|
||||
"email.magic_link.body": "Bonjour,\n\nVoici ton lien de connexion, valable {minutes} minutes :\n\n{link}\n\nSi tu n'es pas à l'origine de cette demande, ignore simplement cet email.",
|
||||
"email.registration_confirmation.subject": "Ton inscription bénévole est confirmée",
|
||||
"email.registration_confirmation.body": "Bonjour {prenom},\n\nTon inscription comme bénévole a bien été enregistrée. La coordination du festival prendra contact avec toi si besoin.\n\nÀ bientôt !",
|
||||
"email.registration_confirmation_paid.subject": "Ton inscription bénévole est confirmée — paiement reçu",
|
||||
"email.registration_confirmation_paid.body": "Bonjour {prenom},\n\nTon inscription comme bénévole a bien été enregistrée, et nous confirmons la réception de ton paiement :\n\n{label} — {montant} €\n\nLa coordination du festival prendra contact avec toi si besoin.\n\nÀ bientôt !",
|
||||
"email.registration_confirmation_paid.mecenat_receipt_note": "Tu trouveras en pièce jointe ton reçu fiscal au titre des dons (cerfa n° 11580*05), à conserver pour ta déclaration de revenus.",
|
||||
"email.planning.default_subject": "Ton planning bénévole",
|
||||
"email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !",
|
||||
"email.test.subject_prefix": "[Test] {subject}",
|
||||
"email.test.example_first_name": "Camille",
|
||||
"email.test.example_last_name": "Exemple",
|
||||
"notifications.invalid_email": "Adresse email invalide.",
|
||||
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
|
||||
"email.planning.shift_line": "- {position} : {start} → {end}",
|
||||
"availability.date_outside_edition": "La date {date} est hors des dates de l'édition.",
|
||||
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes."
|
||||
"email.planning.table.time": "Horaire",
|
||||
"email.planning.table.position": "Poste",
|
||||
"email.planning.table.referent": "Référent",
|
||||
"email.planning.table.no_referent": "à désigner",
|
||||
"email.planning.table.text_line": " {time} · {position} · référent : {referent}",
|
||||
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
|
||||
"badge.image_unreadable": "L'image est illisible ou corrompue.",
|
||||
"badge.empty_image": "L'image est vide.",
|
||||
"badge.image_too_large": "L'image est trop volumineuse.",
|
||||
"badge.duplicate_field_type": "Le champ « {field_type} » ne peut apparaître qu'une fois.",
|
||||
"badge.invalid_size": "La taille du badge doit être strictement positive.",
|
||||
"badge.invalid_color": "La couleur « {color} » n'est pas valide (format attendu : #rrggbb).",
|
||||
"badge.no_fields": "Aucun champ n'est configuré sur le badge.",
|
||||
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
|
||||
"badge.role.referent": "Référent",
|
||||
"badge.role.volunteer": "Bénévole",
|
||||
"position_sheets.footer_title": "Message de l'organisation",
|
||||
"badge.table.day_label": "{weekday} {day}",
|
||||
"badge.weekday.0": "Lundi",
|
||||
"badge.weekday.1": "Mardi",
|
||||
"badge.weekday.2": "Mercredi",
|
||||
"badge.weekday.3": "Jeudi",
|
||||
"badge.weekday.4": "Vendredi",
|
||||
"badge.weekday.5": "Samedi",
|
||||
"badge.weekday.6": "Dimanche",
|
||||
"position.category.bar": "Bar",
|
||||
"position.category.ticketing": "Billetterie",
|
||||
"position.category.welcome": "Accueil",
|
||||
"position.category.parking": "Parking",
|
||||
"position.category.cleaning": "Propreté",
|
||||
"position.category.catering": "Catering",
|
||||
"position.category.setup": "Montage",
|
||||
"position.category.stage": "Régie",
|
||||
"position.category.other": "Autre",
|
||||
"form.closed": "Les inscriptions sont actuellement fermées.",
|
||||
"form.captcha_failed": "Vérification anti-robot invalide, réessayez.",
|
||||
"form.invalid_payload": "Données du formulaire invalides.",
|
||||
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
|
||||
"volunteer.empty_photo": "Le fichier de la photo est vide.",
|
||||
"form.photo_too_large": "La photo est trop volumineuse.",
|
||||
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
|
||||
"form.logo_empty": "Le logo est vide.",
|
||||
"form.logo_too_large": "Le logo est trop volumineux.",
|
||||
"form.invalid_sumup_amount": "Le montant doit être supérieur à 0.",
|
||||
"form.invalid_free_amount": "Le montant doit être compris entre {min} € et {max} €, avec au plus deux décimales.",
|
||||
"form.unknown_payment_option": "Ce type de paiement n'existe pas pour cette édition.",
|
||||
"form.duplicate_payment_option_label": "Un type de paiement porte déjà ce libellé.",
|
||||
"form.pending_already_paid": "Cette inscription est déjà payée.",
|
||||
"form.pending_not_awaiting_info": "Cette inscription n'attend pas ces informations.",
|
||||
"form.too_many_position_wishes": "Deux vœux de poste au maximum.",
|
||||
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.",
|
||||
"position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.",
|
||||
"position_sheets.no_shifts": "Aucun créneau à imprimer pour ces postes.",
|
||||
"mecenat.no_receipts": "Aucun bénévole n'a payé la cotisation en mécénat pour cette édition."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+20
-2
@@ -7,19 +7,23 @@ from app.api.accounts import router as account_router
|
||||
from app.api.artists import router as artist_router
|
||||
from app.api.assignments import router as assignment_router
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.availability import router as availability_router
|
||||
from app.api.badges import router as badge_router
|
||||
from app.api.day_parts import router as day_part_router
|
||||
from app.api.editions import router as edition_router
|
||||
from app.api.forms import router as form_router
|
||||
from app.api.health import router as health_router
|
||||
from app.api.imports import router as import_router
|
||||
from app.api.matrix import router as matrix_router
|
||||
from app.api.notifications import router as notification_router
|
||||
from app.api.position_sheets import router as position_sheets_router
|
||||
from app.api.positions import router as position_router
|
||||
from app.api.shifts import router as shift_router
|
||||
from app.api.solver import router as solver_router
|
||||
from app.api.stats import router as stats_router
|
||||
from app.api.volunteers import router as volunteer_router
|
||||
from app.config import get_settings
|
||||
from app.logging_config import configure_logging
|
||||
from app.request_logging import REQUEST_ID_HEADER, install_request_logging
|
||||
|
||||
configure_logging()
|
||||
|
||||
@@ -36,6 +40,15 @@ if settings.is_production and not settings.email_enabled:
|
||||
"le SMTP avant l'ouverture au public."
|
||||
)
|
||||
|
||||
if settings.is_production and not settings.hcaptcha_secret_key:
|
||||
# Le formulaire public (`app/api/forms.py`) reste accessible sans
|
||||
# HCAPTCHA_SECRET_KEY, mais sans aucune protection anti-robot au-delà du
|
||||
# filet de sécurité global par édition (`form_submission_limiter`).
|
||||
logging.getLogger("app.main").warning(
|
||||
"HCAPTCHA_SECRET_KEY absente en production : le formulaire public "
|
||||
"d'inscription n'est pas protégé par captcha."
|
||||
)
|
||||
|
||||
app = FastAPI(title="Gestion des bénévoles", version="0.1.0")
|
||||
|
||||
app.add_middleware(
|
||||
@@ -44,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)
|
||||
@@ -54,10 +69,13 @@ app.include_router(account_router)
|
||||
app.include_router(import_router)
|
||||
app.include_router(artist_router)
|
||||
app.include_router(volunteer_router)
|
||||
app.include_router(availability_router)
|
||||
app.include_router(position_router)
|
||||
app.include_router(shift_router)
|
||||
app.include_router(assignment_router)
|
||||
app.include_router(solver_router)
|
||||
app.include_router(matrix_router)
|
||||
app.include_router(stats_router)
|
||||
app.include_router(notification_router)
|
||||
app.include_router(badge_router)
|
||||
app.include_router(form_router)
|
||||
app.include_router(position_sheets_router)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from app.mecenat.pdf import MecenatReceipt, generate_mecenat_receipts_pdf
|
||||
from app.mecenat.receipts import RECEIPT_FILENAME, assign_receipt_number, receipt_for
|
||||
|
||||
__all__ = [
|
||||
"RECEIPT_FILENAME",
|
||||
"MecenatReceipt",
|
||||
"assign_receipt_number",
|
||||
"generate_mecenat_receipts_pdf",
|
||||
"receipt_for",
|
||||
]
|
||||
Binary file not shown.
@@ -0,0 +1,84 @@
|
||||
"""Montant en toutes lettres, en français — remplit l'encadré « Somme en
|
||||
toutes lettres » du cerfa (voir `pdf.py`). Fonction pure, testée séparément
|
||||
de la génération PDF : l'orthographe des nombres en français a suffisamment
|
||||
d'exceptions (accord de "quatre-vingts"/"cents", "et un" irrégulier à 71)
|
||||
pour mériter ses propres cas de test.
|
||||
"""
|
||||
|
||||
_ONES = [
|
||||
"zéro", "un", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf",
|
||||
"dix", "onze", "douze", "treize", "quatorze", "quinze", "seize",
|
||||
"dix-sept", "dix-huit", "dix-neuf",
|
||||
]
|
||||
_TENS_PREFIX = {2: "vingt", 3: "trente", 4: "quarante", 5: "cinquante", 6: "soixante"}
|
||||
|
||||
|
||||
def _under_hundred(n: int) -> str:
|
||||
"""0 <= n <= 99."""
|
||||
if n < 20:
|
||||
return _ONES[n]
|
||||
if n < 70:
|
||||
tens, unit = divmod(n, 10)
|
||||
prefix = _TENS_PREFIX[tens]
|
||||
if unit == 0:
|
||||
return prefix
|
||||
if unit == 1:
|
||||
return f"{prefix} et un"
|
||||
return f"{prefix}-{_ONES[unit]}"
|
||||
if n < 80:
|
||||
# 70-79 : soixante + dix..dix-neuf, « et onze » irrégulier à 71 (le
|
||||
# seul cas des années 70 avec « et » — 72..79 n'en prennent pas).
|
||||
rem = n - 60
|
||||
if rem == 11:
|
||||
return "soixante et onze"
|
||||
return f"soixante-{_ONES[rem]}"
|
||||
# 80-99 : quatre-vingt(s) + ... — jamais de « et », y compris à 91.
|
||||
rem = n - 80
|
||||
if rem == 0:
|
||||
return "quatre-vingts"
|
||||
return f"quatre-vingt-{_ONES[rem]}"
|
||||
|
||||
|
||||
def _under_thousand(n: int) -> str:
|
||||
"""0 <= n <= 999."""
|
||||
if n < 100:
|
||||
return _under_hundred(n)
|
||||
hundreds, rem = divmod(n, 100)
|
||||
prefix = "cent" if hundreds == 1 else f"{_ONES[hundreds]} cent"
|
||||
if rem == 0:
|
||||
# « cents » ne prend un accord au pluriel que sur un multiple exact
|
||||
# (deux cents, mais deux cent un).
|
||||
return f"{prefix}s" if hundreds > 1 else prefix
|
||||
return f"{prefix} {_under_hundred(rem)}"
|
||||
|
||||
|
||||
def _number_to_words(n: int) -> str:
|
||||
"""0 <= n <= 999 999 999 — largement suffisant pour une cotisation ou un
|
||||
don, jamais invariable au-delà (« mille » ne prend jamais de « s »)."""
|
||||
if n == 0:
|
||||
return "zéro"
|
||||
parts = []
|
||||
millions, n = divmod(n, 1_000_000)
|
||||
if millions:
|
||||
parts.append("un million" if millions == 1 else f"{_under_thousand(millions)} millions")
|
||||
thousands, n = divmod(n, 1000)
|
||||
if thousands:
|
||||
parts.append("mille" if thousands == 1 else f"{_under_thousand(thousands)} mille")
|
||||
if n:
|
||||
parts.append(_under_thousand(n))
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def amount_in_words(amount: float) -> str:
|
||||
"""« Cent cinquante euros », « cinquante centimes », « un euro et dix
|
||||
centimes »… — texte de l'encadré « Somme en toutes lettres » du cerfa, à
|
||||
partir d'un montant en euros (`Volunteer.fee_amount`)."""
|
||||
cents_total = round(amount * 100)
|
||||
euros, cents = divmod(cents_total, 100)
|
||||
parts = []
|
||||
if euros or not cents:
|
||||
parts.append(f"{_number_to_words(euros)} {'euro' if euros <= 1 else 'euros'}")
|
||||
if cents:
|
||||
parts.append(f"{_number_to_words(cents)} {'centime' if cents <= 1 else 'centimes'}")
|
||||
text = " et ".join(parts)
|
||||
return text[:1].upper() + text[1:]
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Génération des reçus fiscaux (cerfa 11580*05) pour les dons « mécénat »
|
||||
de la cotisation — voir `app/api/volunteers.py:generate_mecenat_receipts`.
|
||||
|
||||
Module isolé, fonction pure (`generate_mecenat_receipts_pdf`) : aucun accès
|
||||
base ici. Le cerfa officiel (`cerfa-mecenat-particuliers.pdf`, à côté de ce
|
||||
fichier) n'est pas un formulaire PDF interactif (aucun champ AcroForm) — les
|
||||
informations du donateur, du montant et de la date sont donc dessinées
|
||||
par-dessus la page 2 du modèle, aux coordonnées mesurées sur ce document
|
||||
précis (mise en page fixe d'un cerfa officiel, jamais destinée à changer).
|
||||
Les informations de l'organisme bénéficiaire (page 1), les cases articles
|
||||
200/978 et la signature (page 2) sont pré-remplies dans le modèle lui-même
|
||||
par l'association — rien à dessiner ici.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from pypdf import PageObject, PdfReader, PdfWriter
|
||||
from reportlab.lib.colors import black
|
||||
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
from app.mecenat.number_words import amount_in_words
|
||||
|
||||
_TEMPLATE_PATH = Path(__file__).parent / "cerfa-mecenat-particuliers.pdf"
|
||||
|
||||
_PAGE_W = 595.0
|
||||
_PAGE_H = 842.0
|
||||
|
||||
# `bottom` : ligne de base du champ imprimé (pointillés) sur lequel écrire,
|
||||
# en points depuis le HAUT de la page (repère pdfplumber, utilisé pour
|
||||
# mesurer ces coordonnées) ; `_NUDGE` replace le texte juste au-dessus de
|
||||
# cette ligne plutôt que dessus, une fois converti en repère PDF (origine en
|
||||
# bas — voir `_draw`).
|
||||
_NUDGE = 1.5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Field:
|
||||
x: float
|
||||
bottom: float
|
||||
max_width: float
|
||||
font_size: float
|
||||
bold: bool = False
|
||||
|
||||
|
||||
_LAST_NAME = _Field(x=66, bottom=250.5, max_width=230, font_size=9)
|
||||
_FIRST_NAME = _Field(x=352, bottom=250.5, max_width=190, font_size=9)
|
||||
_ADDRESS = _Field(x=136, bottom=281.2, max_width=405, font_size=9)
|
||||
_POSTAL_CODE = _Field(x=99, bottom=295.5, max_width=33, font_size=8)
|
||||
_CITY = _Field(x=232, bottom=295.5, max_width=310, font_size=9)
|
||||
_COUNTRY = _Field(x=67, bottom=310.5, max_width=125, font_size=9)
|
||||
_AMOUNT = _Field(x=44, bottom=365.2, max_width=110, font_size=9)
|
||||
_AMOUNT_IN_WORDS = _Field(x=356, bottom=363.1, max_width=190, font_size=8)
|
||||
_PAYMENT_DATE = _Field(x=264, bottom=385, max_width=76, font_size=9)
|
||||
# Page 1 : case « Numéro d'ordre du reçu » (x 418,7 → 566,8, haut 111 → bas
|
||||
# 128,2), texte centré dedans (`x` = centre de la case).
|
||||
_RECEIPT_NUMBER = _Field(x=492.75, bottom=124.0, max_width=140, font_size=11, bold=True)
|
||||
_CERFA_DATE = _Field(x=320, bottom=630, max_width=76, font_size=9)
|
||||
_CHECK_VIRMENT = _Field(x=291.8, bottom=592.1, max_width=10, font_size=8)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MecenatReceipt:
|
||||
"""Un reçu = un bénévole ayant payé un type de paiement « mécénat » —
|
||||
voir `Volunteer.mecenat_*`/`fee_*` (CLAUDE.md § 2, Paiement de la
|
||||
cotisation)."""
|
||||
|
||||
last_name: str
|
||||
first_name: str
|
||||
address: str
|
||||
postal_code: str
|
||||
city: str
|
||||
country: str
|
||||
amount: float
|
||||
payment_date: dt.date
|
||||
number: str = ""
|
||||
|
||||
|
||||
def _fitted_font_size(text: str, max_width: float, font_size: float, bold: bool) -> float:
|
||||
"""Réduit la taille de police jusqu'à ce que `text` tienne dans
|
||||
`max_width` (plancher 6 pt, en dessous le texte reste simplement
|
||||
tronqué par le tracé plutôt que rendu illisible)."""
|
||||
font = "Helvetica-Bold" if bold else "Helvetica"
|
||||
size = font_size
|
||||
while size > 6 and stringWidth(text, font, size) > max_width:
|
||||
size -= 0.5
|
||||
return size
|
||||
|
||||
|
||||
def _draw(c: canvas.Canvas, text: str, field: _Field) -> None:
|
||||
if not text:
|
||||
return
|
||||
font_size = _fitted_font_size(text, field.max_width, field.font_size, field.bold)
|
||||
c.setFont("Helvetica-Bold" if field.bold else "Helvetica", font_size)
|
||||
c.setFillColor(black)
|
||||
c.drawString(field.x, _PAGE_H - field.bottom + _NUDGE, text)
|
||||
|
||||
|
||||
def _draw_centered(c: canvas.Canvas, text: str, field: _Field) -> None:
|
||||
"""Comme `_draw`, mais `field.x` est le centre de la zone (case encadrée,
|
||||
pas une ligne de pointillés : pas de `_NUDGE`)."""
|
||||
if not text:
|
||||
return
|
||||
font_size = _fitted_font_size(text, field.max_width, field.font_size, field.bold)
|
||||
c.setFont("Helvetica-Bold" if field.bold else "Helvetica", font_size)
|
||||
c.setFillColor(black)
|
||||
c.drawCentredString(field.x, _PAGE_H - field.bottom, text)
|
||||
|
||||
|
||||
def _finish(c: canvas.Canvas, buffer: io.BytesIO) -> PageObject:
|
||||
c.showPage()
|
||||
c.save()
|
||||
buffer.seek(0)
|
||||
return PdfReader(buffer).pages[0]
|
||||
|
||||
|
||||
def _organisation_overlay(receipt: MecenatReceipt) -> PageObject:
|
||||
buffer = io.BytesIO()
|
||||
c = canvas.Canvas(buffer, pagesize=(_PAGE_W, _PAGE_H))
|
||||
_draw_centered(c, receipt.number, _RECEIPT_NUMBER)
|
||||
return _finish(c, buffer)
|
||||
|
||||
|
||||
def _overlay_page(receipt: MecenatReceipt) -> PageObject:
|
||||
buffer = io.BytesIO()
|
||||
c = canvas.Canvas(buffer, pagesize=(_PAGE_W, _PAGE_H))
|
||||
_draw(c, receipt.last_name, _LAST_NAME)
|
||||
_draw(c, receipt.first_name, _FIRST_NAME)
|
||||
_draw(c, receipt.address, _ADDRESS)
|
||||
_draw(c, receipt.postal_code, _POSTAL_CODE)
|
||||
_draw(c, receipt.city, _CITY)
|
||||
_draw(c, receipt.country, _COUNTRY)
|
||||
_draw(c, f"{receipt.amount:.2f}".replace(".", ","), _AMOUNT)
|
||||
_draw(c, amount_in_words(receipt.amount), _AMOUNT_IN_WORDS)
|
||||
_draw(c, receipt.payment_date.strftime("%d/%m/%Y"), _PAYMENT_DATE)
|
||||
_draw(c, receipt.payment_date.strftime("%d/%m/%Y"), _CERFA_DATE)
|
||||
_draw(c, "X", _CHECK_VIRMENT)
|
||||
return _finish(c, buffer)
|
||||
|
||||
|
||||
def generate_mecenat_receipts_pdf(receipts: list[MecenatReceipt]) -> bytes:
|
||||
"""Un reçu = les 2 pages du cerfa officiel, page 1 complétée avec le
|
||||
numéro d'ordre, page 2 avec les informations du donateur, du montant et
|
||||
de la date — voir le docstring
|
||||
du module. Retourne les octets du PDF concaténant tous les reçus, un
|
||||
bénévole après l'autre. Le modèle est relu à chaque itération : `merge_page`
|
||||
mutant la page en place, réutiliser le même objet d'un tour à l'autre
|
||||
accumulerait les tracés d'un bénévole sur la page du suivant."""
|
||||
writer = PdfWriter()
|
||||
for receipt in receipts:
|
||||
base = PdfReader(_TEMPLATE_PATH)
|
||||
organisation_page = writer.add_page(base.pages[0])
|
||||
organisation_page.merge_page(_organisation_overlay(receipt))
|
||||
# `add_page` renvoie la page telle qu'attachée au writer — fusionner
|
||||
# l'overlay sur ce retour plutôt que sur `base.pages[1]` (page encore
|
||||
# détachée d'un writer, `merge_page` dépréciée dans ce cas).
|
||||
donor_page = writer.add_page(base.pages[1])
|
||||
donor_page.merge_page(_overlay_page(receipt))
|
||||
buffer = io.BytesIO()
|
||||
writer.write(buffer)
|
||||
return buffer.getvalue()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Passage d'un `Volunteer` à un `MecenatReceipt` — partagé par l'export
|
||||
coordination (`app/api/volunteers.py:export_mecenat_receipts`) et l'envoi du
|
||||
reçu au bénévole à la confirmation de son paiement (`app/api/forms.py:
|
||||
_promote_pending`) — et attribution de son numéro d'ordre."""
|
||||
|
||||
from sqlalchemy import func, text
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.mecenat.pdf import MecenatReceipt
|
||||
from app.models import Volunteer
|
||||
|
||||
RECEIPT_FILENAME = "recu-fiscal-mecenat.pdf"
|
||||
|
||||
# Verrou applicatif PostgreSQL (`pg_advisory_xact_lock`) sérialisant
|
||||
# l'attribution des numéros : le webhook SumUp et le sondage du navigateur
|
||||
# peuvent confirmer deux paiements en même temps — sans lui, les deux
|
||||
# liraient le même maximum et se disputeraient le même numéro. Libéré
|
||||
# automatiquement à la fin de la transaction. Valeur arbitraire, propre à
|
||||
# cet usage.
|
||||
_NUMBERING_LOCK = 11580_05
|
||||
|
||||
|
||||
def assign_receipt_number(session: Session, volunteer: Volunteer) -> None:
|
||||
"""Attribue (sans commit) le prochain numéro de l'année du paiement :
|
||||
`AAAA` + rang sur 5 chiffres, à la suite du plus grand déjà attribué
|
||||
cette année-là, toutes éditions confondues (une seule numérotation pour
|
||||
l'association). Ne fait rien si le bénévole en a déjà un — un numéro
|
||||
n'est jamais réattribué."""
|
||||
if volunteer.mecenat_receipt_number:
|
||||
return
|
||||
session.exec(text("SELECT pg_advisory_xact_lock(:key)").bindparams(key=_NUMBERING_LOCK))
|
||||
year = (volunteer.fee_paid_at or volunteer.created_at).year
|
||||
prefix = str(year)
|
||||
last = session.exec(
|
||||
select(func.max(Volunteer.mecenat_receipt_number)).where(
|
||||
Volunteer.mecenat_receipt_number.startswith(prefix)
|
||||
)
|
||||
).one()
|
||||
rank = int(last[len(prefix):]) + 1 if last else 1
|
||||
volunteer.mecenat_receipt_number = f"{prefix}{rank:05d}"
|
||||
session.add(volunteer)
|
||||
session.flush()
|
||||
|
||||
|
||||
def receipt_for(volunteer: Volunteer) -> MecenatReceipt:
|
||||
return MecenatReceipt(
|
||||
last_name=volunteer.last_name,
|
||||
first_name=volunteer.first_name,
|
||||
address=volunteer.mecenat_address or "",
|
||||
postal_code=volunteer.mecenat_postal_code or "",
|
||||
city=volunteer.mecenat_city or "",
|
||||
country=volunteer.mecenat_country or "",
|
||||
amount=volunteer.fee_amount or 0.0,
|
||||
payment_date=(volunteer.fee_paid_at or volunteer.created_at).date(),
|
||||
number=volunteer.mecenat_receipt_number or "",
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Planches de badges bénévoles : recto/verso et champs positionnés
|
||||
|
||||
Revision ID: 0011_badge_template
|
||||
Revises: 0010_companion_wish
|
||||
Create Date: 2026-09-20
|
||||
|
||||
Deux tables : `badge_template` (images recto/verso, une ligne par édition) et
|
||||
`badge_field` (champs positionnés sur le recto — nom, poste(s), catégorie,
|
||||
rôle). Voir `app/badges/pdf.py` pour la génération des planches PDF.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0011_badge_template"
|
||||
down_revision: str | None = "0010_companion_wish"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"badge_template",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("front_image", sa.LargeBinary(), nullable=True),
|
||||
sa.Column("front_image_content_type", sa.String(), nullable=True),
|
||||
sa.Column("back_image", sa.LargeBinary(), nullable=True),
|
||||
sa.Column("back_image_content_type", sa.String(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("edition_id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"badge_field",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"field_type",
|
||||
sa.Enum(
|
||||
"FULL_NAME", "POSITIONS", "CATEGORY", "ROLE",
|
||||
name="badgefieldtype", native_enum=False,
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("x_mm", sa.Float(), nullable=False),
|
||||
sa.Column("y_mm", sa.Float(), nullable=False),
|
||||
sa.Column("width_mm", sa.Float(), nullable=False),
|
||||
sa.Column("height_mm", sa.Float(), nullable=False),
|
||||
sa.Column("font_size", sa.Float(), nullable=False),
|
||||
sa.Column(
|
||||
"align",
|
||||
sa.Enum(
|
||||
"LEFT", "CENTER", "RIGHT", name="badgealign", native_enum=False
|
||||
),
|
||||
nullable=False,
|
||||
server_default="LEFT",
|
||||
),
|
||||
sa.Column(
|
||||
"bold", sa.Boolean(), server_default="false", nullable=False
|
||||
),
|
||||
sa.Column("color", sa.String(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"edition_id", "field_type", name="uq_badge_field_edition_type"
|
||||
),
|
||||
)
|
||||
op.create_index("ix_badge_field_edition_id", "badge_field", ["edition_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_badge_field_edition_id", table_name="badge_field")
|
||||
op.drop_table("badge_field")
|
||||
op.drop_table("badge_template")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Format du badge dérivé de l'image de fond du recto
|
||||
|
||||
Revision ID: 0012_badge_template_size
|
||||
Revises: 0011_badge_template
|
||||
Create Date: 2026-09-21
|
||||
|
||||
`badge_template.front_image_width_mm`/`front_image_height_mm` : taille
|
||||
physique (mm) déduite des pixels de l'image importée (résolution supposée
|
||||
300 dpi, voir `app/badges/pdf.py:image_size_mm`) — remplace le format CR80
|
||||
fixe pour la génération du PDF (`app/api/badges.py`).
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0012_badge_template_size"
|
||||
down_revision: str | None = "0011_badge_template"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"badge_template", sa.Column("front_image_width_mm", sa.Float(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"badge_template", sa.Column("front_image_height_mm", sa.Float(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("badge_template", "front_image_height_mm")
|
||||
op.drop_column("badge_template", "front_image_width_mm")
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Formulaire public d'inscription + champs bénévole associés
|
||||
|
||||
Revision ID: 0013_signup_form
|
||||
Revises: 0012_badge_template_size
|
||||
Create Date: 2026-09-21
|
||||
|
||||
Deux tables : `form_template` (configuration par édition — ouvert/fermé) et
|
||||
`form_field` (champs activés, présence = activation). Sur `volunteer` :
|
||||
`photo`/`photo_content_type` (formulaire public), `companion_wish_note`
|
||||
(vœu de compagnon en texte libre, résolu ensuite par la coordination),
|
||||
`self_declared_available`/`willing_to_pay_fee` (cases à cocher purement
|
||||
informatives). Voir `app/api/forms.py`.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0013_signup_form"
|
||||
down_revision: str | None = "0012_badge_template_size"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("volunteer", sa.Column("photo", sa.LargeBinary(), nullable=True))
|
||||
op.add_column(
|
||||
"volunteer", sa.Column("photo_content_type", sa.String(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"volunteer", sa.Column("companion_wish_note", sa.String(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"volunteer",
|
||||
sa.Column(
|
||||
"self_declared_available", sa.Boolean(),
|
||||
server_default="false", nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"volunteer",
|
||||
sa.Column(
|
||||
"willing_to_pay_fee", sa.Boolean(),
|
||||
server_default="false", nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"form_template",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"is_open", sa.Boolean(), server_default="true", nullable=False
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("edition_id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"form_field",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"field_type",
|
||||
sa.Enum(
|
||||
"EMAIL", "PHONE", "POSITION_WISHES", "ARTIST_WISHES",
|
||||
"COMPANION_WISH", "AVAILABLE", "WILLING_TO_PAY_FEE", "PHOTO",
|
||||
"COMMENT",
|
||||
name="formfieldtype", native_enum=False,
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"edition_id", "field_type", name="uq_form_field_edition_type"
|
||||
),
|
||||
)
|
||||
op.create_index("ix_form_field_edition_id", "form_field", ["edition_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_form_field_edition_id", table_name="form_field")
|
||||
op.drop_table("form_field")
|
||||
op.drop_table("form_template")
|
||||
op.drop_column("volunteer", "willing_to_pay_fee")
|
||||
op.drop_column("volunteer", "self_declared_available")
|
||||
op.drop_column("volunteer", "companion_wish_note")
|
||||
op.drop_column("volunteer", "photo_content_type")
|
||||
op.drop_column("volunteer", "photo")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Logo et description du formulaire public
|
||||
|
||||
Revision ID: 0014_form_logo
|
||||
Revises: 0013_signup_form
|
||||
Create Date: 2026-09-23
|
||||
|
||||
Habillage de la page publique (`/inscription`, `FormTemplate`) : `logo`/
|
||||
`logo_content_type` (image, stockée en base comme les autres images de
|
||||
l'appli) et `description` (texte libre). Voir `app/api/forms.py`.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0014_form_logo"
|
||||
down_revision: str | None = "0013_signup_form"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("form_template", sa.Column("logo", sa.LargeBinary(), nullable=True))
|
||||
op.add_column(
|
||||
"form_template", sa.Column("logo_content_type", sa.String(), nullable=True)
|
||||
)
|
||||
op.add_column("form_template", sa.Column("description", sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("form_template", "description")
|
||||
op.drop_column("form_template", "logo_content_type")
|
||||
op.drop_column("form_template", "logo")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Lien de paiement Yapla
|
||||
|
||||
Revision ID: 0015_form_yapla_url
|
||||
Revises: 0014_form_logo
|
||||
Create Date: 2026-09-23
|
||||
|
||||
Encart affiché sur la page publique (`/inscription`), près de la case
|
||||
« je participe à la cotisation » — lien vers la page de cotisation sur
|
||||
Yapla (yapla.com, plateforme externe). Voir `app/api/forms.py`.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0015_form_yapla_url"
|
||||
down_revision: str | None = "0014_form_logo"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("form_template", sa.Column("yapla_url", sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("form_template", "yapla_url")
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Paiement de la cotisation via l'API SumUp (remplace le lien Yapla)
|
||||
|
||||
Revision ID: 0016_sumup_payment
|
||||
Revises: 0015_form_yapla_url
|
||||
Create Date: 2026-09-29
|
||||
|
||||
`form_template.yapla_url` (simple lien externe statique) est remplacé par un
|
||||
vrai suivi via l'API SumUp : identifiants marchand (`sumup_merchant_code`,
|
||||
`sumup_api_key`) et montant (`sumup_fee_amount`) configurés par édition, plus
|
||||
le statut de paiement par bénévole (`volunteer.fee_checkout_id`,
|
||||
`fee_payment_status`, `fee_paid_at`), tenu à jour par le webhook SumUp — voir
|
||||
`app/sumup/` et `app/api/forms.py`.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0016_sumup_payment"
|
||||
down_revision: str | None = "0015_form_yapla_url"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column("form_template", "yapla_url")
|
||||
op.add_column(
|
||||
"form_template", sa.Column("sumup_merchant_code", sa.String(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"form_template", sa.Column("sumup_api_key", sa.String(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"form_template", sa.Column("sumup_fee_amount", sa.Float(), nullable=True)
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"volunteer", sa.Column("fee_checkout_id", sa.String(), nullable=True)
|
||||
)
|
||||
op.create_index(
|
||||
"ix_volunteer_fee_checkout_id", "volunteer", ["fee_checkout_id"]
|
||||
)
|
||||
op.add_column(
|
||||
"volunteer",
|
||||
sa.Column(
|
||||
"fee_payment_status",
|
||||
sa.Enum(
|
||||
"PENDING", "PAID", "FAILED", "EXPIRED",
|
||||
name="feepaymentstatus", native_enum=False,
|
||||
),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"volunteer", sa.Column("fee_paid_at", sa.DateTime(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("volunteer", "fee_paid_at")
|
||||
op.drop_column("volunteer", "fee_payment_status")
|
||||
op.drop_index("ix_volunteer_fee_checkout_id", table_name="volunteer")
|
||||
op.drop_column("volunteer", "fee_checkout_id")
|
||||
|
||||
op.drop_column("form_template", "sumup_fee_amount")
|
||||
op.drop_column("form_template", "sumup_api_key")
|
||||
op.drop_column("form_template", "sumup_merchant_code")
|
||||
op.add_column("form_template", sa.Column("yapla_url", sa.String(), nullable=True))
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Plusieurs types de paiement de la cotisation (SumUp)
|
||||
|
||||
Revision ID: 0017_form_payment_options
|
||||
Revises: 0016_sumup_payment
|
||||
Create Date: 2026-09-29
|
||||
|
||||
`form_template.sumup_fee_amount` (un montant unique) est remplacé par une
|
||||
liste libre de types de paiement (`form_payment_option` : libellé + montant,
|
||||
configurés par la coordination, comme les créneaux types `DayPart`). Le
|
||||
bénévole en choisit au plus un à l'inscription ; le libellé et le montant
|
||||
choisis sont recopiés sur `volunteer` (`fee_payment_label`/`fee_amount`) au
|
||||
moment de la création du checkout — voir `app/api/forms.py`.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0017_form_payment_options"
|
||||
down_revision: str | None = "0016_sumup_payment"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"form_payment_option",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("label", sa.String(), nullable=False),
|
||||
sa.Column("amount", sa.Float(), nullable=False),
|
||||
sa.Column("order", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"edition_id", "label", name="uq_form_payment_option_edition_label"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_form_payment_option_edition_id", "form_payment_option", ["edition_id"]
|
||||
)
|
||||
|
||||
op.drop_column("form_template", "sumup_fee_amount")
|
||||
|
||||
op.add_column(
|
||||
"volunteer", sa.Column("fee_payment_label", sa.String(), nullable=True)
|
||||
)
|
||||
op.add_column("volunteer", sa.Column("fee_amount", sa.Float(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("volunteer", "fee_amount")
|
||||
op.drop_column("volunteer", "fee_payment_label")
|
||||
|
||||
op.add_column(
|
||||
"form_template", sa.Column("sumup_fee_amount", sa.Float(), nullable=True)
|
||||
)
|
||||
|
||||
op.drop_index(
|
||||
"ix_form_payment_option_edition_id", table_name="form_payment_option"
|
||||
)
|
||||
op.drop_table("form_payment_option")
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Inscription en deux temps + mécénat (cerfa)
|
||||
|
||||
Revision ID: 0018_pending_submissions_mecenat
|
||||
Revises: 0017_form_payment_options
|
||||
Create Date: 2026-09-29
|
||||
|
||||
Un bénévole qui choisit un paiement en ligne n'est plus créé en base tant que
|
||||
le paiement n'est pas confirmé : `pending_form_submission` retient le
|
||||
formulaire (et l'état du checkout SumUp) jusqu'à la promotion en `Volunteer`
|
||||
(`app/api/forms.py:_promote_pending`) ou jusqu'à purge si jamais payé.
|
||||
`form_payment_option.requires_mecenat_info` distingue un type de paiement
|
||||
« mécénat » (étape d'adresse postale avant paiement, cerfa) ; les coordonnées
|
||||
correspondantes vivent sur `volunteer.mecenat_*` une fois le bénévole promu.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0018_pending_submissions_mecenat"
|
||||
down_revision: str | None = "0017_form_payment_options"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_FEE_STATUS_ENUM = sa.Enum(
|
||||
"PENDING", "PAID", "FAILED", "EXPIRED",
|
||||
name="feepaymentstatus", native_enum=False,
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"form_payment_option",
|
||||
sa.Column(
|
||||
"requires_mecenat_info", sa.Boolean(), server_default="false", nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
op.add_column("volunteer", sa.Column("mecenat_address", sa.String(), nullable=True))
|
||||
op.add_column("volunteer", sa.Column("mecenat_city", sa.String(), nullable=True))
|
||||
op.add_column("volunteer", sa.Column("mecenat_postal_code", sa.String(), nullable=True))
|
||||
op.add_column("volunteer", sa.Column("mecenat_country", sa.String(), nullable=True))
|
||||
|
||||
op.create_table(
|
||||
"pending_form_submission",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("first_name", sa.String(), nullable=True),
|
||||
sa.Column("last_name", sa.String(), nullable=True),
|
||||
sa.Column("email", sa.String(), nullable=True),
|
||||
sa.Column("phone", sa.String(), nullable=True),
|
||||
sa.Column("comment", sa.String(), nullable=True),
|
||||
sa.Column("companion_wish_note", sa.String(), nullable=True),
|
||||
sa.Column("available", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("position_wish_ids_json", sa.String(), nullable=True),
|
||||
sa.Column("artist_wish_ids_json", sa.String(), nullable=True),
|
||||
sa.Column("photo", sa.LargeBinary(), nullable=True),
|
||||
sa.Column("photo_content_type", sa.String(), nullable=True),
|
||||
sa.Column("payment_label", sa.String(), nullable=False),
|
||||
sa.Column("payment_amount", sa.Float(), nullable=False),
|
||||
sa.Column(
|
||||
"requires_mecenat_info", sa.Boolean(), server_default="false", nullable=False
|
||||
),
|
||||
sa.Column("mecenat_address", sa.String(), nullable=True),
|
||||
sa.Column("mecenat_city", sa.String(), nullable=True),
|
||||
sa.Column("mecenat_postal_code", sa.String(), nullable=True),
|
||||
sa.Column("mecenat_country", sa.String(), nullable=True),
|
||||
sa.Column("checkout_id", sa.String(), nullable=True),
|
||||
sa.Column("checkout_status", _FEE_STATUS_ENUM, nullable=True),
|
||||
sa.Column("promoted_volunteer_id", sa.Uuid(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["promoted_volunteer_id"], ["volunteer.id"], ondelete="SET NULL"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_pending_form_submission_edition_id",
|
||||
"pending_form_submission",
|
||||
["edition_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_pending_form_submission_checkout_id",
|
||||
"pending_form_submission",
|
||||
["checkout_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_pending_form_submission_checkout_id", table_name="pending_form_submission"
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_pending_form_submission_edition_id", table_name="pending_form_submission"
|
||||
)
|
||||
op.drop_table("pending_form_submission")
|
||||
|
||||
op.drop_column("volunteer", "mecenat_country")
|
||||
op.drop_column("volunteer", "mecenat_postal_code")
|
||||
op.drop_column("volunteer", "mecenat_city")
|
||||
op.drop_column("volunteer", "mecenat_address")
|
||||
|
||||
op.drop_column("form_payment_option", "requires_mecenat_info")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Jeton d'accès au formulaire public (lien non devinable)
|
||||
|
||||
Revision ID: 0019_form_access_token
|
||||
Revises: 0018_pending_submissions_mecenat
|
||||
Create Date: 2026-09-30
|
||||
|
||||
`form_template.access_token` : secret partagé désormais nécessaire pour
|
||||
atteindre le formulaire public (`/inscription/<jeton>`) — voir CLAUDE.md § 2,
|
||||
`FormTemplate`, et `app/api/forms.py:_require_access_token`. `/inscription`
|
||||
seul, sans jeton dans l'URL, ne mène plus nulle part.
|
||||
|
||||
Rétrocompatibilité : chaque `form_template` déjà en base reçoit un jeton
|
||||
généré aléatoirement — les liens déjà partagés cessent de fonctionner, la
|
||||
coordination doit en repartager un nouveau (« Copier le lien » sur l'écran
|
||||
Inscriptions) — c'est précisément le but du changement.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0019_form_access_token"
|
||||
down_revision: str | None = "0018_pending_submissions_mecenat"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"form_template", sa.Column("access_token", sa.String(), nullable=True)
|
||||
)
|
||||
conn = op.get_bind()
|
||||
for (row_id,) in conn.execute(sa.text("SELECT id FROM form_template")):
|
||||
conn.execute(
|
||||
sa.text("UPDATE form_template SET access_token = :token WHERE id = :id"),
|
||||
{"token": secrets.token_urlsafe(16), "id": row_id},
|
||||
)
|
||||
op.alter_column("form_template", "access_token", nullable=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("form_template", "access_token")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Numéro d'ordre des reçus fiscaux mécénat
|
||||
|
||||
Revision ID: 0020_mecenat_receipt_number
|
||||
Revises: 0019_form_access_token
|
||||
Create Date: 2026-10-03
|
||||
|
||||
`volunteer.mecenat_receipt_number` : numéro unique du cerfa (`AAAA` + rang
|
||||
sur 5 chiffres, ex. `202600001`) — voir `app/mecenat/receipts.py`. Les
|
||||
bénévoles déjà payés en mécénat n'en reçoivent pas ici : ils sont numérotés
|
||||
au premier export des reçus, dans l'ordre de leurs paiements.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0020_mecenat_receipt_number"
|
||||
down_revision: str | None = "0019_form_access_token"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"volunteer", sa.Column("mecenat_receipt_number", sa.String(), nullable=True)
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"volunteer_mecenat_receipt_number_key", "volunteer", ["mecenat_receipt_number"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("volunteer_mecenat_receipt_number_key", "volunteer", type_="unique")
|
||||
op.drop_column("volunteer", "mecenat_receipt_number")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Type de paiement à montant libre
|
||||
|
||||
Revision ID: 0021_payment_option_free_amount
|
||||
Revises: 0020_mecenat_receipt_number
|
||||
Create Date: 2026-10-03
|
||||
|
||||
`form_payment_option.free_amount` : le bénévole saisit lui-même son montant
|
||||
sur le formulaire public, `amount` en devient le minimum — voir
|
||||
`app/api/forms.py:_free_amount`. Les types de paiement existants restent à
|
||||
montant fixe.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0021_payment_option_free_amount"
|
||||
down_revision: str | None = "0020_mecenat_receipt_number"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"form_payment_option",
|
||||
sa.Column("free_amount", sa.Boolean(), nullable=False, server_default="false"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("form_payment_option", "free_amount")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Suppression des disponibilités déclarées
|
||||
|
||||
Revision ID: 0022_drop_availability
|
||||
Revises: 0021_payment_option_free_amount
|
||||
Create Date: 2026-10-05
|
||||
|
||||
Les fenêtres de disponibilité (`availability`) disparaissent, avec la règle
|
||||
`OUTSIDE_AVAILABILITY` qui les exploitait. Les données existantes sont
|
||||
perdues : le `downgrade` recrée la table vide. La case « je suis
|
||||
disponible » du formulaire public (`volunteer.self_declared_available`)
|
||||
n'est pas concernée.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0022_drop_availability"
|
||||
down_revision: str | None = "0021_payment_option_free_amount"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_index(op.f("ix_availability_volunteer_id"), table_name="availability")
|
||||
op.drop_index(op.f("ix_availability_edition_id"), table_name="availability")
|
||||
op.drop_table("availability")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.create_table(
|
||||
"availability",
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("date", sa.Date(), nullable=False),
|
||||
sa.Column("start_time", sa.Time(), nullable=False),
|
||||
sa.Column("end_time", sa.Time(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"volunteer_id", "date", "start_time", "end_time",
|
||||
name="uq_availability_window",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_availability_edition_id"), "availability", ["edition_id"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_availability_volunteer_id"), "availability", ["volunteer_id"], unique=False
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Transaction SumUp de la cotisation
|
||||
|
||||
Revision ID: 0023_fee_transaction
|
||||
Revises: 0022_drop_availability
|
||||
Create Date: 2026-10-12
|
||||
|
||||
`volunteer.fee_transaction_code` (code affiché dans le tableau de bord SumUp,
|
||||
ex. `TEENSK4W2K`) et `fee_transaction_id` (identifiant technique) : la
|
||||
transaction qui a réglé la cotisation, posée à la confirmation du paiement —
|
||||
voir `app/api/forms.py:_promote_pending`. Les bénévoles déjà payés restent
|
||||
à `NULL`.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0023_fee_transaction"
|
||||
down_revision: str | None = "0022_drop_availability"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"volunteer", sa.Column("fee_transaction_code", sa.String(), nullable=True)
|
||||
)
|
||||
op.create_index(
|
||||
"ix_volunteer_fee_transaction_code", "volunteer", ["fee_transaction_code"]
|
||||
)
|
||||
op.add_column(
|
||||
"volunteer", sa.Column("fee_transaction_id", sa.String(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("volunteer", "fee_transaction_id")
|
||||
op.drop_index("ix_volunteer_fee_transaction_code", table_name="volunteer")
|
||||
op.drop_column("volunteer", "fee_transaction_code")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Texte libre du badge
|
||||
|
||||
Revision ID: 0024_volunteer_badge_text
|
||||
Revises: 0023_fee_transaction
|
||||
Create Date: 2026-10-27
|
||||
|
||||
`volunteer.badge_text` : texte propre à chaque bénévole, saisi par la
|
||||
coordination sur sa fiche et imprimé sur le badge par le champ
|
||||
`FREE_TEXT`. Les nouveaux types de champ de badge (`PHOTO`, `FREE_TEXT`)
|
||||
tiennent dans la colonne `badge_field.field_type` existante (VARCHAR sans
|
||||
contrainte CHECK, longueur 9 inchangée) : rien à migrer de ce côté.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0024_volunteer_badge_text"
|
||||
down_revision: str | None = "0023_fee_transaction"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("volunteer", sa.Column("badge_text", sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("volunteer", "badge_text")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Texte commun du champ libre des badges
|
||||
|
||||
Revision ID: 0025_badge_field_text
|
||||
Revises: 0024_volunteer_badge_text
|
||||
Create Date: 2026-10-27
|
||||
|
||||
`badge_field.text` : texte du champ `FREE_TEXT` saisi dans l'éditeur de
|
||||
badges, imprimé sur tous les badges — sauf ceux dont la fiche porte un
|
||||
`volunteer.badge_text`, qui le remplace pour ce bénévole.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0025_badge_field_text"
|
||||
down_revision: str | None = "0024_volunteer_badge_text"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("badge_field", sa.Column("text", sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("badge_field", "text")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Éditeur de badges v2 (verso, fond de zone, auto-ajustement, photo ovale)
|
||||
|
||||
Revision ID: 0026_badge_editor_v2
|
||||
Revises: 0025_badge_field_text
|
||||
Create Date: 2026-10-27
|
||||
|
||||
`badge_field.face` : face du badge (`FRONT`/`BACK`) — les champs existants
|
||||
deviennent des champs du recto. La contrainte d'unicité passe de
|
||||
(`edition_id`, `field_type`) à (`edition_id`, `face`, `field_type`) : un même
|
||||
champ peut figurer sur les deux faces. `bg_color` (fond de la zone,
|
||||
transparent si `NULL`), `auto_fit` (réduction automatique du texte) et
|
||||
`photo_shape` (`RECT`/`OVAL`, champ `PHOTO`).
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0026_badge_editor_v2"
|
||||
down_revision: str | None = "0025_badge_field_text"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_FACE = sa.Enum("FRONT", "BACK", name="badgeface", native_enum=False)
|
||||
_PHOTO_SHAPE = sa.Enum("RECT", "OVAL", name="badgephotoshape", native_enum=False)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"badge_field",
|
||||
sa.Column("face", _FACE, server_default="FRONT", nullable=False),
|
||||
)
|
||||
op.add_column("badge_field", sa.Column("bg_color", sa.String(), nullable=True))
|
||||
op.add_column(
|
||||
"badge_field",
|
||||
sa.Column("auto_fit", sa.Boolean(), server_default="false", nullable=False),
|
||||
)
|
||||
op.add_column(
|
||||
"badge_field",
|
||||
sa.Column("photo_shape", _PHOTO_SHAPE, server_default="RECT", nullable=False),
|
||||
)
|
||||
op.drop_constraint("uq_badge_field_edition_type", "badge_field", type_="unique")
|
||||
op.create_unique_constraint(
|
||||
"uq_badge_field_edition_face_type",
|
||||
"badge_field",
|
||||
["edition_id", "face", "field_type"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Le verso n'existait pas : ses champs sont perdus (sinon l'ancienne
|
||||
# contrainte d'unicité échouerait sur un champ présent sur les deux faces).
|
||||
op.execute("DELETE FROM badge_field WHERE face = 'BACK'")
|
||||
op.drop_constraint("uq_badge_field_edition_face_type", "badge_field", type_="unique")
|
||||
op.create_unique_constraint(
|
||||
"uq_badge_field_edition_type", "badge_field", ["edition_id", "field_type"]
|
||||
)
|
||||
op.drop_column("badge_field", "photo_shape")
|
||||
op.drop_column("badge_field", "auto_fit")
|
||||
op.drop_column("badge_field", "bg_color")
|
||||
op.drop_column("badge_field", "face")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Nombre de badges par bénévole
|
||||
|
||||
Revision ID: 0027_volunteer_badge_count
|
||||
Revises: 0026_badge_editor_v2
|
||||
Create Date: 2026-10-27
|
||||
|
||||
`volunteer.badge_count` : nombre d'exemplaires de son badge imprimés sur les
|
||||
planches (`POST .../badges/generate`), 1 par défaut — les bénévoles existants
|
||||
gardent un seul badge.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0027_volunteer_badge_count"
|
||||
down_revision: str | None = "0026_badge_editor_v2"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"volunteer",
|
||||
sa.Column("badge_count", sa.Integer(), nullable=False, server_default="1"),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_volunteer_badge_count_positive", "volunteer", "badge_count >= 1"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_volunteer_badge_count_positive", "volunteer", type_="check")
|
||||
op.drop_column("volunteer", "badge_count")
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Ordre d'empilement des champs du badge
|
||||
|
||||
Revision ID: 0028_badge_field_z_order
|
||||
Revises: 0027_volunteer_badge_count
|
||||
Create Date: 2026-10-27
|
||||
|
||||
`badge_field.z_order` : les champs d'une face sont dessinés du plus petit au
|
||||
plus grand. Avant, ils l'étaient dans l'ordre alphabétique de leur type, et
|
||||
la photo recouvrait le nom et le texte libre. Les champs existants sont
|
||||
numérotés photo d'abord (dessous), puis les autres par type.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0028_badge_field_z_order"
|
||||
down_revision: str | None = "0027_volunteer_badge_count"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"badge_field",
|
||||
sa.Column("z_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE badge_field AS b SET z_order = ranked.rank
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY edition_id, face
|
||||
ORDER BY (field_type = 'PHOTO') DESC, field_type
|
||||
) - 1 AS rank
|
||||
FROM badge_field
|
||||
) AS ranked
|
||||
WHERE b.id = ranked.id
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("badge_field", "z_order")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Messages aux bénévoles
|
||||
|
||||
Revision ID: 0029_volunteer_messages
|
||||
Revises: 0028_badge_field_z_order
|
||||
Create Date: 2026-10-28
|
||||
|
||||
`edition.volunteer_message` : message de l'organisation, le même pour tous
|
||||
les bénévoles, saisi par la coordination. `position.message` : message du
|
||||
poste (consignes…), saisi par son référent, montré aux bénévoles affectés.
|
||||
Chacun avec la date de sa dernière modification (`*_updated_at`).
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0029_volunteer_messages"
|
||||
down_revision: str | None = "0028_badge_field_z_order"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("edition", sa.Column("volunteer_message", sa.String(), nullable=True))
|
||||
op.add_column(
|
||||
"edition", sa.Column("volunteer_message_updated_at", sa.DateTime(), nullable=True)
|
||||
)
|
||||
op.add_column("position", sa.Column("message", sa.String(), nullable=True))
|
||||
op.add_column("position", sa.Column("message_updated_at", sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("position", "message_updated_at")
|
||||
op.drop_column("position", "message")
|
||||
op.drop_column("edition", "volunteer_message_updated_at")
|
||||
op.drop_column("edition", "volunteer_message")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Pointage des bénévoles
|
||||
|
||||
Revision ID: 0030_assignment_attendance
|
||||
Revises: 0029_volunteer_messages
|
||||
Create Date: 2026-10-28
|
||||
|
||||
`assignment.attendance` (`PRESENT`/`ABSENT`, `NULL` = pas pointé) et
|
||||
`attendance_marked_at` : le pointage du référent était gardé dans le
|
||||
navigateur seulement et se perdait au rechargement.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0030_assignment_attendance"
|
||||
down_revision: str | None = "0029_volunteer_messages"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_ATTENDANCE = sa.Enum("PRESENT", "ABSENT", name="attendancestatus", native_enum=False)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("assignment", sa.Column("attendance", _ATTENDANCE, nullable=True))
|
||||
op.add_column(
|
||||
"assignment", sa.Column("attendance_marked_at", sa.DateTime(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("assignment", "attendance_marked_at")
|
||||
op.drop_column("assignment", "attendance")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Lien de connexion permanent
|
||||
|
||||
Revision ID: 0031_account_login_link_version
|
||||
Revises: 0030_assignment_attendance
|
||||
Create Date: 2026-10-28
|
||||
|
||||
`account.login_link_version` : version du lien de connexion permanent du
|
||||
récapitulatif de planning (`app/auth/permanent_link.py`) — l'incrémenter
|
||||
révoque tous les liens déjà envoyés à ce compte.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0031_account_login_link_version"
|
||||
down_revision: str | None = "0030_assignment_attendance"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"account",
|
||||
sa.Column("login_link_version", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("account", "login_link_version")
|
||||
@@ -9,10 +9,15 @@ from app.models.domain import (
|
||||
Account,
|
||||
Artist,
|
||||
Assignment,
|
||||
Availability,
|
||||
BadgeField,
|
||||
BadgeTemplate,
|
||||
DayPart,
|
||||
Edition,
|
||||
FormField,
|
||||
FormPaymentOption,
|
||||
FormTemplate,
|
||||
MagicLinkToken,
|
||||
PendingFormSubmission,
|
||||
Position,
|
||||
Shift,
|
||||
Volunteer,
|
||||
@@ -22,6 +27,13 @@ from app.models.domain import (
|
||||
)
|
||||
from app.models.enums import (
|
||||
AssignmentSource,
|
||||
AttendanceStatus,
|
||||
BadgeAlign,
|
||||
BadgeFace,
|
||||
BadgeFieldType,
|
||||
BadgePhotoShape,
|
||||
FeePaymentStatus,
|
||||
FormFieldType,
|
||||
PositionCategory,
|
||||
Role,
|
||||
)
|
||||
@@ -31,10 +43,22 @@ __all__ = [
|
||||
"Artist",
|
||||
"Assignment",
|
||||
"AssignmentSource",
|
||||
"Availability",
|
||||
"AttendanceStatus",
|
||||
"BadgeAlign",
|
||||
"BadgeFace",
|
||||
"BadgeField",
|
||||
"BadgeFieldType",
|
||||
"BadgePhotoShape",
|
||||
"BadgeTemplate",
|
||||
"DayPart",
|
||||
"Edition",
|
||||
"FeePaymentStatus",
|
||||
"FormField",
|
||||
"FormFieldType",
|
||||
"FormPaymentOption",
|
||||
"FormTemplate",
|
||||
"MagicLinkToken",
|
||||
"PendingFormSubmission",
|
||||
"Position",
|
||||
"PositionCategory",
|
||||
"Role",
|
||||
|
||||
+403
-37
@@ -7,15 +7,23 @@ au niveau base (`ondelete="CASCADE"`) ; les relations ORM utilisent
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, CheckConstraint, Column, Integer, Time
|
||||
from sqlalchemy import Boolean, CheckConstraint, Column, Integer, LargeBinary, Time
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
|
||||
|
||||
from app.models.base import TimestampMixin
|
||||
from app.models.enums import (
|
||||
AssignmentSource,
|
||||
AttendanceStatus,
|
||||
BadgeAlign,
|
||||
BadgeFace,
|
||||
BadgeFieldType,
|
||||
BadgePhotoShape,
|
||||
FeePaymentStatus,
|
||||
FormFieldType,
|
||||
PositionCategory,
|
||||
Role,
|
||||
)
|
||||
@@ -85,6 +93,11 @@ class Edition(TimestampMixin, table=True):
|
||||
end_date: dt.date
|
||||
# Durée de rétention des données nominatives, en jours. None = pas de purge.
|
||||
retention_days: int | None = None
|
||||
# Message de l'organisation, le même pour tous les bénévoles (écran
|
||||
# bénévole) — saisi par la coordination. `_updated_at` : date affichée
|
||||
# sous le message, posée par l'API à chaque changement de texte.
|
||||
volunteer_message: str | None = None
|
||||
volunteer_message_updated_at: dt.datetime | None = None
|
||||
|
||||
day_parts: list["DayPart"] = Relationship(
|
||||
back_populates="edition", sa_relationship_kwargs=_PASSIVE
|
||||
@@ -155,6 +168,10 @@ class Artist(TimestampMixin, table=True):
|
||||
|
||||
|
||||
class Volunteer(TimestampMixin, table=True):
|
||||
__table_args__ = (
|
||||
CheckConstraint("badge_count >= 1", name="ck_volunteer_badge_count_positive"),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
@@ -168,6 +185,74 @@ class Volunteer(TimestampMixin, table=True):
|
||||
last_name: str
|
||||
phone: str | None = None
|
||||
comment: str | None = None
|
||||
# Renseigné par le formulaire public d'inscription (`app/api/forms.py`) —
|
||||
# voir aussi ce module pour `photo`/`photo_content_type`.
|
||||
photo: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
||||
photo_content_type: str | None = None
|
||||
# « Collègue que j'aime bien » saisi en texte libre à l'inscription : la
|
||||
# personne visée n'existe pas forcément encore en base à ce moment-là,
|
||||
# donc pas de lien direct vers un autre `Volunteer`. La coordination lit
|
||||
# cette note sur la fiche et la transforme, si besoin, en un vrai
|
||||
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
|
||||
companion_wish_note: str | None = None
|
||||
# Texte du badge propre à ce bénévole (champ `BadgeFieldType.FREE_TEXT`),
|
||||
# saisi par la coordination sur la fiche — ex. « Accès backstage ». Vide :
|
||||
# le texte commun de l'éditeur (`BadgeField.text`) s'applique.
|
||||
badge_text: str | None = None
|
||||
# Nombre d'exemplaires de son badge à imprimer sur les planches (badge
|
||||
# perdu, plusieurs accréditations…) — 1 par défaut.
|
||||
badge_count: int = Field(
|
||||
default=1, sa_column=Column(Integer, nullable=False, server_default="1")
|
||||
)
|
||||
# Cases à cocher du formulaire public — purement informatives : aucune
|
||||
# logique n'y est attachée (pas de workflow de paiement).
|
||||
self_declared_available: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
willing_to_pay_fee: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
# Paiement de la cotisation via l'API SumUp (`app/sumup/`, voir
|
||||
# `app/api/forms.py:submit_public_form`/`sumup_webhook`) — créé si le
|
||||
# bénévole choisit un type de paiement (`FormPaymentOption`) et que
|
||||
# l'édition a configuré SumUp (`FormTemplate.sumup_*`). `fee_payment_label`/
|
||||
# `fee_amount` sont une copie figée du libellé/montant de l'option
|
||||
# choisie au moment du choix (jamais recalculés si l'option est éditée
|
||||
# ou supprimée ensuite). `fee_checkout_id` (identifiant SumUp du
|
||||
# checkout) sert seulement à faire le lien depuis le webhook ; jamais
|
||||
# exposé (voir `app/api/volunteers.py:_read`). `fee_payment_status` reste
|
||||
# `NULL` tant qu'aucun checkout n'a été créé.
|
||||
fee_payment_label: str | None = None
|
||||
fee_amount: float | None = None
|
||||
fee_checkout_id: str | None = Field(default=None, index=True)
|
||||
fee_payment_status: FeePaymentStatus | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
|
||||
)
|
||||
fee_paid_at: dt.datetime | None = None
|
||||
# Transaction SumUp qui a réglé la cotisation (`app/sumup/client.py:
|
||||
# SumUpCheckout`), posée à la confirmation du paiement : le code court
|
||||
# (`TEENSK4W2K`) est celui du tableau de bord SumUp et de ses reçus —
|
||||
# rapprochement comptable paiement ↔ bénévole. Exposés (fiche, export
|
||||
# Excel), contrairement à `fee_checkout_id`.
|
||||
fee_transaction_code: str | None = Field(default=None, index=True)
|
||||
fee_transaction_id: str | None = None
|
||||
# Coordonnées collectées uniquement pour un type de paiement « mécénat »
|
||||
# (`FormPaymentOption.requires_mecenat_info`), avant le paiement (voir
|
||||
# `PendingFormSubmission` et `app/api/forms.py:submit_mecenat_info`) —
|
||||
# conservées pour générer un reçu fiscal (cerfa) ultérieurement. `None`
|
||||
# pour un bénévole n'ayant pas payé via une option de mécénat.
|
||||
mecenat_address: str | None = None
|
||||
mecenat_city: str | None = None
|
||||
mecenat_postal_code: str | None = None
|
||||
mecenat_country: str | None = None
|
||||
# Numéro d'ordre du reçu fiscal (cerfa) : année du paiement + rang sur 5
|
||||
# chiffres (`202600001`), attribué une seule fois — à la confirmation du
|
||||
# paiement ou, à défaut, au premier export — puis figé, pour qu'un reçu
|
||||
# regénéré garde son numéro. Voir `app/mecenat/receipts.py`.
|
||||
mecenat_receipt_number: str | None = Field(default=None, unique=True)
|
||||
|
||||
wished_artists: list[Artist] = Relationship(
|
||||
back_populates="fans",
|
||||
@@ -179,9 +264,6 @@ class Volunteer(TimestampMixin, table=True):
|
||||
link_model=VolunteerPositionWish,
|
||||
sa_relationship_kwargs=_PASSIVE,
|
||||
)
|
||||
availabilities: list["Availability"] = Relationship(
|
||||
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
|
||||
)
|
||||
assignments: list["Assignment"] = Relationship(
|
||||
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
|
||||
)
|
||||
@@ -212,6 +294,12 @@ class Account(TimestampMixin, table=True):
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Version du lien de connexion permanent (`app/auth/permanent_link.py`) :
|
||||
# l'incrémenter révoque tous les liens déjà envoyés à ce compte.
|
||||
login_link_version: int = Field(
|
||||
default=0, sa_column=Column(Integer, nullable=False, server_default="0")
|
||||
)
|
||||
|
||||
@property
|
||||
def auth_method(self) -> str:
|
||||
return "password" if self.password_hash else "magic_link"
|
||||
@@ -250,6 +338,10 @@ class Position(TimestampMixin, table=True):
|
||||
ondelete="SET NULL",
|
||||
nullable=True,
|
||||
)
|
||||
# Message du poste (consignes…), saisi par son référent ou la
|
||||
# coordination, montré aux bénévoles affectés au poste.
|
||||
message: str | None = None
|
||||
message_updated_at: dt.datetime | None = None
|
||||
|
||||
shifts: list["Shift"] = Relationship(
|
||||
back_populates="position", sa_relationship_kwargs=_PASSIVE
|
||||
@@ -294,43 +386,11 @@ class Shift(TimestampMixin, table=True):
|
||||
)
|
||||
|
||||
|
||||
class Availability(TimestampMixin, table=True):
|
||||
"""Déclaration du bénévole : « tel jour, je peux venir de telle à telle
|
||||
heure ». Fenêtre horaire libre (pas liée aux créneaux types). Plusieurs
|
||||
fenêtres par jour possibles.
|
||||
|
||||
Heures naïves, locales Europe/Paris. `end_time <= start_time` ⇒ la fenêtre
|
||||
passe minuit (se termine le lendemain). À ne pas confondre avec Assignment
|
||||
(décision de l'organisation).
|
||||
"""
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"volunteer_id", "date", "start_time", "end_time",
|
||||
name="uq_availability_window",
|
||||
),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
volunteer_id: uuid.UUID = Field(
|
||||
foreign_key="volunteer.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
date: dt.date
|
||||
start_time: dt.time
|
||||
end_time: dt.time
|
||||
|
||||
volunteer: Volunteer = Relationship(back_populates="availabilities")
|
||||
|
||||
|
||||
class Assignment(TimestampMixin, table=True):
|
||||
"""Décision de l'organisation : « tu tiens tel créneau ».
|
||||
|
||||
Existe ⇒ le bénévole est affecté (pas d'étape de confirmation). Retirer une
|
||||
affectation, c'est supprimer la ligne. À ne pas confondre avec Availability
|
||||
(déclaration du bénévole).
|
||||
affectation, c'est supprimer la ligne.
|
||||
"""
|
||||
|
||||
__table_args__ = (
|
||||
@@ -361,6 +421,312 @@ class Assignment(TimestampMixin, table=True):
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
# Pointage par le référent du poste (ou la coordination) : `None` tant
|
||||
# que personne n'a pointé. `attendance_marked_at` : heure du dernier
|
||||
# pointage, affichée sur l'écran référent.
|
||||
attendance: AttendanceStatus | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(SAEnum(AttendanceStatus, native_enum=False), nullable=True),
|
||||
)
|
||||
attendance_marked_at: dt.datetime | None = None
|
||||
|
||||
volunteer: Volunteer = Relationship(back_populates="assignments")
|
||||
shift: Shift = Relationship(back_populates="assignments")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Badges (planches recto/verso, voir app/badges/) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class BadgeTemplate(TimestampMixin, table=True):
|
||||
"""Recto/verso du badge d'une édition. Une ligne par édition (créée à la
|
||||
demande). Le verso (`back_image`) est le même pour tous les bénévoles ;
|
||||
le recto combine `front_image` (fond) et les `BadgeField` positionnés
|
||||
dessus. Images stockées en base (pas de volume de fichiers à gérer).
|
||||
|
||||
Le format du badge (`front_image_width_mm`/`height_mm`) est **dérivé de
|
||||
l'image du recto** à l'import (voir `app/badges/pdf.py:image_size_mm`,
|
||||
résolution supposée 300 dpi) plutôt qu'un format fixe imposé — `None`
|
||||
tant qu'aucune image n'a été importée (l'éditeur retombe alors sur le
|
||||
format CR80 par défaut, voir `DEFAULT_BADGE_WIDTH_MM`)."""
|
||||
|
||||
__tablename__ = "badge_template"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", unique=True, ondelete="CASCADE"
|
||||
)
|
||||
front_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
||||
front_image_content_type: str | None = None
|
||||
front_image_width_mm: float | None = None
|
||||
front_image_height_mm: float | None = None
|
||||
back_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
||||
back_image_content_type: str | None = None
|
||||
|
||||
|
||||
class BadgeField(TimestampMixin, table=True):
|
||||
"""Un champ positionné sur une face du badge (`face` : recto ou verso —
|
||||
nom, poste(s), catégorie, rôle… voir `BadgeFieldType`). Coordonnées en
|
||||
millimètres depuis le coin haut-gauche du badge (voir `app/badges/pdf.py`).
|
||||
Au plus un champ par `field_type` et par face pour une édition (le même
|
||||
champ peut figurer au recto et au verso) : décocher un champ dans
|
||||
l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`)."""
|
||||
|
||||
__tablename__ = "badge_field"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"edition_id", "face", "field_type", name="uq_badge_field_edition_face_type"
|
||||
),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
face: BadgeFace = Field(
|
||||
default=BadgeFace.FRONT,
|
||||
sa_column=Column(
|
||||
SAEnum(BadgeFace, native_enum=False),
|
||||
nullable=False,
|
||||
server_default=BadgeFace.FRONT.value,
|
||||
),
|
||||
)
|
||||
field_type: BadgeFieldType = Field(
|
||||
sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False)
|
||||
)
|
||||
x_mm: float
|
||||
y_mm: float
|
||||
width_mm: float
|
||||
height_mm: float
|
||||
font_size: float = Field(default=10.0)
|
||||
align: BadgeAlign = Field(
|
||||
default=BadgeAlign.LEFT,
|
||||
sa_column=Column(
|
||||
SAEnum(BadgeAlign, native_enum=False),
|
||||
nullable=False,
|
||||
server_default=BadgeAlign.LEFT.value,
|
||||
),
|
||||
)
|
||||
bold: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
color: str = Field(default="#000000")
|
||||
# Champ `FREE_TEXT` seulement : texte saisi dans l'éditeur, imprimé sur
|
||||
# tous les badges — sauf ceux dont la fiche porte un « Texte du badge »
|
||||
# (`Volunteer.badge_text`), qui le remplace pour ce bénévole.
|
||||
text: str | None = None
|
||||
# Fond de la zone du champ (`#rrggbb`) ; `None` = transparent. Sans
|
||||
# effet sur le champ `PHOTO`.
|
||||
bg_color: str | None = None
|
||||
# Réduit la taille du texte (par pas de 0,5 pt) jusqu'à ce que tout
|
||||
# tienne dans le cadre, au lieu de tronquer les lignes en trop.
|
||||
auto_fit: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
# Champ `PHOTO` seulement : rectangle ou ellipse inscrite dans le cadre.
|
||||
photo_shape: BadgePhotoShape = Field(
|
||||
default=BadgePhotoShape.RECT,
|
||||
sa_column=Column(
|
||||
SAEnum(BadgePhotoShape, native_enum=False),
|
||||
nullable=False,
|
||||
server_default=BadgePhotoShape.RECT.value,
|
||||
),
|
||||
)
|
||||
# Ordre d'empilement sur sa face : les champs sont dessinés du plus petit
|
||||
# au plus grand, un champ recouvre donc ceux d'un `z_order` inférieur.
|
||||
z_order: int = Field(
|
||||
default=0, sa_column=Column(Integer, nullable=False, server_default="0")
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Formulaire public d'inscription (voir app/api/forms.py) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class FormTemplate(TimestampMixin, table=True):
|
||||
"""Configuration du formulaire public d'une édition. Une ligne par
|
||||
édition (créée à la demande). `is_open` coupe les nouvelles soumissions
|
||||
sans effacer la configuration (les champs activés restent enregistrés).
|
||||
`logo`/`logo_content_type` et `description` habillent la page publique
|
||||
(`/inscription`) — aucun rapport avec les champs collectés par
|
||||
`FormField` ; stockage en base comme les autres images de l'appli
|
||||
(badges, photo de bénévole), pas de volume de fichiers à gérer.
|
||||
`sumup_*` configurent le paiement de la cotisation via l'API SumUp (voir
|
||||
`app/sumup/`)."""
|
||||
|
||||
__tablename__ = "form_template"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", unique=True, ondelete="CASCADE"
|
||||
)
|
||||
is_open: bool = Field(
|
||||
default=True,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="true"),
|
||||
)
|
||||
logo: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
||||
logo_content_type: str | None = None
|
||||
description: str | None = None
|
||||
# Paiement de la cotisation via l'API SumUp (`app/sumup/`) : un checkout
|
||||
# est créé à la soumission si le bénévole choisit un des types de
|
||||
# paiement configurés (`FormPaymentOption`, voir plus bas) et que ces
|
||||
# deux identifiants sont renseignés (voir `app/api/forms.py`,
|
||||
# `sumup_enabled` sur `PublicFormConfig`). `sumup_api_key` n'est jamais
|
||||
# renvoyé par l'API (voir `FormTemplateRead.sumup_api_key_set`) — c'est
|
||||
# un secret, au même titre qu'un mot de passe SMTP.
|
||||
sumup_merchant_code: str | None = None
|
||||
sumup_api_key: str | None = None
|
||||
# Secret partagé nécessaire pour atteindre le formulaire public
|
||||
# (`GET .../form/public`, `GET .../form/logo`, `POST .../form/public/submit`
|
||||
# — voir `app/api/forms.py:_require_access_token`) : `/inscription` seul,
|
||||
# sans ce jeton dans l'URL, ne mène nulle part. Généré aléatoirement à la
|
||||
# création de la ligne (`secrets.token_urlsafe`) ; régénérable par la
|
||||
# coordination (`POST .../form/regenerate-token`) pour révoquer un lien
|
||||
# qui aurait fuité — l'ancien cesse alors immédiatement de fonctionner.
|
||||
access_token: str = Field(default_factory=lambda: secrets.token_urlsafe(16))
|
||||
|
||||
|
||||
class FormPaymentOption(TimestampMixin, table=True):
|
||||
"""Un type de paiement proposé sur le formulaire public (ex. « Cotisation
|
||||
classique », « Mécénat ») : libellé + montant fixe, configurés librement
|
||||
par la coordination (autant qu'elle veut, comme les créneaux types
|
||||
`DayPart`) — voir `app/api/forms.py`. Le bénévole en choisit au plus un
|
||||
à l'inscription ; le libellé et le montant réellement proposés sont
|
||||
recopiés sur `Volunteer` au moment de la création du checkout
|
||||
(`fee_payment_label`/`fee_amount`), pour ne jamais changer
|
||||
rétroactivement si cette configuration est éditée ou supprimée plus
|
||||
tard."""
|
||||
|
||||
__tablename__ = "form_payment_option"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"edition_id", "label", name="uq_form_payment_option_edition_label"
|
||||
),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
label: str
|
||||
amount: float
|
||||
order: int = Field(
|
||||
default=0,
|
||||
sa_column=Column(Integer, nullable=False, server_default="0"),
|
||||
)
|
||||
# Type « mécénat » : avant le paiement, le bénévole passe par une étape
|
||||
# supplémentaire (adresse postale) pour permettre l'édition d'un reçu
|
||||
# fiscal (cerfa) — voir `Volunteer.mecenat_*` et
|
||||
# `app/api/forms.py:submit_mecenat_info`.
|
||||
requires_mecenat_info: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
# Montant libre : le bénévole saisit lui-même son montant sur le
|
||||
# formulaire public ; `amount` en est alors le minimum (voir
|
||||
# `app/api/forms.py:_free_amount`).
|
||||
free_amount: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
|
||||
|
||||
class PendingFormSubmission(TimestampMixin, table=True):
|
||||
"""Inscription en attente de paiement en ligne (voir CLAUDE.md § 2,
|
||||
`Paiement de la cotisation`). Créée à la place d'un `Volunteer` quand le
|
||||
bénévole choisit un type de paiement en ligne (`FormPaymentOption`) sur
|
||||
le formulaire public — le `Volunteer` n'est créé qu'à la confirmation du
|
||||
paiement (`app/api/forms.py:_promote_pending`), pour ne jamais compter en
|
||||
base quelqu'un qui a rempli le formulaire sans aller au bout du
|
||||
paiement.
|
||||
|
||||
Reprend les champs du formulaire (identiques à `Volunteer`, en plus
|
||||
volatil) ; `position_wish_ids_json`/`artist_wish_ids_json` : listes
|
||||
JSON stringifiées (pas d'association SQL pour une ligne éphémère), leurs
|
||||
UUID sont résolus (silencieusement filtrés si un poste/artiste a
|
||||
entretemps disparu — le paiement ne doit jamais échouer pour ça) au
|
||||
moment de la promotion en `Volunteer`.
|
||||
|
||||
`id` sert aussi de jeton public (`GET .../form/public/pending/{id}`,
|
||||
aucune authentification) : un UUID v4 est déjà pratiquement impossible
|
||||
à deviner, comme un identifiant de session de paiement chez la plupart
|
||||
des prestataires.
|
||||
|
||||
`payment_label`/`payment_amount`/`requires_mecenat_info` sont une copie
|
||||
figée de l'option choisie (même principe que `Volunteer.fee_payment_label`)
|
||||
— indépendante d'une modification de `FormPaymentOption` pendant que le
|
||||
paiement est en cours. `checkout_id`/`checkout_status` suivent le
|
||||
checkout SumUp *courant* : relancés (nouvel identifiant) à chaque
|
||||
nouvelle tentative après un échec/une expiration
|
||||
(`app/api/forms.py:retry_pending_payment`), sans jamais perdre les
|
||||
informations déjà saisies.
|
||||
|
||||
`promoted_volunteer_id` : posé au moment du paiement confirmé (`PAID`) —
|
||||
le `Volunteer` créé. Les champs nominatifs sont alors vidés (RGPD :
|
||||
la même information vit désormais uniquement sur `Volunteer`), seuls
|
||||
les champs de suivi du paiement restent. Une ligne jamais promue est
|
||||
purgée après un délai (voir `_prune_stale_pending`)."""
|
||||
|
||||
__tablename__ = "pending_form_submission"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
comment: str | None = None
|
||||
companion_wish_note: str | None = None
|
||||
available: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
position_wish_ids_json: str | None = None
|
||||
artist_wish_ids_json: str | None = None
|
||||
photo: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
||||
photo_content_type: str | None = None
|
||||
|
||||
payment_label: str
|
||||
payment_amount: float
|
||||
requires_mecenat_info: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
mecenat_address: str | None = None
|
||||
mecenat_city: str | None = None
|
||||
mecenat_postal_code: str | None = None
|
||||
mecenat_country: str | None = None
|
||||
|
||||
checkout_id: str | None = Field(default=None, index=True)
|
||||
checkout_status: FeePaymentStatus | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
|
||||
)
|
||||
promoted_volunteer_id: uuid.UUID | None = Field(
|
||||
default=None, foreign_key="volunteer.id", ondelete="SET NULL"
|
||||
)
|
||||
|
||||
|
||||
class FormField(SQLModel, table=True):
|
||||
"""Un champ activé sur le formulaire public (voir `FormFieldType`) — la
|
||||
présence d'une ligne vaut activation. Pas de champs d'audit : juste une
|
||||
case à cocher dans l'écran de configuration, remplacée en bloc (`PUT
|
||||
.../form/fields`), rien d'autre à suivre dans le temps."""
|
||||
|
||||
__tablename__ = "form_field"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"edition_id", "field_type", name="uq_form_field_edition_type"
|
||||
),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
field_type: FormFieldType = Field(
|
||||
sa_column=Column(SAEnum(FormFieldType, native_enum=False), nullable=False)
|
||||
)
|
||||
|
||||
@@ -18,6 +18,13 @@ class AssignmentSource(enum.StrEnum):
|
||||
MANUAL = "MANUAL"
|
||||
|
||||
|
||||
class AttendanceStatus(enum.StrEnum):
|
||||
"""Pointage d'un bénévole sur son créneau, par le référent du poste."""
|
||||
|
||||
PRESENT = "PRESENT"
|
||||
ABSENT = "ABSENT"
|
||||
|
||||
|
||||
class PositionCategory(enum.StrEnum):
|
||||
BAR = "BAR"
|
||||
TICKETING = "TICKETING"
|
||||
@@ -28,3 +35,68 @@ class PositionCategory(enum.StrEnum):
|
||||
SETUP = "SETUP"
|
||||
STAGE = "STAGE"
|
||||
OTHER = "OTHER"
|
||||
|
||||
|
||||
class BadgeFieldType(enum.StrEnum):
|
||||
"""Champ affichable sur le recto ou le verso d'un badge (voir `app/badges/`)."""
|
||||
|
||||
FULL_NAME = "FULL_NAME"
|
||||
POSITIONS = "POSITIONS"
|
||||
CATEGORY = "CATEGORY"
|
||||
ROLE = "ROLE"
|
||||
# Photo du bénévole (`Volunteer.photo`), ajustée au cadre du champ.
|
||||
PHOTO = "PHOTO"
|
||||
# Texte libre saisi par la coordination dans l'éditeur (`BadgeField.text`,
|
||||
# commun à tous les badges), remplacé pour un bénévole par le « Texte du
|
||||
# badge » de sa fiche (`Volunteer.badge_text`) s'il en a un.
|
||||
FREE_TEXT = "FREE_TEXT"
|
||||
|
||||
|
||||
class BadgeFace(enum.StrEnum):
|
||||
"""Face du badge sur laquelle un champ est imprimé : le verso est
|
||||
personnalisable comme le recto (même champ possible sur les deux)."""
|
||||
|
||||
FRONT = "FRONT"
|
||||
BACK = "BACK"
|
||||
|
||||
|
||||
class BadgePhotoShape(enum.StrEnum):
|
||||
"""Forme de découpe du champ `PHOTO` (cadre rectangulaire ou ellipse
|
||||
inscrite dans le cadre)."""
|
||||
|
||||
RECT = "RECT"
|
||||
OVAL = "OVAL"
|
||||
|
||||
|
||||
class BadgeAlign(enum.StrEnum):
|
||||
LEFT = "LEFT"
|
||||
CENTER = "CENTER"
|
||||
RIGHT = "RIGHT"
|
||||
|
||||
|
||||
class FeePaymentStatus(enum.StrEnum):
|
||||
"""Statut d'un paiement de cotisation via l'API SumUp (voir `app/sumup/`
|
||||
et `app/api/forms.py`). Reprend tel quel le statut d'un checkout SumUp —
|
||||
`NULL` sur `Volunteer.fee_payment_status` tant qu'aucun checkout n'a
|
||||
jamais été créé pour ce bénévole."""
|
||||
|
||||
PENDING = "PENDING"
|
||||
PAID = "PAID"
|
||||
FAILED = "FAILED"
|
||||
EXPIRED = "EXPIRED"
|
||||
|
||||
|
||||
class FormFieldType(enum.StrEnum):
|
||||
"""Champ activable sur le formulaire public d'inscription (voir
|
||||
`app/api/forms.py`). Nom/prénom sont toujours collectés, pas au choix de
|
||||
la coordination — ils sont indispensables à la création du bénévole."""
|
||||
|
||||
EMAIL = "EMAIL"
|
||||
PHONE = "PHONE"
|
||||
POSITION_WISHES = "POSITION_WISHES"
|
||||
ARTIST_WISHES = "ARTIST_WISHES"
|
||||
COMPANION_WISH = "COMPANION_WISH"
|
||||
AVAILABLE = "AVAILABLE"
|
||||
WILLING_TO_PAY_FEE = "WILLING_TO_PAY_FEE"
|
||||
PHOTO = "PHOTO"
|
||||
COMMENT = "COMMENT"
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Journal des requêtes HTTP — une ligne par requête, pour diagnostiquer
|
||||
après coup une erreur rencontrée par un utilisateur (voir CLAUDE.md § 8,
|
||||
RGPD : identifiants et codes seulement).
|
||||
|
||||
- Chaque requête reçoit un identifiant (`X-Request-ID`, repris s'il est
|
||||
fourni et bien formé, généré sinon), renvoyé dans l'en-tête de la réponse
|
||||
et préfixé à toutes les lignes de log émises pendant son traitement
|
||||
(`app/logging_config.py:request_id_var`).
|
||||
- La ligne d'accès donne la route **modèle** (`/editions/{edition_id}/…`) et
|
||||
ses paramètres d'identifiants, jamais l'URL brute : la chaîne de requête
|
||||
peut porter un jeton (`access_token` du formulaire public), et les
|
||||
paramètres de chemin nommés `*token*` (inscription en attente) sont omis.
|
||||
- Code d'erreur métier (`detail.code`) et compte connecté ajoutés quand ils
|
||||
existent ; détails d'une erreur de validation 422 (emplacement + type,
|
||||
jamais la valeur saisie).
|
||||
- Une exception non gérée est journalisée avec sa trace et renvoie un 500
|
||||
JSON `{detail: {code: "error.internal", request_id}}` plutôt qu'un texte
|
||||
brut.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exception_handlers import (
|
||||
http_exception_handler,
|
||||
request_validation_exception_handler,
|
||||
)
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.logging_config import request_id_var
|
||||
|
||||
logger = logging.getLogger("app.request")
|
||||
|
||||
REQUEST_ID_HEADER = "X-Request-ID"
|
||||
_INCOMING_ID = re.compile(r"^[A-Za-z0-9-]{8,64}$")
|
||||
# Sondes de santé (Docker, Caddy) : pas une ligne INFO toutes les 10 s.
|
||||
_QUIET_PATHS = {"/health"}
|
||||
|
||||
|
||||
def _route_label(request: Request) -> str:
|
||||
route = request.scope.get("route")
|
||||
template = getattr(route, "path", None) or "<non routé>"
|
||||
params = {
|
||||
key: value
|
||||
for key, value in (request.scope.get("path_params") or {}).items()
|
||||
if "token" not in key
|
||||
}
|
||||
if not params:
|
||||
return template
|
||||
return f"{template} " + " ".join(f"{k}={v}" for k, v in params.items())
|
||||
|
||||
|
||||
def install_request_logging(app: FastAPI) -> None:
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
incoming = request.headers.get(REQUEST_ID_HEADER, "")
|
||||
request_id = incoming if _INCOMING_ID.match(incoming) else uuid.uuid4().hex[:12]
|
||||
reset = request_id_var.set(request_id)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Exception non gérée sur %s %s", request.method, _route_label(request)
|
||||
)
|
||||
response = JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": {"code": "error.internal", "request_id": request_id}},
|
||||
)
|
||||
duration_ms = (time.monotonic() - started) * 1000
|
||||
response.headers[REQUEST_ID_HEADER] = request_id
|
||||
|
||||
status_code = response.status_code
|
||||
if status_code >= 500:
|
||||
level = logging.ERROR
|
||||
elif status_code >= 400:
|
||||
level = logging.WARNING
|
||||
elif request.url.path in _QUIET_PATHS or request.method == "OPTIONS":
|
||||
level = logging.DEBUG
|
||||
else:
|
||||
level = logging.INFO
|
||||
extras = []
|
||||
error_code = getattr(request.state, "error_code", None)
|
||||
if error_code:
|
||||
extras.append(f"code={error_code}")
|
||||
account_id = getattr(request.state, "account_id", None)
|
||||
if account_id:
|
||||
extras.append(f"compte={account_id}")
|
||||
logger.log(
|
||||
level,
|
||||
"%s %s → %d (%d ms)%s",
|
||||
request.method,
|
||||
_route_label(request),
|
||||
status_code,
|
||||
duration_ms,
|
||||
(" " + " ".join(extras)) if extras else "",
|
||||
)
|
||||
return response
|
||||
finally:
|
||||
request_id_var.reset(reset)
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def log_http_exception(request: Request, exc: HTTPException):
|
||||
detail = exc.detail
|
||||
if isinstance(detail, dict) and detail.get("code"):
|
||||
request.state.error_code = detail["code"]
|
||||
violations = detail.get("violations")
|
||||
if violations:
|
||||
# Codes de violation seulement (`SHIFT_OVERLAP`…) : les
|
||||
# paramètres peuvent porter des horaires, pas utiles ici.
|
||||
request.state.error_code += " violations=" + ",".join(
|
||||
sorted({str(v.get("code")) for v in violations if isinstance(v, dict)})
|
||||
)
|
||||
return await http_exception_handler(request, exc)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def log_validation_error(request: Request, exc: RequestValidationError):
|
||||
# Emplacement + type d'erreur, jamais `input` (la valeur saisie, qui
|
||||
# peut être un nom, un email…).
|
||||
request.state.error_code = "validation " + "; ".join(
|
||||
f"{'.'.join(str(p) for p in err.get('loc', ()))}:{err.get('type')}"
|
||||
for err in exc.errors()[:10]
|
||||
)
|
||||
return await request_validation_exception_handler(request, exc)
|
||||
+51
-157
@@ -9,9 +9,8 @@ Déterministe (graine fixe). Aucune donnée nominative réelle.
|
||||
import datetime as dt
|
||||
import random
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth.passwords import hash_password
|
||||
@@ -22,9 +21,9 @@ from app.models import (
|
||||
Artist,
|
||||
Assignment,
|
||||
AssignmentSource,
|
||||
Availability,
|
||||
DayPart,
|
||||
Edition,
|
||||
FeePaymentStatus,
|
||||
Position,
|
||||
PositionCategory,
|
||||
Role,
|
||||
@@ -34,14 +33,7 @@ from app.models import (
|
||||
VolunteerCompanionWish,
|
||||
VolunteerPositionWish,
|
||||
)
|
||||
from app.validation import (
|
||||
AvailabilityWindow,
|
||||
DayPartWindow,
|
||||
Severity,
|
||||
ShiftInfo,
|
||||
ValidationInput,
|
||||
validate,
|
||||
)
|
||||
from app.validation import Severity, validate
|
||||
from app.validation.loader import build_validation_input
|
||||
|
||||
SEED = 20260709
|
||||
@@ -203,6 +195,14 @@ ARTIST_SLOTS: list[tuple[dt.time, dt.time]] = [
|
||||
(dt.time(21, 30), dt.time(23, 0)),
|
||||
]
|
||||
|
||||
# Adresses postales des bénévoles « mécénat » — voir `_add_demo_payments`.
|
||||
MECENAT_ADDRESSES = [
|
||||
("12 rue des Lilas", "75020", "Paris"),
|
||||
("3 impasse du Petit Bois", "44000", "Nantes"),
|
||||
("8 avenue de la République", "69003", "Lyon"),
|
||||
("21 chemin des Vignes", "33000", "Bordeaux"),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Summary:
|
||||
@@ -216,7 +216,8 @@ class Summary:
|
||||
position_wishes: int = 0
|
||||
artist_wishes: int = 0
|
||||
companion_wishes: int = 0
|
||||
warnings: Counter = field(default_factory=Counter)
|
||||
fee_paid_classique: int = 0
|
||||
fee_paid_mecenat: int = 0
|
||||
blocking: int = 0
|
||||
|
||||
def __str__(self) -> str:
|
||||
@@ -232,10 +233,9 @@ class Summary:
|
||||
f" Vœux de poste déclarés : {self.position_wishes}",
|
||||
f" Vœux d'artiste déclarés : {self.artist_wishes}",
|
||||
f" Vœux de compagnon déclarés : {self.companion_wishes}",
|
||||
f" Avertissements actifs : {sum(self.warnings.values())}",
|
||||
f" Cotisation payée (classique) : {self.fee_paid_classique}",
|
||||
f" Cotisation payée (mécénat) : {self.fee_paid_mecenat}",
|
||||
]
|
||||
for code, count in sorted(self.warnings.items()):
|
||||
lines.append(f" {code} : {count}")
|
||||
lines.append(f" Violations bloquantes : {self.blocking}")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
@@ -296,9 +296,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
]
|
||||
session.add_all(day_parts)
|
||||
session.flush()
|
||||
dp_windows = tuple(
|
||||
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order) for dp in day_parts
|
||||
)
|
||||
|
||||
coordination = Account(
|
||||
edition_id=edition.id,
|
||||
@@ -415,27 +412,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
)
|
||||
)
|
||||
n_companion_wishes += 1
|
||||
# Disponibilités : chaque bénévole déclare, pour ~80 % des jours, une
|
||||
# large fenêtre couvrant la journée et la nuit (06:00 → 04:00 le lendemain).
|
||||
_FULL_DAY = (dt.time(6, 0), dt.time(4, 0))
|
||||
availabilities: dict[object, list[AvailabilityWindow]] = {}
|
||||
for volunteer in volunteers:
|
||||
windows: list[AvailabilityWindow] = []
|
||||
for day in dates:
|
||||
if rng.random() < 0.8:
|
||||
start, end = _FULL_DAY
|
||||
session.add(
|
||||
Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=day,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
)
|
||||
)
|
||||
windows.append(AvailabilityWindow(day, start, end))
|
||||
availabilities[volunteer.id] = windows
|
||||
|
||||
shifts: list[Shift] = []
|
||||
for position, (_n, category, _l, _r) in zip(positions, POSITIONS, strict=True):
|
||||
for day in dates:
|
||||
@@ -457,28 +433,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
|
||||
assigned: dict[object, list[Shift]] = {v.id: [] for v in volunteers}
|
||||
|
||||
# Trois bénévoles réservés aux scénarios d'avertissement : exclus de
|
||||
# l'affectation générale pour rester libres.
|
||||
reserved = volunteers[:3]
|
||||
reserved_ids = {v.id for v in reserved}
|
||||
assignable = [v for v in volunteers if v.id not in reserved_ids]
|
||||
|
||||
def _clean(volunteer: Volunteer, shift: Shift) -> bool:
|
||||
data = ValidationInput(
|
||||
candidate_shift=ShiftInfo(
|
||||
id=str(shift.id),
|
||||
starts_at=shift.starts_at,
|
||||
ends_at=shift.ends_at,
|
||||
),
|
||||
existing_shifts=tuple(
|
||||
ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at)
|
||||
for s in assigned[volunteer.id]
|
||||
),
|
||||
availabilities=tuple(availabilities[volunteer.id]),
|
||||
day_parts=dp_windows,
|
||||
)
|
||||
return not validate(data)
|
||||
|
||||
n_assignments = 0
|
||||
order = shifts[:]
|
||||
rng.shuffle(order)
|
||||
@@ -487,7 +441,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
target = max(0, shift.min_staff - 1)
|
||||
else:
|
||||
target = rng.randint(shift.min_staff, shift.ideal_staff)
|
||||
pool = assignable[:]
|
||||
pool = volunteers[:]
|
||||
rng.shuffle(pool)
|
||||
placed = 0
|
||||
for volunteer in pool:
|
||||
@@ -495,8 +449,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
break
|
||||
if any(_overlap(shift, s) for s in assigned[volunteer.id]):
|
||||
continue
|
||||
if not _clean(volunteer, shift):
|
||||
continue
|
||||
session.add(
|
||||
Assignment(
|
||||
edition_id=edition.id,
|
||||
@@ -509,9 +461,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
placed += 1
|
||||
n_assignments += 1
|
||||
|
||||
n_assignments += _add_demo_warnings(
|
||||
session, edition, shifts, reserved, assigned, availabilities
|
||||
)
|
||||
n_fee_classique, n_fee_mecenat = _add_demo_payments(session, volunteers, rng)
|
||||
session.flush()
|
||||
|
||||
fill: Counter = Counter()
|
||||
@@ -537,103 +487,49 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
position_wishes=n_position_wishes,
|
||||
artist_wishes=n_artist_wishes,
|
||||
companion_wishes=n_companion_wishes,
|
||||
fee_paid_classique=n_fee_classique,
|
||||
fee_paid_mecenat=n_fee_mecenat,
|
||||
)
|
||||
_verify(session, edition, summary)
|
||||
return summary
|
||||
|
||||
|
||||
def _clear_day(session, volunteer, day, availabilities) -> None:
|
||||
session.execute(
|
||||
delete(Availability)
|
||||
.where(Availability.volunteer_id == volunteer.id)
|
||||
.where(Availability.date == day)
|
||||
)
|
||||
availabilities[volunteer.id] = [
|
||||
w for w in availabilities[volunteer.id] if w.date != day
|
||||
]
|
||||
def _add_demo_payments(
|
||||
session: Session, volunteers: list[Volunteer], rng: random.Random
|
||||
) -> tuple[int, int]:
|
||||
"""Quelques bénévoles ayant payé leur cotisation via SumUp — voir
|
||||
CLAUDE.md § 2, Paiement de la cotisation. `fee_payment_label`/
|
||||
`fee_amount` sont une copie figée (comme en production) : aucun
|
||||
`FormPaymentOption` n'a besoin d'exister pour autant. Pour que l'export
|
||||
Excel et les reçus fiscaux mécénat (`/editions/{id}/volunteers/
|
||||
mecenat-receipts`) aient de quoi montrer quelque chose en démonstration."""
|
||||
pool = rng.sample(volunteers, k=12)
|
||||
classique, mecenat = pool[:8], pool[8:]
|
||||
|
||||
|
||||
def _declare_window(
|
||||
session, edition, volunteer, day, start, end, availabilities
|
||||
) -> None:
|
||||
session.add(
|
||||
Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=day,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
for volunteer in classique:
|
||||
volunteer.fee_payment_label = "Cotisation classique"
|
||||
volunteer.fee_amount = 15.0
|
||||
volunteer.fee_payment_status = FeePaymentStatus.PAID
|
||||
volunteer.fee_paid_at = dt.datetime.combine(
|
||||
START - dt.timedelta(days=rng.randint(5, 45)), dt.time(rng.randint(9, 21))
|
||||
)
|
||||
)
|
||||
availabilities[volunteer.id].append(AvailabilityWindow(day, start, end))
|
||||
session.add(volunteer)
|
||||
|
||||
|
||||
def _add_demo_warnings(
|
||||
session, edition, shifts, reserved, assigned, availabilities
|
||||
) -> int:
|
||||
"""Quelques affectations qui déclenchent des avertissements (et rien
|
||||
d'autre) : hors disponibilité, repos insuffisant. `reserved` = trois
|
||||
bénévoles tenus à l'écart de l'affectation générale.
|
||||
"""
|
||||
a, b, tired = reserved
|
||||
added = 0
|
||||
|
||||
# OUTSIDE_AVAILABILITY : deux bénévoles affectés un jour où ils n'ont
|
||||
# déclaré aucune disponibilité.
|
||||
plain = sorted(shifts, key=lambda s: s.starts_at)
|
||||
for volunteer, shift in ((a, plain[3]), (b, plain[7])):
|
||||
_clear_day(session, volunteer, shift.starts_at.date(), availabilities)
|
||||
_add_active(session, edition, volunteer, shift, assigned)
|
||||
added += 1
|
||||
|
||||
# INSUFFICIENT_REST : deux créneaux espacés de moins de 8 h, sur deux
|
||||
# journées (pour ne pas déclencher le cumul), tous deux couverts par une
|
||||
# disponibilité.
|
||||
pair = _find_rest_pair(shifts)
|
||||
if pair is not None:
|
||||
first, second = pair
|
||||
for shift in (first, second):
|
||||
_clear_day(session, tired, shift.starts_at.date(), availabilities)
|
||||
_declare_window(
|
||||
session,
|
||||
edition,
|
||||
tired,
|
||||
shift.starts_at.date(),
|
||||
dt.time(6, 0),
|
||||
dt.time(4, 0),
|
||||
availabilities,
|
||||
)
|
||||
_add_active(session, edition, tired, first, assigned)
|
||||
_add_active(session, edition, tired, second, assigned)
|
||||
added += 2
|
||||
|
||||
return added
|
||||
|
||||
|
||||
def _find_rest_pair(shifts: list[Shift]) -> tuple[Shift, Shift] | None:
|
||||
plain = sorted(shifts, key=lambda s: s.starts_at)
|
||||
for i, first in enumerate(plain):
|
||||
for second in plain[i + 1 :]:
|
||||
gap = second.starts_at - first.ends_at
|
||||
if (
|
||||
first.ends_at <= second.starts_at
|
||||
and dt.timedelta(0) < gap < dt.timedelta(hours=8)
|
||||
and first.starts_at.date() != second.starts_at.date()
|
||||
):
|
||||
return first, second
|
||||
return None
|
||||
|
||||
|
||||
def _add_active(session, edition, volunteer, shift, assigned) -> None:
|
||||
session.add(
|
||||
Assignment(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
shift_id=shift.id,
|
||||
source=AssignmentSource.MANUAL,
|
||||
for i, volunteer in enumerate(mecenat):
|
||||
address, postal_code, city = MECENAT_ADDRESSES[i % len(MECENAT_ADDRESSES)]
|
||||
volunteer.fee_payment_label = "Mécénat"
|
||||
volunteer.fee_amount = rng.choice([30.0, 50.0, 80.0, 120.0])
|
||||
volunteer.fee_payment_status = FeePaymentStatus.PAID
|
||||
volunteer.fee_paid_at = dt.datetime.combine(
|
||||
START - dt.timedelta(days=rng.randint(5, 45)), dt.time(rng.randint(9, 21))
|
||||
)
|
||||
)
|
||||
assigned[volunteer.id].append(shift)
|
||||
volunteer.mecenat_address = address
|
||||
volunteer.mecenat_postal_code = postal_code
|
||||
volunteer.mecenat_city = city
|
||||
volunteer.mecenat_country = "France"
|
||||
session.add(volunteer)
|
||||
|
||||
return len(classique), len(mecenat)
|
||||
|
||||
|
||||
def _verify(session: Session, edition: Edition, summary: Summary) -> None:
|
||||
@@ -655,8 +551,6 @@ def _verify(session: Session, edition: Edition, summary: Summary) -> None:
|
||||
):
|
||||
if violation.severity is Severity.BLOCKING:
|
||||
summary.blocking += 1
|
||||
else:
|
||||
summary.warnings[violation.code] += 1
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from app.sheets.pdf import (
|
||||
PositionDaySheet,
|
||||
SheetShift,
|
||||
SheetVolunteer,
|
||||
generate_position_sheets_pdf,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PositionDaySheet",
|
||||
"SheetShift",
|
||||
"SheetVolunteer",
|
||||
"generate_position_sheets_pdf",
|
||||
]
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Génération des feuilles de poste (PDF).
|
||||
|
||||
Module isolé, fonction pure (`generate_position_sheets_pdf`) : aucun accès
|
||||
base ici — voir `app/api/position_sheets.py` pour l'assemblage des données.
|
||||
|
||||
Une page par poste et par jour, listant les créneaux du jour avec les
|
||||
bénévoles affectés et leur téléphone — à imprimer et afficher sur place.
|
||||
Complément des badges (`app/badges/pdf.py`) : un badge identifie un
|
||||
bénévole, une feuille identifie un poste pour la journée.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
from PIL import Image as PILImage
|
||||
from PIL import ImageOps, UnidentifiedImageError
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.platypus import (
|
||||
Image,
|
||||
PageBreak,
|
||||
Paragraph,
|
||||
SimpleDocTemplate,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
)
|
||||
|
||||
# Mise en page compacte (une feuille = une page autant que possible) :
|
||||
# vignette de 10 mm, deux bénévoles par ligne.
|
||||
_PHOTO_BOX_MM = 10 * mm
|
||||
# Côté de la vignette en pixels : 10 mm à 300 dpi.
|
||||
_PHOTO_BOX_PX = round(10 / 25.4 * 300)
|
||||
_PER_ROW = 2
|
||||
|
||||
_WEEKDAYS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
|
||||
_MONTHS_FR = [
|
||||
"janvier", "février", "mars", "avril", "mai", "juin",
|
||||
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
|
||||
]
|
||||
|
||||
|
||||
def _format_day_fr(date: dt.date) -> str:
|
||||
return f"{_WEEKDAYS_FR[date.weekday()]} {date.day} {_MONTHS_FR[date.month - 1]} {date.year}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SheetVolunteer:
|
||||
name: str
|
||||
phone: str | None
|
||||
photo: bytes | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SheetArtist:
|
||||
name: str
|
||||
start_time: dt.time
|
||||
end_time: dt.time
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SheetShift:
|
||||
starts_at: dt.datetime
|
||||
ends_at: dt.datetime
|
||||
min_staff: int
|
||||
ideal_staff: int
|
||||
volunteers: tuple[SheetVolunteer, ...]
|
||||
|
||||
@property
|
||||
def understaffed(self) -> bool:
|
||||
return len(self.volunteers) < self.min_staff
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PositionDaySheet:
|
||||
"""Une page : un poste, un jour, ses créneaux triés chronologiquement."""
|
||||
|
||||
position_name: str
|
||||
position_location: str | None
|
||||
date: dt.date
|
||||
shifts: tuple[SheetShift, ...]
|
||||
# None si le poste n'a pas (encore) de référent désigné
|
||||
# (`Position.lead_account_id`).
|
||||
referent_name: str | None = None
|
||||
referent_phone: str | None = None
|
||||
# Programmation du jour (tous postes confondus, pas seulement les
|
||||
# artistes que les bénévoles affectés souhaitent voir) — repère utile sur
|
||||
# place (affluence au bar, sécurité...), triée par heure de passage.
|
||||
artists: tuple[SheetArtist, ...] = ()
|
||||
|
||||
|
||||
def _styles() -> dict[str, ParagraphStyle]:
|
||||
base = getSampleStyleSheet()
|
||||
return {
|
||||
"title": ParagraphStyle(
|
||||
"SheetTitle", parent=base["Title"], alignment=0,
|
||||
fontSize=18, leading=21, spaceBefore=0, spaceAfter=1,
|
||||
),
|
||||
"subtitle": ParagraphStyle(
|
||||
"SheetSubtitle", parent=base["Normal"],
|
||||
fontSize=10.5, leading=13, textColor=colors.grey, spaceAfter=2,
|
||||
),
|
||||
"referent": ParagraphStyle(
|
||||
"SheetReferent", parent=base["Normal"],
|
||||
fontSize=10.5, leading=13, fontName="Helvetica-Bold", spaceAfter=3,
|
||||
),
|
||||
"artists": ParagraphStyle(
|
||||
"SheetArtists", parent=base["Normal"],
|
||||
fontSize=8.5, leading=10.5, textColor=colors.HexColor("#4a4a4a"), spaceAfter=4,
|
||||
),
|
||||
"shiftHeader": ParagraphStyle(
|
||||
"ShiftHeader", parent=base["Heading3"],
|
||||
fontSize=11, leading=13, spaceBefore=7, spaceAfter=3,
|
||||
),
|
||||
"shiftHeaderWarning": ParagraphStyle(
|
||||
"ShiftHeaderWarning", parent=base["Heading3"],
|
||||
fontSize=11, leading=13, spaceBefore=7, spaceAfter=3,
|
||||
textColor=colors.HexColor("#b23a1f"),
|
||||
),
|
||||
"volunteerName": ParagraphStyle(
|
||||
"SheetVolunteerName", parent=base["Normal"],
|
||||
fontName="Helvetica-Bold", fontSize=9.5, leading=11.5,
|
||||
),
|
||||
"volunteerPhone": ParagraphStyle(
|
||||
"SheetVolunteerPhone", parent=base["Normal"], fontSize=9, leading=11,
|
||||
),
|
||||
"footerTitle": ParagraphStyle(
|
||||
"SheetFooterTitle", parent=base["Normal"],
|
||||
fontName="Helvetica-Bold", fontSize=8.5, leading=11,
|
||||
),
|
||||
"footer": ParagraphStyle(
|
||||
"SheetFooter", parent=base["Normal"], fontSize=8.5, leading=11,
|
||||
),
|
||||
"normal": base["Normal"],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SheetFooter:
|
||||
"""Message imprimé en bas de chaque page (message de l'organisation,
|
||||
`Edition.volunteer_message`) : `title` en gras, puis `message` —
|
||||
retours à la ligne conservés."""
|
||||
|
||||
title: str
|
||||
message: str
|
||||
|
||||
|
||||
_MARGIN_X = 15 * mm
|
||||
_MARGIN_TOP = 12 * mm
|
||||
_MARGIN_BOTTOM = 12 * mm
|
||||
# Écart entre le bas du contenu et le filet du pied de page.
|
||||
_FOOTER_GAP = 5 * mm
|
||||
|
||||
|
||||
def _footer_paragraphs(footer: SheetFooter, styles: dict) -> list[Paragraph]:
|
||||
message = escape(footer.message).replace("\n", "<br/>")
|
||||
return [
|
||||
Paragraph(escape(footer.title), styles["footerTitle"]),
|
||||
Paragraph(message, styles["footer"]),
|
||||
]
|
||||
|
||||
|
||||
def _thumbnail(photo: bytes) -> tuple[bytes, int, int] | None:
|
||||
"""Photo réduite à la taille de la vignette (orientation EXIF appliquée),
|
||||
en JPEG : `(octets, largeur, hauteur)`, `None` si illisible. La photo
|
||||
d'origine (souvent plusieurs Mo, prise au téléphone) intégrée telle
|
||||
quelle pour chaque bénévole de chaque créneau faisait tourner la
|
||||
génération plusieurs minutes et produisait un PDF énorme — jusqu'à
|
||||
l'erreur (bug corrigé une fois)."""
|
||||
try:
|
||||
with PILImage.open(io.BytesIO(photo)) as img:
|
||||
# JPEG : décodage directement à taille réduite (bien plus rapide).
|
||||
img.draft("RGB", (_PHOTO_BOX_PX, _PHOTO_BOX_PX))
|
||||
thumb = ImageOps.exif_transpose(img).convert("RGB")
|
||||
thumb.thumbnail((_PHOTO_BOX_PX, _PHOTO_BOX_PX))
|
||||
except (UnidentifiedImageError, OSError, ValueError):
|
||||
return None
|
||||
out = io.BytesIO()
|
||||
thumb.save(out, format="JPEG", quality=85)
|
||||
return out.getvalue(), thumb.width, thumb.height
|
||||
|
||||
|
||||
def _photo_cell(photo: bytes | None, cache: dict[bytes, tuple[bytes, int, int] | None]):
|
||||
"""Vignette carrée (14 mm de côté max), proportions préservées. Chaque
|
||||
photo n'est réduite qu'une fois par PDF (`cache`), même si le bénévole
|
||||
figure sur plusieurs créneaux."""
|
||||
if not photo:
|
||||
return ""
|
||||
if photo not in cache:
|
||||
cache[photo] = _thumbnail(photo)
|
||||
thumb = cache[photo]
|
||||
if thumb is None:
|
||||
return ""
|
||||
data, width_px, height_px = thumb
|
||||
scale = _PHOTO_BOX_MM / max(width_px, height_px)
|
||||
return Image(io.BytesIO(data), width=width_px * scale, height=height_px * scale)
|
||||
|
||||
|
||||
def _shift_table(
|
||||
shift: SheetShift,
|
||||
cache: dict[bytes, tuple[bytes, int, int] | None],
|
||||
styles: dict[str, ParagraphStyle],
|
||||
width: float,
|
||||
) -> Table:
|
||||
"""Bénévoles du créneau, deux par ligne : vignette, puis nom (gras) et
|
||||
téléphone l'un sous l'autre. Deux fois moins de lignes qu'une liste à
|
||||
une personne par ligne (avant l'étape 65, souvent deux pages par
|
||||
feuille)."""
|
||||
photo_w = _PHOTO_BOX_MM + 3 * mm
|
||||
text_w = width / _PER_ROW - photo_w
|
||||
cells = [
|
||||
[
|
||||
_photo_cell(v.photo, cache),
|
||||
[
|
||||
Paragraph(escape(v.name), styles["volunteerName"]),
|
||||
Paragraph(escape(v.phone or "—"), styles["volunteerPhone"]),
|
||||
],
|
||||
]
|
||||
for v in shift.volunteers
|
||||
]
|
||||
rows = []
|
||||
for i in range(0, len(cells), _PER_ROW):
|
||||
chunk = cells[i : i + _PER_ROW]
|
||||
chunk += [["", ""]] * (_PER_ROW - len(chunk))
|
||||
rows.append([cell for pair in chunk for cell in pair])
|
||||
table = Table(rows, colWidths=[photo_w, text_w] * _PER_ROW)
|
||||
style = [
|
||||
("TOPPADDING", (0, 0), (-1, -1), 2),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 2),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 3),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 3),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("LINEBELOW", (0, 0), (-1, -1), 0.25, colors.lightgrey),
|
||||
("LINEABOVE", (0, 0), (-1, 0), 0.25, colors.lightgrey),
|
||||
]
|
||||
# Filet vertical entre les deux bénévoles d'une même ligne.
|
||||
for col in range(2, 2 * _PER_ROW, 2):
|
||||
style.append(("LINEBEFORE", (col, 0), (col, -1), 0.25, colors.lightgrey))
|
||||
table.setStyle(TableStyle(style))
|
||||
return table
|
||||
|
||||
|
||||
def generate_position_sheets_pdf(
|
||||
sheets: list[PositionDaySheet], footer: SheetFooter | None = None
|
||||
) -> bytes:
|
||||
"""`footer` : imprimé en bas de **chaque** page (y compris la suite
|
||||
d'une feuille sur une seconde page) ; la marge du bas s'agrandit de sa
|
||||
hauteur pour que le contenu ne passe jamais dessous."""
|
||||
styles = _styles()
|
||||
buffer = io.BytesIO()
|
||||
width = A4[0] - 2 * _MARGIN_X
|
||||
paragraphs = _footer_paragraphs(footer, styles) if footer else []
|
||||
footer_height = sum(p.wrap(width, A4[1])[1] for p in paragraphs)
|
||||
doc = SimpleDocTemplate(
|
||||
buffer, pagesize=A4,
|
||||
leftMargin=_MARGIN_X, rightMargin=_MARGIN_X, topMargin=_MARGIN_TOP,
|
||||
bottomMargin=_MARGIN_BOTTOM + (footer_height + _FOOTER_GAP if paragraphs else 0),
|
||||
)
|
||||
|
||||
def draw_footer(canvas, _doc) -> None:
|
||||
if not paragraphs:
|
||||
return
|
||||
canvas.saveState()
|
||||
top = _MARGIN_BOTTOM + footer_height
|
||||
canvas.setStrokeColor(colors.black)
|
||||
canvas.setLineWidth(0.75)
|
||||
canvas.line(_MARGIN_X, top + 2 * mm, _MARGIN_X + width, top + 2 * mm)
|
||||
y = top
|
||||
for p in paragraphs:
|
||||
_, h = p.wrap(width, A4[1])
|
||||
y -= h
|
||||
p.drawOn(canvas, _MARGIN_X, y)
|
||||
canvas.restoreState()
|
||||
|
||||
story = []
|
||||
thumbnails: dict[bytes, tuple[bytes, int, int] | None] = {}
|
||||
for i, sheet in enumerate(sheets):
|
||||
if i > 0:
|
||||
story.append(PageBreak())
|
||||
story.append(Paragraph(sheet.position_name, styles["title"]))
|
||||
subtitle = _format_day_fr(sheet.date)
|
||||
if sheet.position_location:
|
||||
subtitle += f" — {sheet.position_location}"
|
||||
story.append(Paragraph(subtitle, styles["subtitle"]))
|
||||
|
||||
if sheet.referent_name:
|
||||
referent_line = f"Référent : {sheet.referent_name}"
|
||||
if sheet.referent_phone:
|
||||
referent_line += f" — {sheet.referent_phone}"
|
||||
story.append(Paragraph(referent_line, styles["referent"]))
|
||||
else:
|
||||
story.append(Paragraph("Référent : à désigner", styles["referent"]))
|
||||
|
||||
if sheet.artists:
|
||||
programme = " · ".join(
|
||||
f"{a.start_time:%H:%M}–{a.end_time:%H:%M} {a.name}" for a in sheet.artists
|
||||
)
|
||||
story.append(Paragraph(f"Programmation : {programme}", styles["artists"]))
|
||||
|
||||
if not sheet.shifts:
|
||||
story.append(Paragraph("Aucun créneau ce jour-là.", styles["normal"]))
|
||||
continue
|
||||
|
||||
for shift in sheet.shifts:
|
||||
header = (
|
||||
f"{shift.starts_at:%H:%M} – {shift.ends_at:%H:%M}"
|
||||
f" ({len(shift.volunteers)}/{shift.ideal_staff})"
|
||||
)
|
||||
if shift.understaffed:
|
||||
header += " ⚠ sous l'effectif minimal"
|
||||
style = styles["shiftHeaderWarning"] if shift.understaffed else styles["shiftHeader"]
|
||||
story.append(Paragraph(header, style))
|
||||
if shift.volunteers:
|
||||
story.append(_shift_table(shift, thumbnails, styles, width))
|
||||
else:
|
||||
story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"]))
|
||||
story.append(Spacer(1, 0))
|
||||
|
||||
doc.build(story, onFirstPage=draw_footer, onLaterPages=draw_footer)
|
||||
return buffer.getvalue()
|
||||
@@ -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,7 @@ from sqlmodel import Session, select
|
||||
from app.models import (
|
||||
Artist,
|
||||
Assignment,
|
||||
Availability,
|
||||
DayPart,
|
||||
Edition,
|
||||
Shift,
|
||||
Volunteer,
|
||||
@@ -18,7 +18,8 @@ from app.models import (
|
||||
VolunteerPositionWish,
|
||||
)
|
||||
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
|
||||
from app.validation.models import AvailabilityWindow, ShiftInfo
|
||||
from app.validation.engine import day_boundary, festival_day
|
||||
from app.validation.models import ShiftInfo
|
||||
|
||||
|
||||
def _artist_window(artist: Artist) -> ShiftInfo:
|
||||
@@ -41,6 +42,11 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
||||
)
|
||||
shifts_by_id = {s.id: s for s in shifts}
|
||||
|
||||
# « Même jour » = même journée de festival (CLAUDE.md § 3) : 00h–01h
|
||||
# enchaîne bien 23h–00h de la veille au calendrier.
|
||||
boundary = day_boundary(
|
||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||
)
|
||||
# Prédécesseur(s) : créneaux (n'importe quel poste) du même jour dont la
|
||||
# fin coïncide avec le début de celui-ci — sert au malus de chaînage
|
||||
# (voir `engine._score`), pour limiter les enchaînements de shifts plutôt
|
||||
@@ -49,7 +55,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
||||
predecessor_ids: dict = {}
|
||||
by_day: dict = defaultdict(list)
|
||||
for s in shifts:
|
||||
by_day[s.starts_at.date()].append(s)
|
||||
by_day[festival_day(s.starts_at, boundary)].append(s)
|
||||
for day_shifts in by_day.values():
|
||||
ids_by_end: dict = defaultdict(list)
|
||||
for s in day_shifts:
|
||||
@@ -86,14 +92,6 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
||||
if assigned_count.get(s.id, 0) < s.ideal_staff
|
||||
)
|
||||
|
||||
availabilities: dict = defaultdict(list)
|
||||
for a in session.exec(
|
||||
select(Availability).where(Availability.edition_id == edition.id)
|
||||
):
|
||||
availabilities[a.volunteer_id].append(
|
||||
AvailabilityWindow(date=a.date, start_time=a.start_time, end_time=a.end_time)
|
||||
)
|
||||
|
||||
wishes: dict = defaultdict(set)
|
||||
for w in session.exec(
|
||||
select(VolunteerPositionWish).join(
|
||||
@@ -134,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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.sumup.client import SumUpCheckout, create_checkout, get_checkout
|
||||
|
||||
__all__ = ["SumUpCheckout", "create_checkout", "get_checkout"]
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Client de l'API SumUp — paiement de la cotisation (voir CLAUDE.md § 2,
|
||||
`FormTemplate`, et `app/api/forms.py`).
|
||||
|
||||
`create_checkout`/`get_checkout` sont les points d'injection uniques pour les
|
||||
tests (remplacés comme `app.forms.captcha.verify_hcaptcha`). Best effort,
|
||||
comme l'envoi d'emails : toute erreur réseau/HTTP est journalisée (code/
|
||||
classe d'exception seulement, jamais de PII) et renvoyée comme `None` —
|
||||
l'inscription au formulaire public n'échoue jamais pour autant.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("app.sumup")
|
||||
|
||||
_BASE_URL = "https://api.sumup.com"
|
||||
_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SumUpCheckout:
|
||||
id: str
|
||||
status: str # PENDING / PAID / FAILED / EXPIRED (voir FeePaymentStatus)
|
||||
hosted_checkout_url: str | None
|
||||
# Transaction qui a réglé le checkout (renseignés par SumUp une fois
|
||||
# payé, `None` avant) : `transaction_code` est le code court affiché dans
|
||||
# le tableau de bord SumUp et sur ses reçus (ex. `TEENSK4W2K`) — c'est
|
||||
# lui que la coordination recherche pour rapprocher un paiement d'un
|
||||
# bénévole ; `transaction_id` est l'identifiant technique (API).
|
||||
transaction_code: str | None = None
|
||||
transaction_id: str | None = None
|
||||
|
||||
|
||||
def _call(method: str, path: str, api_key: str, json: dict | None = None) -> dict | None:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
resp = httpx.request(
|
||||
method,
|
||||
f"{_BASE_URL}{path}",
|
||||
json=json,
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
# Le corps d'erreur SumUp (`error_code`/`message`, voir leur doc) dit
|
||||
# pourquoi l'appel est refusé (identifiants invalides, montant,
|
||||
# référence déjà utilisée…) — il ne contient que des codes, pas de
|
||||
# données du payeur.
|
||||
logger.warning(
|
||||
"Échec de l'appel API SumUp (%s %s) : HTTP %d %s",
|
||||
method,
|
||||
path,
|
||||
exc.response.status_code,
|
||||
_error_summary(exc.response),
|
||||
)
|
||||
return None
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
logger.warning(
|
||||
"Échec de l'appel API SumUp (%s %s) : %s", method, path, exc.__class__.__name__
|
||||
)
|
||||
return None
|
||||
logger.debug(
|
||||
"API SumUp %s %s : OK en %d ms",
|
||||
method,
|
||||
path,
|
||||
(time.monotonic() - started) * 1000,
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def _error_summary(resp: httpx.Response) -> str:
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
return ""
|
||||
if isinstance(body, list): # SumUp renvoie parfois une liste d'erreurs
|
||||
body = body[0] if body else {}
|
||||
if not isinstance(body, dict):
|
||||
return ""
|
||||
return " ".join(
|
||||
str(body[key])[:200] for key in ("error_code", "message", "param") if body.get(key)
|
||||
)
|
||||
|
||||
|
||||
def _to_checkout(data: dict) -> SumUpCheckout:
|
||||
transaction_code = data.get("transaction_code")
|
||||
transaction_id = data.get("transaction_id")
|
||||
if not transaction_code:
|
||||
# Selon la version de l'API, seuls les essais de paiement
|
||||
# (`transactions`) portent le code : on retient celui qui a abouti.
|
||||
successful = next(
|
||||
(
|
||||
tx
|
||||
for tx in data.get("transactions") or []
|
||||
if isinstance(tx, dict) and tx.get("status") == "SUCCESSFUL"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if successful is not None:
|
||||
transaction_code = successful.get("transaction_code")
|
||||
transaction_id = transaction_id or successful.get("id")
|
||||
return SumUpCheckout(
|
||||
id=data["id"],
|
||||
status=data["status"],
|
||||
hosted_checkout_url=data.get("hosted_checkout_url"),
|
||||
transaction_code=transaction_code,
|
||||
transaction_id=transaction_id,
|
||||
)
|
||||
|
||||
|
||||
def create_checkout(
|
||||
*,
|
||||
api_key: str,
|
||||
merchant_code: str,
|
||||
amount: float,
|
||||
currency: str,
|
||||
checkout_reference: str,
|
||||
description: str,
|
||||
return_url: str,
|
||||
redirect_url: str,
|
||||
) -> SumUpCheckout | None:
|
||||
"""Crée un checkout hébergé (`hosted_checkout.enabled=true`) : la page de
|
||||
paiement SumUp est à `hosted_checkout_url`. `None` en cas d'échec (réseau,
|
||||
identifiants invalides…).
|
||||
|
||||
L'API de création n'accepte pas de métadonnées libres : `description`
|
||||
(« affichée dans les outils SumUp ») et `checkout_reference` sont les
|
||||
seuls champs du marchand repris sur la transaction — c'est donc par eux
|
||||
que passe le nom du bénévole (voir `app/api/forms.py:
|
||||
_checkout_description`)."""
|
||||
data = _call(
|
||||
"POST",
|
||||
"/v0.1/checkouts",
|
||||
api_key,
|
||||
json={
|
||||
"checkout_reference": checkout_reference,
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
"merchant_code": merchant_code,
|
||||
"description": description,
|
||||
"return_url": return_url,
|
||||
"redirect_url": redirect_url,
|
||||
"hosted_checkout": {"enabled": True},
|
||||
},
|
||||
)
|
||||
return _to_checkout(data) if data is not None else None
|
||||
|
||||
|
||||
def get_checkout(*, api_key: str, checkout_id: str) -> SumUpCheckout | None:
|
||||
"""Relit l'état réel d'un checkout — un webhook SumUp ne donne qu'un
|
||||
identifiant, jamais un statut de confiance en lui-même (voir la doc
|
||||
SumUp sur les webhooks et `app/api/forms.py:sumup_webhook`)."""
|
||||
data = _call("GET", f"/v0.1/checkouts/{checkout_id}", api_key)
|
||||
return _to_checkout(data) if data is not None else None
|
||||
@@ -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,115 +49,60 @@ def _check_overlap(data: ValidationInput) -> list[Violation]:
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# OUTSIDE_AVAILABILITY (avertissement) #
|
||||
# « Journée de festival » — plus utilisée par la validation elle-même, mais #
|
||||
# partagée par le solveur, les feuilles de poste et les statistiques. #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _window_interval(w: AvailabilityWindow) -> tuple[dt.datetime, dt.datetime]:
|
||||
start = dt.datetime.combine(w.date, w.start_time)
|
||||
end_day = w.date if w.end_time > w.start_time else w.date + dt.timedelta(days=1)
|
||||
return start, dt.datetime.combine(end_day, w.end_time)
|
||||
_MINUTES_PER_DAY = 24 * 60
|
||||
|
||||
|
||||
def uncovered_minutes(
|
||||
shift: ShiftInfo, windows: tuple[AvailabilityWindow, ...]
|
||||
) -> int:
|
||||
"""Minutes du créneau qui ne tombent dans aucune fenêtre de disponibilité.
|
||||
|
||||
Le bénévole peut déclarer plusieurs fenêtres ; on regarde leur union.
|
||||
"""
|
||||
cursor = shift.starts_at
|
||||
intervals = sorted(
|
||||
(iv for iv in map(_window_interval, windows) if iv[1] > iv[0]),
|
||||
key=lambda iv: iv[0],
|
||||
)
|
||||
gap = dt.timedelta()
|
||||
for start, end in intervals:
|
||||
if end <= cursor:
|
||||
continue
|
||||
if start > shift.ends_at:
|
||||
break
|
||||
if start > cursor:
|
||||
gap += min(start, shift.ends_at) - cursor
|
||||
cursor = max(cursor, end)
|
||||
if cursor >= shift.ends_at:
|
||||
return int(gap.total_seconds() // 60)
|
||||
if cursor < shift.ends_at:
|
||||
gap += shift.ends_at - cursor
|
||||
return int(gap.total_seconds() // 60)
|
||||
|
||||
|
||||
def _check_outside_availability(data: ValidationInput) -> list[Violation]:
|
||||
missing = uncovered_minutes(data.candidate_shift, data.availabilities)
|
||||
if missing == 0:
|
||||
return []
|
||||
return [
|
||||
Violation.of(
|
||||
ViolationCode.OUTSIDE_AVAILABILITY,
|
||||
date=data.candidate_shift.starts_at.date().isoformat(),
|
||||
uncovered_minutes=missing,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# DAILY_HOURS_EXCEEDED (avertissement) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _day_boundary(day_parts: tuple) -> dt.time:
|
||||
def day_boundary(day_parts) -> dt.time:
|
||||
"""Début d'une « journée de festival » : début du plus long creux que
|
||||
laissent les créneaux types (`DayPart`) sur le cadran de 24 h — l'heure à
|
||||
laquelle le dernier de la journée se termine —, minuit à défaut — voir
|
||||
CLAUDE.md § 3. Avec Matin 08 h–13 h, Après-midi 13 h–19 h, Soirée
|
||||
19 h–02 h, le creux 02 h–08 h est le plus long : la journée
|
||||
commence à 02 h, la soirée qui passe minuit reste sur le jour où elle a
|
||||
commencé. Indépendant de `order`, qui n'est qu'un ordre d'affichage et
|
||||
ne suit pas les heures quand on les modifie (un créneau type d'ordre 0
|
||||
déplacé à 21 h faisait commencer chaque journée à 21 h). Sans creux (les
|
||||
créneaux types couvrent 24 h), début du premier par `order`. Accepte
|
||||
aussi bien les `DayPartInfo` de ce module que les lignes `DayPart`.
|
||||
Miroir front : `lib/planning.ts:dayBoundaryFromDayParts`."""
|
||||
day_parts = list(day_parts)
|
||||
if not day_parts:
|
||||
return dt.time(0, 0)
|
||||
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 _day_key(moment: dt.datetime, boundary: dt.time) -> dt.date:
|
||||
def festival_day(moment: dt.datetime, boundary: dt.time) -> dt.date:
|
||||
"""Journée de festival d'un instant : avant `boundary`, c'est encore la
|
||||
nuit de la veille (ex. 01:00 le dimanche ⇒ samedi). Partagé avec le
|
||||
solveur et les feuilles de poste, pour qu'une soirée qui passe minuit
|
||||
reste d'un seul tenant partout."""
|
||||
if moment.time() >= boundary:
|
||||
return moment.date()
|
||||
return moment.date() - dt.timedelta(days=1)
|
||||
|
||||
|
||||
def _check_daily_hours(data: ValidationInput) -> list[Violation]:
|
||||
boundary = _day_boundary(data.day_parts)
|
||||
candidate = data.candidate_shift
|
||||
key = _day_key(candidate.starts_at, boundary)
|
||||
|
||||
total = candidate.ends_at - candidate.starts_at
|
||||
for other in data.existing_shifts:
|
||||
if _day_key(other.starts_at, boundary) == key:
|
||||
total += other.ends_at - other.starts_at
|
||||
|
||||
if total > _EIGHT_HOURS:
|
||||
return [
|
||||
Violation.of(
|
||||
ViolationCode.DAILY_HOURS_EXCEEDED,
|
||||
date=key.isoformat(),
|
||||
total_minutes=int(total.total_seconds() // 60),
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# INSUFFICIENT_REST (avertissement) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _check_insufficient_rest(data: ValidationInput) -> list[Violation]:
|
||||
candidate = data.candidate_shift
|
||||
out: list[Violation] = []
|
||||
for other in data.existing_shifts:
|
||||
if _overlaps(candidate, other):
|
||||
continue
|
||||
if other.ends_at <= candidate.starts_at:
|
||||
gap = candidate.starts_at - other.ends_at
|
||||
direction = "BEFORE"
|
||||
elif candidate.ends_at <= other.starts_at:
|
||||
gap = other.starts_at - candidate.ends_at
|
||||
direction = "AFTER"
|
||||
else: # pragma: no cover - inatteignable : non-chevauchement ⇒ avant ou après
|
||||
continue
|
||||
if gap < _EIGHT_HOURS:
|
||||
out.append(
|
||||
Violation.of(
|
||||
ViolationCode.INSUFFICIENT_REST,
|
||||
other_shift_id=other.id,
|
||||
direction=direction,
|
||||
gap_minutes=int(gap.total_seconds() // 60),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
@@ -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, ...] = ()
|
||||
|
||||
@@ -12,16 +12,23 @@ dependencies = [
|
||||
"pydantic-settings>=2.6",
|
||||
"python-multipart>=0.0.12",
|
||||
"openpyxl>=3.1",
|
||||
"pyjwt>=2.9",
|
||||
# >=2.15 : aucune des CVE corrigées (toutes autour de PyJWKClient/JWKS,
|
||||
# jamais utilisés ici — seul HS256 à secret symétrique statique est en
|
||||
# jeu, voir app/auth/tokens.py) n'est exploitable dans cette appli, mais
|
||||
# autant ne pas rester sur une version listée vulnérable sans raison.
|
||||
"pyjwt>=2.15",
|
||||
"bcrypt>=4.1",
|
||||
"ortools>=9.11",
|
||||
"reportlab>=4.2",
|
||||
"pypdf>=5.1",
|
||||
"pillow>=11.0",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.3",
|
||||
"pytest-cov>=6.0",
|
||||
"httpx>=0.27",
|
||||
"ruff>=0.8",
|
||||
]
|
||||
|
||||
|
||||
@@ -7,7 +7,12 @@ from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata)
|
||||
from app.auth.deps import get_current_account
|
||||
from app.auth.rate_limit import login_limiter, magic_link_limiter
|
||||
from app.auth.rate_limit import (
|
||||
form_submission_limiter,
|
||||
login_limiter,
|
||||
magic_link_limiter,
|
||||
pending_status_limiter,
|
||||
)
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.main import app
|
||||
@@ -31,6 +36,8 @@ def _reset_rate_limiters():
|
||||
déclenche un ne doit pas polluer les suivants."""
|
||||
login_limiter.reset()
|
||||
magic_link_limiter.reset()
|
||||
form_submission_limiter.reset()
|
||||
pending_status_limiter.reset()
|
||||
yield
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Aide de test : les messages capturés par `outbox` (conftest.py) sont
|
||||
`multipart/alternative` depuis l'ajout d'une version HTML (voir
|
||||
`app/email/sender.py:build_message`) — `.get_content()` ne suffit plus à lui
|
||||
seul, il faut préciser quelle partie lire."""
|
||||
|
||||
from email.message import EmailMessage
|
||||
|
||||
|
||||
def plain_text(message: EmailMessage) -> str:
|
||||
return message.get_body(preferencelist=("plain",)).get_content()
|
||||
@@ -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
|
||||
@@ -2,6 +2,7 @@ from sqlmodel import select
|
||||
|
||||
from app.models import Assignment, MagicLinkToken, Role
|
||||
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
||||
from tests.mail import plain_text
|
||||
|
||||
|
||||
def _url(edition):
|
||||
@@ -38,17 +39,17 @@ def test_one_email_per_volunteer_with_an_address(client, edition, session, outbo
|
||||
|
||||
by_to = {m["To"]: m for m in outbox}
|
||||
assert by_to["ana@e.fr"]["Subject"] == "Planning de Ana"
|
||||
ana_body = by_to["ana@e.fr"].get_content()
|
||||
ana_body = plain_text(by_to["ana@e.fr"])
|
||||
assert "Tu as 2 créneau(x), 7.0 h." in ana_body
|
||||
assert "Bar" in ana_body
|
||||
assert "Tu as 0 créneau(x), 0.0 h." in by_to["bob@e.fr"].get_content()
|
||||
assert "Tu as 0 créneau(x), 0.0 h." in plain_text(by_to["bob@e.fr"])
|
||||
|
||||
|
||||
def test_default_template_is_used_when_body_omitted(client, edition, session, outbox):
|
||||
make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
||||
resp = client.post(_url(edition), json={})
|
||||
assert resp.status_code == 200
|
||||
assert outbox[0].get_content().startswith("Bonjour Ana,")
|
||||
assert plain_text(outbox[0]).startswith("Bonjour Ana,")
|
||||
|
||||
|
||||
def test_magic_link_is_embedded_when_placeholder_present(
|
||||
@@ -61,8 +62,9 @@ def test_magic_link_is_embedded_when_placeholder_present(
|
||||
_url(edition), json={"body": "Connecte-toi : {lien_connexion}"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "/connexion?token=" in outbox[0].get_content()
|
||||
assert len(session.exec(select(MagicLinkToken)).all()) == 1
|
||||
assert "/connexion?token=pl." in plain_text(outbox[0])
|
||||
# Lien permanent : rien de stocké, pas de jeton à usage unique.
|
||||
assert session.exec(select(MagicLinkToken)).all() == []
|
||||
|
||||
|
||||
def test_no_magic_link_token_without_placeholder(
|
||||
@@ -90,3 +92,111 @@ def test_planning_notification_is_coordinator_only(
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
resp = as_account(lead).post(_url(edition), json={})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_shifts_placeholder_is_a_table_by_day_with_the_referent(
|
||||
client, edition, session, outbox, make_account
|
||||
):
|
||||
rita = make_volunteer(session, edition, first_name="Rita", last_name="Ref", email="r@e.fr")
|
||||
rita.phone = "06 11 22 33 44"
|
||||
session.add(rita)
|
||||
lead = make_account(Role.LEAD, volunteer_id=rita.id)
|
||||
bar = make_position(session, edition, name="Bar central")
|
||||
bar.lead_account_id = lead.id
|
||||
session.add(bar)
|
||||
parking = make_position(session, edition, name="Parking")
|
||||
s1 = make_shift(session, edition, bar, start=dt_at(10, 18), end=dt_at(10, 22))
|
||||
s2 = make_shift(session, edition, parking, start=dt_at(11, 9), end=dt_at(11, 12))
|
||||
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
||||
session.add_all(
|
||||
[
|
||||
Assignment(edition_id=edition.id, volunteer_id=ana.id, shift_id=s1.id),
|
||||
Assignment(edition_id=edition.id, volunteer_id=ana.id, shift_id=s2.id),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
client.post(
|
||||
_url(edition), json={"volunteer_ids": [str(ana.id)], "body": "Ton planning :\n\n{creneaux}"}
|
||||
)
|
||||
(message,) = outbox
|
||||
html = message.get_body(preferencelist=("html",)).get_content()
|
||||
# Vrai tableau : en-têtes, une ligne de jour, horaire, poste, référent.
|
||||
assert "<table" in html and "Référent" in html
|
||||
assert "18:00–22:00" in html and "Bar central" in html
|
||||
assert "Rita Ref · 06 11 22 33 44" in html
|
||||
assert "à désigner" in html # Parking n'a pas de référent
|
||||
assert "" not in html
|
||||
text = plain_text(message)
|
||||
assert "18:00–22:00 · Bar central · référent : Rita Ref · 06 11 22 33 44" in text
|
||||
assert "09:00–12:00 · Parking · référent : à désigner" in text
|
||||
|
||||
|
||||
|
||||
def _link(message) -> str:
|
||||
import re
|
||||
|
||||
return re.search(r"token=(\S+)", plain_text(message)).group(1)
|
||||
|
||||
|
||||
def _verify(client, edition, token):
|
||||
return client.post(f"/editions/{edition.id}/auth/verify", json={"token": token})
|
||||
|
||||
|
||||
def test_login_link_is_permanent_and_reusable(client, edition, session, make_account, outbox):
|
||||
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
||||
make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="ana@e.fr")
|
||||
client.post(_url(edition), json={"body": "{lien_connexion}"})
|
||||
client.post(_url(edition), json={"body": "{lien_connexion}"})
|
||||
first, second = (_link(m) for m in outbox)
|
||||
# Le même lien dans chaque email, utilisable plusieurs fois.
|
||||
assert first == second
|
||||
for _ in range(3):
|
||||
resp = _verify(client, edition, first)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["account"]["volunteer_id"] == str(ana.id)
|
||||
|
||||
|
||||
def test_reset_revokes_the_links_already_sent(client, edition, session, make_account, outbox):
|
||||
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
||||
account = make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="ana@e.fr")
|
||||
client.post(_url(edition), json={"body": "{lien_connexion}"})
|
||||
old = _link(outbox[0])
|
||||
|
||||
resp = client.post(f"/editions/{edition.id}/accounts/{account.id}/reset-login-link")
|
||||
assert resp.status_code == 200
|
||||
assert _verify(client, edition, old).status_code == 401
|
||||
|
||||
client.post(_url(edition), json={"body": "{lien_connexion}"})
|
||||
new = _link(outbox[1])
|
||||
assert new != old
|
||||
assert _verify(client, edition, new).status_code == 200
|
||||
|
||||
|
||||
def test_sending_the_link_gives_an_account_to_a_volunteer_without_one(
|
||||
client, edition, session, outbox
|
||||
):
|
||||
from app.models import Account
|
||||
|
||||
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
||||
client.post(_url(edition), json={"body": "{lien_connexion}"})
|
||||
(account,) = session.exec(select(Account).where(Account.volunteer_id == ana.id)).all()
|
||||
assert account.role == Role.VOLUNTEER
|
||||
assert _verify(client, edition, _link(outbox[0])).status_code == 200
|
||||
|
||||
|
||||
def test_shared_address_gets_the_login_page_only(client, edition, session, make_account, outbox):
|
||||
# Couple sur la même adresse : un seul compte possible pour cet email.
|
||||
ana = make_volunteer(session, edition, first_name="Ana", email="foyer@e.fr")
|
||||
make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="foyer@e.fr")
|
||||
bob = make_volunteer(session, edition, first_name="Bob", email="foyer@e.fr")
|
||||
client.post(_url(edition), json={"volunteer_ids": [str(bob.id)], "body": "{lien_connexion}"})
|
||||
assert "token=" not in plain_text(outbox[0])
|
||||
|
||||
|
||||
def test_forged_or_coordinator_links_are_refused(client, edition, coordinator):
|
||||
from app.auth.permanent_link import permanent_login_token
|
||||
|
||||
assert _verify(client, edition, permanent_login_token(coordinator)).status_code == 401
|
||||
assert _verify(client, edition, "pl.nimportequoi.abc").status_code == 401
|
||||
assert _verify(client, edition, f"pl.{coordinator.id.hex}.faux").status_code == 401
|
||||
|
||||
@@ -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)
|
||||
@@ -1,13 +1,15 @@
|
||||
import datetime as dt
|
||||
import io
|
||||
import uuid
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from pypdf import PdfReader
|
||||
from sqlmodel import select
|
||||
|
||||
from app.models import Assignment, Availability, 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,
|
||||
@@ -205,14 +207,6 @@ def test_delete_is_a_full_cascade(client, edition, session):
|
||||
shift = make_shift(
|
||||
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
|
||||
)
|
||||
make_availability(
|
||||
session,
|
||||
edition,
|
||||
volunteer,
|
||||
date=dt.date(2026, 7, 10),
|
||||
start_time=dt.time(8, 0),
|
||||
end_time=dt.time(13, 0),
|
||||
)
|
||||
session.add(
|
||||
Assignment(
|
||||
edition_id=edition.id,
|
||||
@@ -227,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
|
||||
|
||||
@@ -389,3 +382,286 @@ def test_lead_can_read_any_volunteers_planning(
|
||||
client = as_account(lead_account)
|
||||
|
||||
assert client.get(_url(edition, f"/{other.id}/planning")).status_code == 200
|
||||
|
||||
|
||||
def test_export_volunteers_as_excel(client, edition, session):
|
||||
position = make_position(session, edition, name="Bar central")
|
||||
artist = make_artist(session, edition, name="Chilla")
|
||||
volunteer = make_volunteer(
|
||||
session, edition, first_name="Alex", last_name="Martin", email="alex@ex.fr"
|
||||
)
|
||||
client.patch(
|
||||
_url(edition, f"/{volunteer.id}"),
|
||||
json={
|
||||
"wished_position_ids": [str(position.id)],
|
||||
"artist_ids": [str(artist.id)],
|
||||
},
|
||||
)
|
||||
|
||||
resp = client.get(_url(edition, "/export"))
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == (
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
)
|
||||
assert f"benevoles-{edition.year}.xlsx" in resp.headers["content-disposition"]
|
||||
|
||||
workbook = load_workbook(io.BytesIO(resp.content))
|
||||
sheet = workbook.active
|
||||
rows = list(sheet.iter_rows(values_only=True))
|
||||
assert rows[0][0] == "Prénom"
|
||||
row = rows[1]
|
||||
assert row[0] == "Alex"
|
||||
assert row[1] == "Martin"
|
||||
assert row[2] == "alex@ex.fr"
|
||||
assert row[5] == "Bar central"
|
||||
assert row[6] == "Chilla"
|
||||
|
||||
|
||||
def test_export_says_how_each_volunteer_pays(client, edition, session):
|
||||
on_site = make_volunteer(session, edition, first_name="Alex", last_name="A")
|
||||
on_site.willing_to_pay_fee = True
|
||||
online = make_volunteer(session, edition, first_name="Sam", last_name="B")
|
||||
_mark_mecenat_paid(session, online)
|
||||
make_volunteer(session, edition, first_name="Jo", last_name="C")
|
||||
session.add(on_site)
|
||||
session.commit()
|
||||
|
||||
resp = client.get(_url(edition, "/export"))
|
||||
rows = list(load_workbook(io.BytesIO(resp.content)).active.iter_rows(values_only=True))
|
||||
column = rows[0].index("Mode de paiement")
|
||||
by_first_name = {row[0]: row[column] for row in rows[1:]}
|
||||
assert by_first_name == {"Alex": "Sur place", "Sam": "En ligne", "Jo": None}
|
||||
|
||||
|
||||
def test_export_neutralizes_formula_injection(client, edition, session):
|
||||
# CWE-1236 : un bénévole inscrit via le formulaire public (sans
|
||||
# authentification) contrôle son nom, son commentaire, etc. — ouverts
|
||||
# tels quels dans Excel par la coordination, une valeur comme
|
||||
# `=cmd|'/c calc'!A0` y serait exécutée comme une formule. Un préfixe
|
||||
# `'` force l'interprétation en texte (voir `app/exporting/xlsx.py`).
|
||||
volunteer = make_volunteer(
|
||||
session, edition, first_name="=cmd|'/c calc'!A0", last_name="Martin"
|
||||
)
|
||||
client.patch(
|
||||
_url(edition, f"/{volunteer.id}"),
|
||||
json={"comment": "@SUM(1+1)*cmd|' /C calc'!A0"},
|
||||
)
|
||||
|
||||
resp = client.get(_url(edition, "/export"))
|
||||
workbook = load_workbook(io.BytesIO(resp.content))
|
||||
header, row = list(workbook.active.iter_rows(values_only=True))[:2]
|
||||
assert row[0] == "'=cmd|'/c calc'!A0"
|
||||
assert row[header.index("Commentaire")] == "'@SUM(1+1)*cmd|' /C calc'!A0"
|
||||
|
||||
|
||||
def test_export_volunteers_is_coordinator_only(edition, make_account, as_account):
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
resp = as_account(lead).get(_url(edition, "/export"))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def _mark_mecenat_paid(session, volunteer, *, amount=50.0, paid_at=dt.datetime(2026, 3, 5, 10, 0)):
|
||||
"""Simule l'état d'un bénévole ayant payé un type de paiement « mécénat »
|
||||
(voir `app/api/forms.py:_promote_pending`) — `mecenat_address` est le
|
||||
marqueur qui distingue ce chemin d'un simple paiement classique."""
|
||||
volunteer.fee_payment_status = FeePaymentStatus.PAID
|
||||
volunteer.fee_amount = amount
|
||||
volunteer.fee_paid_at = paid_at
|
||||
volunteer.mecenat_address = "12 rue des Lilas"
|
||||
volunteer.mecenat_city = "Paris"
|
||||
volunteer.mecenat_postal_code = "75020"
|
||||
volunteer.mecenat_country = "France"
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_mecenat_receipts_pdf_includes_only_paid_mecenat_donors(
|
||||
client, edition, session
|
||||
):
|
||||
paid_mecenat = make_volunteer(
|
||||
session, edition, first_name="Alex", last_name="Martin"
|
||||
)
|
||||
_mark_mecenat_paid(session, paid_mecenat)
|
||||
|
||||
# Cotisation classique payée (pas de mécénat) : ne doit pas apparaître.
|
||||
paid_classique = make_volunteer(
|
||||
session, edition, first_name="Sam", last_name="Petit"
|
||||
)
|
||||
paid_classique.fee_payment_status = FeePaymentStatus.PAID
|
||||
paid_classique.fee_amount = 15.0
|
||||
paid_classique.fee_paid_at = dt.datetime(2026, 3, 5, 10, 0)
|
||||
session.add(paid_classique)
|
||||
session.commit()
|
||||
|
||||
# Mécénat choisi mais paiement non abouti : ne doit pas apparaître.
|
||||
failed_mecenat = make_volunteer(
|
||||
session, edition, first_name="Jo", last_name="Dupuis"
|
||||
)
|
||||
failed_mecenat.fee_payment_status = FeePaymentStatus.FAILED
|
||||
failed_mecenat.mecenat_address = "1 rue X"
|
||||
session.add(failed_mecenat)
|
||||
session.commit()
|
||||
|
||||
resp = client.get(_url(edition, "/mecenat-receipts"))
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "application/pdf"
|
||||
assert f"recus-mecenat-{edition.year}.pdf" in resp.headers["content-disposition"]
|
||||
|
||||
reader = PdfReader(io.BytesIO(resp.content))
|
||||
# Un reçu = 2 pages (le cerfa officiel) — un seul bénévole qualifie ici.
|
||||
assert len(reader.pages) == 2
|
||||
donor_page_text = reader.pages[1].extract_text()
|
||||
assert "Martin" in donor_page_text
|
||||
assert "Alex" in donor_page_text
|
||||
assert "50,00" in donor_page_text
|
||||
assert "05/03/2026" in donor_page_text
|
||||
assert "Petit" not in donor_page_text
|
||||
|
||||
|
||||
def test_mecenat_receipts_are_numbered_by_payment_year_and_order(client, edition, session):
|
||||
later = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
_mark_mecenat_paid(session, later, paid_at=dt.datetime(2026, 4, 1, 9, 0))
|
||||
earlier = make_volunteer(session, edition, first_name="Sam", last_name="Petit")
|
||||
_mark_mecenat_paid(session, earlier, paid_at=dt.datetime(2026, 3, 1, 9, 0))
|
||||
|
||||
resp = client.get(_url(edition, "/mecenat-receipts"))
|
||||
assert resp.status_code == 200
|
||||
|
||||
session.refresh(later)
|
||||
session.refresh(earlier)
|
||||
# Attribués dans l'ordre des paiements, pas de l'ordre alphabétique.
|
||||
assert earlier.mecenat_receipt_number == "202600001"
|
||||
assert later.mecenat_receipt_number == "202600002"
|
||||
|
||||
reader = PdfReader(io.BytesIO(resp.content))
|
||||
# Trié par nom : Martin (pages 1-2) puis Petit (pages 3-4).
|
||||
assert "202600002" in reader.pages[0].extract_text()
|
||||
assert "202600001" in reader.pages[2].extract_text()
|
||||
|
||||
|
||||
def test_mecenat_receipt_number_is_stable_across_regenerations(client, edition, session):
|
||||
volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
_mark_mecenat_paid(session, volunteer)
|
||||
client.get(_url(edition, "/mecenat-receipts"))
|
||||
session.refresh(volunteer)
|
||||
first = volunteer.mecenat_receipt_number
|
||||
|
||||
newcomer = make_volunteer(session, edition, first_name="Sam", last_name="Petit")
|
||||
_mark_mecenat_paid(session, newcomer)
|
||||
client.get(_url(edition, "/mecenat-receipts"))
|
||||
session.refresh(volunteer)
|
||||
session.refresh(newcomer)
|
||||
assert volunteer.mecenat_receipt_number == first
|
||||
assert newcomer.mecenat_receipt_number == "202600002"
|
||||
|
||||
|
||||
def test_mecenat_receipt_numbers_continue_the_existing_sequence(client, edition, session):
|
||||
numbered = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
_mark_mecenat_paid(session, numbered)
|
||||
numbered.mecenat_receipt_number = "202600041"
|
||||
session.add(numbered)
|
||||
session.commit()
|
||||
other = make_volunteer(session, edition, first_name="Sam", last_name="Petit")
|
||||
_mark_mecenat_paid(session, other)
|
||||
# Une autre année repart à 1.
|
||||
next_year = make_volunteer(session, edition, first_name="Jo", last_name="Dupuis")
|
||||
_mark_mecenat_paid(session, next_year, paid_at=dt.datetime(2027, 1, 2, 9, 0))
|
||||
|
||||
client.get(_url(edition, "/mecenat-receipts"))
|
||||
session.refresh(other)
|
||||
session.refresh(next_year)
|
||||
assert other.mecenat_receipt_number == "202600042"
|
||||
assert next_year.mecenat_receipt_number == "202700001"
|
||||
|
||||
|
||||
def test_mecenat_receipts_is_422_without_any_paid_donor(client, edition):
|
||||
resp = client.get(_url(edition, "/mecenat-receipts"))
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "mecenat.no_receipts"
|
||||
|
||||
|
||||
def test_mecenat_receipts_is_coordinator_only(
|
||||
edition, session, make_account, as_account
|
||||
):
|
||||
volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
_mark_mecenat_paid(session, volunteer)
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
resp = as_account(lead).get(_url(edition, "/mecenat-receipts"))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_export_gives_the_sumup_transaction(client, edition, session):
|
||||
volunteer = make_volunteer(session, edition, first_name="Sam", last_name="B")
|
||||
_mark_mecenat_paid(session, volunteer)
|
||||
volunteer.fee_transaction_code = "TEENSK4W2K"
|
||||
volunteer.fee_transaction_id = "4ba8a7ba-0f61-4c8a-9b5c-2f3e0b4a8a11"
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
|
||||
resp = client.get(_url(edition, "/export"))
|
||||
rows = list(load_workbook(io.BytesIO(resp.content)).active.iter_rows(values_only=True))
|
||||
code = rows[0].index("Transaction SumUp (code)")
|
||||
ident = rows[0].index("Transaction SumUp (identifiant)")
|
||||
assert rows[1][code] == "TEENSK4W2K"
|
||||
assert rows[1][ident] == "4ba8a7ba-0f61-4c8a-9b5c-2f3e0b4a8a11"
|
||||
|
||||
fiche = client.get(_url(edition, f"/{volunteer.id}")).json()
|
||||
assert fiche["fee_transaction_code"] == "TEENSK4W2K"
|
||||
# L'identifiant du checkout, lui, reste interne.
|
||||
assert "fee_checkout_id" not in fiche
|
||||
|
||||
|
||||
def _png() -> bytes:
|
||||
from PIL import Image
|
||||
|
||||
out = io.BytesIO()
|
||||
Image.new("RGB", (4, 4), "red").save(out, format="PNG")
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def test_coordinator_adds_then_removes_a_volunteer_photo(client, edition, session):
|
||||
volunteer = make_volunteer(session, edition)
|
||||
url = _url(edition, f"/{volunteer.id}/photo")
|
||||
assert client.get(url).status_code == 404
|
||||
|
||||
resp = client.put(url, files={"file": ("photo.png", _png(), "image/png")})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["has_photo"] is True
|
||||
photo = client.get(url)
|
||||
assert photo.status_code == 200
|
||||
assert photo.headers["content-type"] == "image/png"
|
||||
|
||||
resp = client.delete(url)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["has_photo"] is False
|
||||
assert client.get(url).status_code == 404
|
||||
|
||||
|
||||
def test_adding_a_fake_photo_is_rejected(client, edition, session):
|
||||
volunteer = make_volunteer(session, edition)
|
||||
resp = client.put(
|
||||
_url(edition, f"/{volunteer.id}/photo"),
|
||||
files={"file": ("photo.png", b"pas une image", "image/png")},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "form.photo_type_not_supported"
|
||||
|
||||
|
||||
def test_only_the_coordination_can_change_a_photo(
|
||||
session, edition, make_account, as_account
|
||||
):
|
||||
volunteer = make_volunteer(session, edition)
|
||||
lead = as_account(make_account(Role.LEAD, email="lead@e.fr"))
|
||||
url = _url(edition, f"/{volunteer.id}/photo")
|
||||
|
||||
assert lead.put(url, files={"file": ("p.png", _png(), "image/png")}).status_code == 403
|
||||
assert lead.delete(url).status_code == 403
|
||||
|
||||
|
||||
def test_manual_add_requires_no_field(client, edition):
|
||||
resp = client.post(_url(edition), json={})
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["first_name"] == ""
|
||||
assert body["last_name"] == ""
|
||||
assert client.get(_url(edition)).status_code == 200
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlmodel import select
|
||||
|
||||
from app.auth.tokens import hash_token
|
||||
from app.models import MagicLinkToken, Role, local_now
|
||||
from tests.mail import plain_text
|
||||
|
||||
|
||||
def _url(edition, suffix):
|
||||
@@ -31,7 +32,7 @@ def test_full_flow_magic_link_then_jwt_then_me(
|
||||
assert resp.status_code == 202
|
||||
assert len(outbox) == 1
|
||||
assert outbox[0]["To"] == "ref@example.fr"
|
||||
raw_token = outbox[0].get_content().split("?token=")[1].split()[0].strip()
|
||||
raw_token = plain_text(outbox[0]).split("?token=")[1].split()[0].strip()
|
||||
|
||||
verified = raw_client.post(
|
||||
_url(edition, "verify"), json={"token": raw_token}
|
||||
|
||||
@@ -67,6 +67,22 @@ def test_volunteer_scope(session, edition, make_account, as_account):
|
||||
)
|
||||
|
||||
|
||||
def test_volunteer_sees_only_own_photo(session, edition, make_account, as_account):
|
||||
me = make_volunteer(session, edition, email="me@e.fr")
|
||||
other = make_volunteer(session, edition, email="other@e.fr")
|
||||
me.photo = b"fake-bytes"
|
||||
me.photo_content_type = "image/png"
|
||||
other.photo = b"fake-bytes"
|
||||
other.photo_content_type = "image/png"
|
||||
session.add_all([me, other])
|
||||
session.commit()
|
||||
account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="me@e.fr")
|
||||
client = as_account(account)
|
||||
|
||||
assert client.get(_e(edition, f"/volunteers/{me.id}/photo")).status_code == 200
|
||||
assert client.get(_e(edition, f"/volunteers/{other.id}/photo")).status_code == 403
|
||||
|
||||
|
||||
def test_volunteer_sees_only_own_assignments(
|
||||
session, edition, make_account, as_account
|
||||
):
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
"""Configuration et génération des planches de badges (API)."""
|
||||
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import Role
|
||||
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
||||
|
||||
# PNG 1×1 valide (transparent), le plus petit possible.
|
||||
_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
|
||||
"42YAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
def _badges_url(edition):
|
||||
return f"/editions/{edition.id}/badges"
|
||||
|
||||
|
||||
def _set_full_name_field(client, edition):
|
||||
return client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
{
|
||||
"field_type": "FULL_NAME",
|
||||
"x_mm": 5,
|
||||
"y_mm": 5,
|
||||
"width_mm": 60,
|
||||
"height_mm": 10,
|
||||
"font_size": 12,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_template_is_coordinator_only(edition, make_account, as_account):
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
client = as_account(lead)
|
||||
assert client.get(f"{_badges_url(edition)}/template").status_code == 403
|
||||
resp = client.put(f"{_badges_url(edition)}/template/fields", json={"fields": []})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_template_starts_empty(client, edition):
|
||||
resp = client.get(f"{_badges_url(edition)}/template")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["has_front_image"] is False
|
||||
assert body["has_back_image"] is False
|
||||
assert body["fields"] == []
|
||||
# Format CR80 par défaut tant qu'aucune image n'a été importée.
|
||||
assert body["badge_width_mm"] == 85.6
|
||||
assert body["badge_height_mm"] == 54.0
|
||||
|
||||
|
||||
def test_uploading_front_image_derives_the_badge_format_from_its_pixels(client, edition):
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/front-image",
|
||||
files={"file": ("front.png", _PNG, "image/png")},
|
||||
)
|
||||
body = resp.json()
|
||||
# PNG 1×1 px, résolution supposée 300 dpi ⇒ 25,4/300 mm de côté.
|
||||
assert body["badge_width_mm"] == pytest.approx(25.4 / 300)
|
||||
assert body["badge_height_mm"] == pytest.approx(25.4 / 300)
|
||||
|
||||
|
||||
def test_uploading_front_and_back_images(client, edition):
|
||||
front = client.put(
|
||||
f"{_badges_url(edition)}/template/front-image",
|
||||
files={"file": ("front.png", _PNG, "image/png")},
|
||||
)
|
||||
assert front.status_code == 200
|
||||
assert front.json()["has_front_image"] is True
|
||||
|
||||
back = client.put(
|
||||
f"{_badges_url(edition)}/template/back-image",
|
||||
files={"file": ("back.png", _PNG, "image/png")},
|
||||
)
|
||||
assert back.status_code == 200
|
||||
assert back.json()["has_back_image"] is True
|
||||
|
||||
served = client.get(f"{_badges_url(edition)}/template/front-image")
|
||||
assert served.status_code == 200
|
||||
assert served.headers["content-type"] == "image/png"
|
||||
assert served.content == _PNG
|
||||
|
||||
|
||||
def test_uploading_an_unsupported_image_type_is_rejected(client, edition):
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/front-image",
|
||||
files={"file": ("front.gif", b"not-a-real-gif", "image/gif")},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "badge.image_type_not_supported"
|
||||
|
||||
|
||||
def test_resizing_the_badge_scales_it_independently_of_the_image(client, edition):
|
||||
client.put(
|
||||
f"{_badges_url(edition)}/template/front-image",
|
||||
files={"file": ("front.png", _PNG, "image/png")},
|
||||
)
|
||||
# Image 1×1 px à 300 dpi supposés ⇒ badge minuscule (~0,08 mm) : on
|
||||
# l'agrandit à un format exploitable, en conservant le ratio (ici 1:1).
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/size",
|
||||
json={"badge_width_mm": 90, "badge_height_mm": 90},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["badge_width_mm"] == 90
|
||||
assert body["badge_height_mm"] == 90
|
||||
|
||||
# Persisté : une relecture du gabarit renvoie toujours la taille agrandie.
|
||||
again = client.get(f"{_badges_url(edition)}/template").json()
|
||||
assert again["badge_width_mm"] == 90
|
||||
assert again["badge_height_mm"] == 90
|
||||
|
||||
|
||||
def test_resizing_the_badge_to_a_non_positive_size_is_rejected(client, edition):
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/size",
|
||||
json={"badge_width_mm": 0, "badge_height_mm": 54},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "badge.invalid_size"
|
||||
|
||||
|
||||
def test_setting_the_same_field_type_twice_is_rejected(client, edition):
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
{
|
||||
"field_type": "FULL_NAME", "x_mm": 0, "y_mm": 0,
|
||||
"width_mm": 10, "height_mm": 10,
|
||||
},
|
||||
{
|
||||
"field_type": "FULL_NAME", "x_mm": 5, "y_mm": 5,
|
||||
"width_mm": 10, "height_mm": 10,
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "badge.duplicate_field_type"
|
||||
|
||||
|
||||
def test_generating_without_any_field_configured_is_rejected(client, edition, session):
|
||||
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
resp = client.post(f"{_badges_url(edition)}/generate", json={})
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "badge.no_fields"
|
||||
|
||||
|
||||
def test_generating_with_no_matching_volunteers_is_rejected(client, edition, session):
|
||||
_set_full_name_field(client, edition)
|
||||
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
resp = client.post(
|
||||
f"{_badges_url(edition)}/generate",
|
||||
json={"volunteer_ids": ["00000000-0000-0000-0000-000000000000"]},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "badge.no_volunteers"
|
||||
|
||||
|
||||
def test_generating_produces_a_pdf(client, edition, session):
|
||||
from app.models import Assignment
|
||||
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
{
|
||||
"field_type": "FULL_NAME", "x_mm": 5, "y_mm": 5,
|
||||
"width_mm": 60, "height_mm": 10, "font_size": 12,
|
||||
},
|
||||
{
|
||||
"field_type": "POSITIONS", "x_mm": 5, "y_mm": 20,
|
||||
"width_mm": 60, "height_mm": 10, "font_size": 9,
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
client.put(
|
||||
f"{_badges_url(edition)}/template/front-image",
|
||||
files={"file": ("front.png", _PNG, "image/png")},
|
||||
)
|
||||
client.put(
|
||||
f"{_badges_url(edition)}/template/back-image",
|
||||
files={"file": ("back.png", _PNG, "image/png")},
|
||||
)
|
||||
|
||||
volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
position = make_position(session, edition, name="Bar central")
|
||||
shift = make_shift(session, edition, position, start=dt_at(9, 18), end=dt_at(9, 22))
|
||||
session.add(Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id))
|
||||
session.commit()
|
||||
|
||||
resp = client.post(
|
||||
f"{_badges_url(edition)}/generate",
|
||||
json={"volunteer_ids": [str(volunteer.id)]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "application/pdf"
|
||||
assert resp.content.startswith(b"%PDF")
|
||||
|
||||
|
||||
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"]
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Texte résolu des champs du badge — fonctions pures (pas de base)."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from app.api.badges import _field_text, _positions_table
|
||||
from app.models import (
|
||||
BadgeField,
|
||||
BadgeFieldType,
|
||||
Position,
|
||||
PositionCategory,
|
||||
Shift,
|
||||
Volunteer,
|
||||
)
|
||||
|
||||
|
||||
def _volunteer(**kw) -> Volunteer:
|
||||
return Volunteer(id=uuid.uuid4(), edition_id=uuid.uuid4(), **kw)
|
||||
|
||||
|
||||
def _position(**kw) -> Position:
|
||||
return Position(id=uuid.uuid4(), edition_id=uuid.uuid4(), category=PositionCategory.BAR, **kw)
|
||||
|
||||
|
||||
def _shift(start: dt.datetime, end: dt.datetime) -> Shift:
|
||||
return Shift(
|
||||
id=uuid.uuid4(), edition_id=uuid.uuid4(), position_id=uuid.uuid4(),
|
||||
starts_at=start, ends_at=end,
|
||||
)
|
||||
|
||||
|
||||
_BOUNDARY = dt.time(2, 0) # journée de festival de 02:00 à 02:00
|
||||
|
||||
|
||||
def test_positions_table_groups_shifts_under_their_day():
|
||||
accueil = (
|
||||
_position(name="Accueil"),
|
||||
_shift(dt.datetime(2026, 7, 11, 9, 0), dt.datetime(2026, 7, 11, 13, 0)),
|
||||
)
|
||||
bar = (
|
||||
_position(name="Bar central"),
|
||||
_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)),
|
||||
)
|
||||
|
||||
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)),
|
||||
)
|
||||
|
||||
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():
|
||||
volunteer = _volunteer(first_name="Sam", last_name="Nuit")
|
||||
field = BadgeField(
|
||||
edition_id=uuid.uuid4(), field_type=BadgeFieldType.ROLE,
|
||||
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
|
||||
)
|
||||
|
||||
assert _field_text(field, volunteer, {}, lead_volunteer_ids=set()) == "Bénévole"
|
||||
assert _field_text(field, volunteer, {}, lead_volunteer_ids={volunteer.id}) == "Référent"
|
||||
|
||||
|
||||
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()) == ""
|
||||
@@ -0,0 +1,436 @@
|
||||
"""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,
|
||||
)
|
||||
|
||||
|
||||
def _badge(text="Alex Martin") -> BadgeSpec:
|
||||
return BadgeSpec(
|
||||
fields=(
|
||||
BadgeFieldSpec(
|
||||
text=text,
|
||||
x_mm=5,
|
||||
y_mm=5,
|
||||
width_mm=60,
|
||||
height_mm=10,
|
||||
font_size=12,
|
||||
align="LEFT",
|
||||
bold=True,
|
||||
color="#000000",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_image_size_mm_uses_the_assumed_print_resolution():
|
||||
# 300 dpi (résolution d'impression standard) : 1013 px / 300 * 25,4 mm.
|
||||
width_mm, height_mm = image_size_mm(1013, 638, dpi=300)
|
||||
assert width_mm == 1013 / 300 * 25.4
|
||||
assert height_mm == 638 / 300 * 25.4
|
||||
|
||||
|
||||
def test_grid_size_fits_a4_with_cr80_badges():
|
||||
# A4 (210 × 297 mm), marge 10 mm, gouttière 4 mm, badge CR80 85,6 × 54 mm
|
||||
# ⇒ 2 colonnes (190 mm utiles / 89,6 mm par badge+gouttière) x 4 rangées
|
||||
# (277 mm utiles / 58 mm par badge+gouttière).
|
||||
assert _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM) == (2, 4)
|
||||
|
||||
|
||||
def test_grid_size_adapts_to_a_different_badge_format():
|
||||
# Badge carré 50 × 50 mm : plus de colonnes/rangées qu'un CR80.
|
||||
assert _grid_size(50, 50) == (3, 5)
|
||||
|
||||
|
||||
def test_cell_origin_places_badges_left_to_right_top_to_bottom():
|
||||
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||
x0, y0 = _cell_origin(0, cols, DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||
x1, _ = _cell_origin(1, cols, DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||
_, y_row2 = _cell_origin(cols, cols, DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||
assert x1 > x0 # la 2e colonne est à droite de la 1re
|
||||
assert y_row2 < y0 # la 2e rangée est plus bas (repère PDF : Y croît vers le haut)
|
||||
|
||||
|
||||
def test_generates_a_valid_pdf_for_a_single_badge():
|
||||
pdf = generate_badge_sheets_pdf([_badge()], front_image=None, back_image=None)
|
||||
assert pdf.startswith(b"%PDF")
|
||||
assert pdf.rstrip().endswith(b"%%EOF")
|
||||
|
||||
|
||||
def test_generates_a_valid_pdf_with_a_custom_badge_format():
|
||||
pdf = generate_badge_sheets_pdf(
|
||||
[_badge()], front_image=None, back_image=None,
|
||||
badge_width_mm=50, badge_height_mm=50,
|
||||
)
|
||||
assert pdf.startswith(b"%PDF")
|
||||
|
||||
|
||||
def test_multiline_field_text_does_not_crash_generation():
|
||||
badge = BadgeSpec(
|
||||
fields=(
|
||||
BadgeFieldSpec(
|
||||
text="Bar central 19:00–22:00\nAccueil 09:00–13:00 (Référent : Sam Nuit)",
|
||||
x_mm=5, y_mm=5, width_mm=70, height_mm=20,
|
||||
font_size=9, align="LEFT", bold=False, color="#000000",
|
||||
),
|
||||
)
|
||||
)
|
||||
pdf = generate_badge_sheets_pdf([badge], front_image=None, back_image=None)
|
||||
assert pdf.startswith(b"%PDF")
|
||||
|
||||
|
||||
def test_more_badges_than_one_sheet_produces_a_larger_pdf():
|
||||
cols, rows = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||
per_page = cols * rows
|
||||
one_page = generate_badge_sheets_pdf(
|
||||
[_badge() for _ in range(per_page)], front_image=None, back_image=None
|
||||
)
|
||||
# Un badge de plus déborde sur une 2e planche (recto + verso en plus).
|
||||
two_pages = generate_badge_sheets_pdf(
|
||||
[_badge() for _ in range(per_page + 1)], front_image=None, back_image=None
|
||||
)
|
||||
assert len(two_pages) > len(one_page)
|
||||
|
||||
|
||||
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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user