Compare commits
79
Commits
166e2c3366
...
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
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# CI/CD Gitea Actions : à chaque push sur `main`, build des images
|
||||
# (registre de conteneurs intégré à Gitea) puis déploiement SSH sur le VPS.
|
||||
#
|
||||
# Nécessite un runner Gitea Actions (act_runner, exécuteur "docker") attaché
|
||||
# au dépôt ou à l'organisation.
|
||||
#
|
||||
# Secrets à définir (Paramètres du dépôt → Actions → Secrets) :
|
||||
# REGISTRY_TOKEN jeton d'accès (scope "write:package") pour pousser sur
|
||||
# le registre de conteneurs de cette instance Gitea
|
||||
# SSH_KEY clé privée SSH (format OpenSSH) pour se connecter au VPS
|
||||
#
|
||||
# Variables à définir (Paramètres du dépôt → Actions → Variables) :
|
||||
# REGISTRY_HOST hôte du registre (ex. gitea.mon-domaine.fr)
|
||||
# REGISTRY_NAMESPACE espace du registre (ex. <organisation>/<depot>)
|
||||
# REGISTRY_USER utilisateur associé à REGISTRY_TOKEN
|
||||
# SSH_HOST hôte du VPS
|
||||
# SSH_USER utilisateur SSH
|
||||
# DEPLOY_PATH dossier du projet sur le VPS (ex. /opt/benevoles)
|
||||
# VITE_EDITION_ID UUID de l'édition de production, à renseigner APRÈS
|
||||
# la toute première mise en service (voir deploy/README.md)
|
||||
# VITE_HCAPTCHA_SITE_KEY clé de site hCaptcha (publique) — figée au build
|
||||
# du frontend, voir app/api/forms.py et deploy/README.md.
|
||||
# Vide ⇒ formulaire public sans vérification anti-robot.
|
||||
|
||||
name: ci-cd
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-cd-${{ gitea.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
container: docker:27
|
||||
steps:
|
||||
- name: Installer git et node
|
||||
run: apk add --no-cache git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
- name: Connexion au registre
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ vars.REGISTRY_USER }}" --password-stdin
|
||||
- name: Build & push
|
||||
run: |
|
||||
IMAGE="${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_NAMESPACE }}/backend"
|
||||
docker build -t "$IMAGE:${{ gitea.sha }}" -t "$IMAGE:latest" backend
|
||||
docker push "$IMAGE:${{ gitea.sha }}"
|
||||
docker push "$IMAGE:latest"
|
||||
|
||||
build-frontend:
|
||||
needs: build-backend # séquentiel pour ne pas saturer le CPU
|
||||
runs-on: ubuntu-latest
|
||||
container: docker:27
|
||||
steps:
|
||||
- name: Installer git et node
|
||||
run: apk add --no-cache git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
- name: Connexion au registre
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ vars.REGISTRY_USER }}" --password-stdin
|
||||
- name: Build & push
|
||||
run: |
|
||||
IMAGE="${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_NAMESPACE }}/frontend"
|
||||
docker build \
|
||||
--build-arg VITE_API_MODE=http \
|
||||
--build-arg VITE_EDITION_ID="${{ vars.VITE_EDITION_ID }}" \
|
||||
--build-arg VITE_HCAPTCHA_SITE_KEY="${{ vars.VITE_HCAPTCHA_SITE_KEY }}" \
|
||||
-f frontend/Dockerfile.prod \
|
||||
-t "$IMAGE:${{ gitea.sha }}" -t "$IMAGE:latest" frontend
|
||||
docker push "$IMAGE:${{ gitea.sha }}"
|
||||
docker push "$IMAGE:latest"
|
||||
|
||||
deploy:
|
||||
needs: [build-backend, build-frontend]
|
||||
runs-on: ubuntu-latest
|
||||
container: alpine:3.20
|
||||
steps:
|
||||
- name: Installer les outils
|
||||
run: apk add --no-cache git nodejs openssh-client rsync
|
||||
- uses: actions/checkout@v4
|
||||
- name: Déployer par SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
echo "${{ secrets.SSH_KEY }}" | tr -d '\r' > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H "${{ vars.SSH_HOST }}" >> ~/.ssh/known_hosts
|
||||
rsync -az -e "ssh -i ~/.ssh/id_ed25519" deploy/docker-compose.prod.yml "${{ vars.SSH_USER }}@${{ vars.SSH_HOST }}:${{ vars.DEPLOY_PATH }}/docker-compose.prod.yml"
|
||||
ssh -i ~/.ssh/id_ed25519 "${{ vars.SSH_USER }}@${{ vars.SSH_HOST }}" sh -s <<EOF
|
||||
set -e
|
||||
cd "${{ vars.DEPLOY_PATH }}"
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ vars.REGISTRY_USER }}" --password-stdin
|
||||
export BACKEND_IMAGE="${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_NAMESPACE }}/backend:${{ gitea.sha }}"
|
||||
export FRONTEND_IMAGE="${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_NAMESPACE }}/frontend:${{ gitea.sha }}"
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.prod.yml run --rm backend uv run alembic upgrade head
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
docker image prune -f
|
||||
EOF
|
||||
@@ -23,3 +23,5 @@ dist/
|
||||
|
||||
# Claude Code (worktrees, agents locaux)
|
||||
.claude/
|
||||
|
||||
TODO
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
# CI/CD : tests à chaque push, puis sur `main` seulement, build des images
|
||||
# (registre de conteneurs GitLab) et déploiement SSH sur le VPS.
|
||||
#
|
||||
# Variables CI/CD à définir (Paramètres → CI/CD → Variables du projet) :
|
||||
# SSH_HOST hôte du VPS
|
||||
# SSH_USER utilisateur SSH
|
||||
# SSH_KEY clé privée SSH (format OpenSSH), variable « protégée »
|
||||
# + « masquée », type fichier ou variable multi-lignes
|
||||
# DEPLOY_PATH dossier du projet sur le VPS (ex. /opt/benevoles)
|
||||
# VITE_EDITION_ID UUID de l'édition de production, à renseigner APRÈS la
|
||||
# toute première mise en service (voir deploy/README.md) —
|
||||
# le frontend fige cette valeur au moment du build.
|
||||
#
|
||||
# `CI_REGISTRY`, `CI_REGISTRY_USER`, `CI_REGISTRY_PASSWORD` et
|
||||
# `CI_REGISTRY_IMAGE` sont fournis automatiquement par GitLab.
|
||||
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
BACKEND_IMAGE: $CI_REGISTRY_IMAGE/backend
|
||||
FRONTEND_IMAGE: $CI_REGISTRY_IMAGE/frontend
|
||||
|
||||
backend-test:
|
||||
stage: test
|
||||
image: python:3.12-slim
|
||||
services:
|
||||
- name: postgres:16-alpine
|
||||
alias: postgres
|
||||
variables:
|
||||
POSTGRES_USER: benevoles
|
||||
POSTGRES_PASSWORD: benevoles
|
||||
POSTGRES_DB: benevoles_test
|
||||
TEST_DATABASE_URL: postgresql+psycopg://benevoles:benevoles@postgres:5432/benevoles_test
|
||||
DATABASE_URL: postgresql+psycopg://benevoles:benevoles@postgres:5432/benevoles_test
|
||||
before_script:
|
||||
- pip install --no-cache-dir uv
|
||||
- cd backend
|
||||
- uv sync
|
||||
script:
|
||||
- uv run ruff check .
|
||||
- uv run alembic upgrade head
|
||||
- uv run alembic check
|
||||
- uv run pytest -q
|
||||
|
||||
frontend-test:
|
||||
stage: test
|
||||
image: node:20-alpine
|
||||
before_script:
|
||||
- cd frontend
|
||||
- npm ci
|
||||
script:
|
||||
- npm run typecheck
|
||||
- npm run lint
|
||||
- npm run build
|
||||
- npm run smoke
|
||||
|
||||
build-backend:
|
||||
stage: build
|
||||
image: docker:27
|
||||
services:
|
||||
- docker:27-dind
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
script:
|
||||
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
|
||||
- docker build -t "$BACKEND_IMAGE:$CI_COMMIT_SHORT_SHA" -t "$BACKEND_IMAGE:latest" backend
|
||||
- docker push "$BACKEND_IMAGE:$CI_COMMIT_SHORT_SHA"
|
||||
- docker push "$BACKEND_IMAGE:latest"
|
||||
|
||||
build-frontend:
|
||||
stage: build
|
||||
image: docker:27
|
||||
services:
|
||||
- docker:27-dind
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
script:
|
||||
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
|
||||
- >
|
||||
docker build
|
||||
--build-arg VITE_API_MODE=http
|
||||
--build-arg VITE_EDITION_ID="$VITE_EDITION_ID"
|
||||
-f frontend/Dockerfile.prod
|
||||
-t "$FRONTEND_IMAGE:$CI_COMMIT_SHORT_SHA" -t "$FRONTEND_IMAGE:latest"
|
||||
frontend
|
||||
- docker push "$FRONTEND_IMAGE:$CI_COMMIT_SHORT_SHA"
|
||||
- docker push "$FRONTEND_IMAGE:latest"
|
||||
|
||||
deploy:
|
||||
stage: deploy
|
||||
image: alpine:3.20
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
before_script:
|
||||
- apk add --no-cache openssh-client rsync
|
||||
- eval "$(ssh-agent -s)"
|
||||
- echo "$SSH_KEY" | tr -d '\r' | ssh-add -
|
||||
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
- ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts
|
||||
script:
|
||||
- rsync -az deploy/docker-compose.prod.yml "$SSH_USER@$SSH_HOST:$DEPLOY_PATH/docker-compose.prod.yml"
|
||||
- |
|
||||
ssh "$SSH_USER@$SSH_HOST" sh -s <<EOF
|
||||
set -e
|
||||
cd "$DEPLOY_PATH"
|
||||
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
|
||||
export BACKEND_IMAGE="$BACKEND_IMAGE:$CI_COMMIT_SHORT_SHA"
|
||||
export FRONTEND_IMAGE="$FRONTEND_IMAGE:$CI_COMMIT_SHORT_SHA"
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.prod.yml run --rm backend uv run alembic upgrade head
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
docker image prune -f
|
||||
EOF
|
||||
+362
@@ -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.
|
||||
@@ -48,4 +48,4 @@ démarrage du conteneur PostgreSQL.
|
||||
| `backend/` | API FastAPI, modèle SQLModel, migrations Alembic |
|
||||
| `frontend/` | Interface React + Vite + TS + Tailwind (service `frontend`) |
|
||||
| `deploy/` | Configuration de déploiement production (compose, Caddy, runbook) |
|
||||
| `.gitlab-ci.yml` | Chaîne CI/CD (tests → build → SSH sur le VPS) |
|
||||
| `.gitea/workflows/` | Chaîne CI/CD (tests → build → SSH sur le VPS) |
|
||||
|
||||
+9
-1
@@ -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,
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlmodel import Session, select
|
||||
|
||||
from app.api._common import owned_or_404
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import AccountCreate, AccountRead
|
||||
from app.api.schemas import AccountCreate, AccountRead, AccountUpdate
|
||||
from app.auth.deps import require_coordinator
|
||||
from app.auth.passwords import MIN_LENGTH, hash_password
|
||||
from app.db import get_session
|
||||
@@ -85,6 +85,48 @@ def list_accounts(
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{account_id}", response_model=AccountRead)
|
||||
def update_account(
|
||||
account_id: uuid.UUID,
|
||||
payload: AccountUpdate,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Account:
|
||||
"""Seul le rôle change de main ici, et seulement entre LEAD et VOLUNTEER
|
||||
(lien magique dans les deux cas) : devenir référent d'un poste (voir
|
||||
`positions.py`) met à niveau le compte bénévole existant, sans toucher au
|
||||
mot de passe d'un compte COORDINATION."""
|
||||
account = owned_or_404(session.get(Account, account_id), edition.id)
|
||||
if payload.role is not None and payload.role != account.role:
|
||||
if Role.COORDINATOR in (account.role, payload.role):
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "account.role_change_not_allowed"}
|
||||
)
|
||||
account.role = payload.role
|
||||
session.commit()
|
||||
session.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
@router.post("/{account_id}/reset-login-link", response_model=AccountRead)
|
||||
def reset_login_link(
|
||||
account_id: uuid.UUID,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Account:
|
||||
"""Révoque le lien de connexion permanent du compte (`app/auth/
|
||||
permanent_link.py`) : tous les liens déjà envoyés cessent de
|
||||
fonctionner ; le prochain récapitulatif portera un nouveau lien. Ex.
|
||||
email transféré, téléphone perdu."""
|
||||
account = owned_or_404(session.get(Account, account_id), edition.id)
|
||||
account.login_link_version += 1
|
||||
session.commit()
|
||||
session.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_account(
|
||||
account_id: uuid.UUID,
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.api.schemas import (
|
||||
AssignmentValidateRequest,
|
||||
AssignmentValidateResult,
|
||||
AssignmentWriteResult,
|
||||
AttendanceUpdate,
|
||||
)
|
||||
from app.auth.deps import (
|
||||
current_account_in_edition,
|
||||
@@ -24,7 +25,7 @@ from app.auth.deps import (
|
||||
require_staff,
|
||||
)
|
||||
from app.db import get_session
|
||||
from app.models import Account, Assignment, Edition, Role, Shift, Volunteer
|
||||
from app.models import Account, Assignment, Edition, Role, Shift, Volunteer, local_now
|
||||
from app.validation import has_blocking, validate
|
||||
from app.validation.loader import build_validation_input
|
||||
|
||||
@@ -206,6 +207,30 @@ def update_assignment(
|
||||
return AssignmentWriteResult(assignment=assignment, warnings=warnings)
|
||||
|
||||
|
||||
@router.put("/{assignment_id}/attendance", response_model=AssignmentRead)
|
||||
def set_attendance(
|
||||
assignment_id: uuid.UUID,
|
||||
payload: AttendanceUpdate,
|
||||
edition: Edition = Depends(get_edition),
|
||||
account: Account = Depends(current_account_in_edition),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Assignment:
|
||||
"""Pointe un bénévole présent ou absent sur son créneau (`None` annule) —
|
||||
le référent du poste ou la coordination. Enregistré en base : le
|
||||
pointage survit à un rechargement et se retrouve sur tout appareil."""
|
||||
assignment = owned_or_404(
|
||||
session.get(Assignment, assignment_id), edition.id
|
||||
)
|
||||
ensure_can_write_shift(
|
||||
account, session, session.get(Shift, assignment.shift_id)
|
||||
)
|
||||
assignment.attendance = payload.attendance
|
||||
assignment.attendance_marked_at = local_now() if payload.attendance else None
|
||||
session.commit()
|
||||
session.refresh(assignment)
|
||||
return assignment
|
||||
|
||||
|
||||
@router.delete("/{assignment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_assignment(
|
||||
assignment_id: uuid.UUID,
|
||||
|
||||
+54
-2
@@ -25,10 +25,13 @@ from app.api.schemas import (
|
||||
from app.auth.deps import get_current_account
|
||||
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
|
||||
from app.auth.passwords import MIN_LENGTH, hash_password, verify_password
|
||||
from app.auth.permanent_link import PREFIX as PERMANENT_PREFIX
|
||||
from app.auth.permanent_link import account_from_permanent_token
|
||||
from app.auth.rate_limit import login_limiter, magic_link_limiter
|
||||
from app.auth.tokens import create_access_token, hash_token
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.email import render_magic_link, send
|
||||
from app.email import edition_logo, render_magic_link, send
|
||||
from app.models import Account, MagicLinkToken, local_now
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
@@ -76,13 +79,24 @@ def login(
|
||||
payload: LoginRequest,
|
||||
session: Session = Depends(get_session),
|
||||
) -> TokenResponse:
|
||||
login_limiter.hit(f"{edition_id}:{payload.email.strip().lower()}")
|
||||
account = _account_by_email(session, edition_id, payload.email)
|
||||
if (
|
||||
account is None
|
||||
or account.password_hash is None
|
||||
or not verify_password(payload.password, account.password_hash)
|
||||
):
|
||||
# Cause réelle dans les logs seulement — la réponse reste la même
|
||||
# (pas de divulgation). Jamais l'email saisi.
|
||||
if account is None:
|
||||
reason = "aucun compte pour cet email"
|
||||
elif account.password_hash is None:
|
||||
reason = f"compte {account.id} sans mot de passe (lien magique)"
|
||||
else:
|
||||
reason = f"mot de passe incorrect pour le compte {account.id}"
|
||||
logger.info("Connexion refusée (édition %s) : %s", edition_id, reason)
|
||||
raise _invalid_credentials()
|
||||
logger.info("Connexion par mot de passe : compte %s (édition %s)", account.id, edition_id)
|
||||
return _issue(account)
|
||||
|
||||
|
||||
@@ -131,8 +145,21 @@ def request_magic_link(
|
||||
"""Émet un lien magique si un compte **sans mot de passe** correspond.
|
||||
Réponse identique dans tous les cas (pas de divulgation). L'email part en
|
||||
tâche de fond (une erreur SMTP est journalisée, pas remontée)."""
|
||||
magic_link_limiter.hit(f"{edition_id}:{payload.email.strip().lower()}")
|
||||
account = _account_by_email(session, edition_id, payload.email)
|
||||
if account is None:
|
||||
logger.info(
|
||||
"Lien magique demandé sans compte correspondant (édition %s) — rien envoyé "
|
||||
"(un email sur une fiche bénévole ne crée pas d'accès)",
|
||||
edition_id,
|
||||
)
|
||||
elif account.password_hash is not None:
|
||||
logger.info(
|
||||
"Lien magique demandé pour le compte %s, qui a un mot de passe — rien envoyé",
|
||||
account.id,
|
||||
)
|
||||
if account is not None and account.password_hash is None:
|
||||
logger.info("Lien magique émis pour le compte %s (édition %s)", account.id, edition_id)
|
||||
settings = get_settings()
|
||||
raw = issue_magic_link_token(session, account)
|
||||
session.commit()
|
||||
@@ -140,7 +167,9 @@ def request_magic_link(
|
||||
link=build_magic_link_url(raw),
|
||||
minutes=settings.magic_link_ttl_minutes,
|
||||
)
|
||||
background.add_task(send, account.email, subject, body)
|
||||
background.add_task(
|
||||
send, account.email, subject, body, logo=edition_logo(session, edition_id)
|
||||
)
|
||||
if not settings.email_enabled:
|
||||
# Aide au dev sans SMTP : le jeton (non nominatif) est journalisé.
|
||||
logger.info(
|
||||
@@ -157,6 +186,16 @@ def verify_magic_link(
|
||||
payload: VerifyRequest,
|
||||
session: Session = Depends(get_session),
|
||||
) -> TokenResponse:
|
||||
raw = payload.token.strip()
|
||||
if raw.startswith(PERMANENT_PREFIX):
|
||||
# Lien permanent du récapitulatif de planning : réutilisable, sans
|
||||
# expiration, révocable (voir `app/auth/permanent_link.py`).
|
||||
account = account_from_permanent_token(session, raw)
|
||||
if account is None or account.edition_id != edition_id:
|
||||
logger.info("Lien permanent refusé (édition %s)", edition_id)
|
||||
raise _invalid_token()
|
||||
logger.info("Connexion par lien permanent : compte %s (édition %s)", account.id, edition_id)
|
||||
return _issue(account)
|
||||
token = session.exec(
|
||||
select(MagicLinkToken).where(
|
||||
MagicLinkToken.token_hash == hash_token(payload.token.strip())
|
||||
@@ -167,13 +206,26 @@ def verify_magic_link(
|
||||
or token.used_at is not None
|
||||
or token.expires_at < local_now()
|
||||
):
|
||||
if token is None:
|
||||
reason = "jeton inconnu"
|
||||
elif token.used_at is not None:
|
||||
reason = f"jeton déjà utilisé (compte {token.account_id})"
|
||||
else:
|
||||
reason = f"jeton expiré (compte {token.account_id})"
|
||||
logger.info("Lien magique refusé (édition %s) : %s", edition_id, reason)
|
||||
raise _invalid_token()
|
||||
account = session.get(Account, token.account_id)
|
||||
if account is None or account.edition_id != edition_id:
|
||||
logger.info(
|
||||
"Lien magique refusé (édition %s) : compte %s absent ou d'une autre édition",
|
||||
edition_id,
|
||||
token.account_id,
|
||||
)
|
||||
raise _invalid_token()
|
||||
token.used_at = local_now()
|
||||
session.commit()
|
||||
session.refresh(account)
|
||||
logger.info("Connexion par lien magique : compte %s (édition %s)", account.id, edition_id)
|
||||
return _issue(account)
|
||||
|
||||
|
||||
|
||||
@@ -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"'},
|
||||
)
|
||||
@@ -1,11 +1,16 @@
|
||||
"""CRUD des postes."""
|
||||
"""CRUD des postes.
|
||||
|
||||
`lead_account_id` (référent) ne peut désigner qu'un compte `Role.LEAD` de la
|
||||
même édition — voir `_check_lead_account`. C'est le seul moyen de donner à un
|
||||
bénévole une vue référent (`/mobile-referent`) à la connexion ; voir
|
||||
`api/accounts.py:update_account` pour le faire passer de VOLUNTEER à LEAD."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api._common import owned_or_404
|
||||
from app.api._common import owned_or_404, set_message
|
||||
from app.api._shiftgen import generate_missing_shifts
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import (
|
||||
@@ -21,13 +26,28 @@ from app.auth.deps import (
|
||||
require_staff,
|
||||
)
|
||||
from app.db import get_session
|
||||
from app.models import Account, Edition, Position
|
||||
from app.models import Account, Edition, Position, Role
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/editions/{edition_id}/positions", tags=["positions"]
|
||||
)
|
||||
|
||||
|
||||
def _check_lead_account(
|
||||
session: Session, edition: Edition, lead_account_id: uuid.UUID | None
|
||||
) -> None:
|
||||
"""`lead_account_id` doit désigner un compte RÉFÉRENT (`Role.LEAD`) de
|
||||
cette même édition — sinon le poste se retrouve sans référent utilisable
|
||||
(cf. `ensure_can_write_position`, `auth/deps.py`)."""
|
||||
if lead_account_id is None:
|
||||
return
|
||||
lead = session.get(Account, lead_account_id)
|
||||
if lead is None or lead.edition_id != edition.id or lead.role is not Role.LEAD:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "position.lead_must_be_referent_account"}
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=PositionRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_position(
|
||||
payload: PositionCreate,
|
||||
@@ -35,11 +55,12 @@ def create_position(
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Position:
|
||||
_check_lead_account(session, edition, payload.lead_account_id)
|
||||
position = Position(edition_id=edition.id, **payload.model_dump())
|
||||
session.add(position)
|
||||
session.flush()
|
||||
# Un créneau à besoin 0/0 par jour et par créneau type de l'édition.
|
||||
generate_missing_shifts(session, edition, position)
|
||||
# Pas de créneaux générés automatiquement : la coordination choisit
|
||||
# elle-même ceux dont elle a besoin (créés à la main, ou en repartant des
|
||||
# créneaux types via `generate_shifts` ci-dessous).
|
||||
session.commit()
|
||||
session.refresh(position)
|
||||
return position
|
||||
@@ -80,7 +101,11 @@ def update_position(
|
||||
) -> Position:
|
||||
position = owned_or_404(session.get(Position, position_id), edition.id)
|
||||
ensure_can_write_position(account, position)
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
_check_lead_account(session, edition, changes.get("lead_account_id"))
|
||||
if "message" in changes:
|
||||
set_message(position, "message", changes.pop("message"))
|
||||
for field, value in changes.items():
|
||||
setattr(position, field, value)
|
||||
session.commit()
|
||||
session.refresh(position)
|
||||
|
||||
+483
-29
@@ -7,6 +7,13 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models import (
|
||||
AssignmentSource,
|
||||
AttendanceStatus,
|
||||
BadgeAlign,
|
||||
BadgeFace,
|
||||
BadgeFieldType,
|
||||
BadgePhotoShape,
|
||||
FeePaymentStatus,
|
||||
FormFieldType,
|
||||
PositionCategory,
|
||||
Role,
|
||||
)
|
||||
@@ -29,23 +36,34 @@ class EditionRead(ApiModel):
|
||||
start_date: dt.date
|
||||
end_date: dt.date
|
||||
retention_days: int | None
|
||||
volunteer_message: str | None
|
||||
volunteer_message_updated_at: dt.datetime | None
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
# Plafond d'un message (organisation, poste) : quelques paragraphes, pas un
|
||||
# document.
|
||||
MESSAGE_MAX_LENGTH = 2000
|
||||
|
||||
|
||||
class EditionUpdate(ApiModel):
|
||||
name: str | None = None
|
||||
year: int | None = None
|
||||
start_date: dt.date | None = None
|
||||
end_date: dt.date | None = None
|
||||
retention_days: int | None = None
|
||||
# Vide ou blanc = plus de message.
|
||||
volunteer_message: str | None = Field(default=None, max_length=MESSAGE_MAX_LENGTH)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Volunteer #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class VolunteerCreate(ApiModel):
|
||||
first_name: str
|
||||
last_name: str
|
||||
# Facultatifs à l'ajout manuel par la coordination (fiche complétée plus
|
||||
# tard) ; le formulaire public et l'import les exigent toujours.
|
||||
first_name: str = ""
|
||||
last_name: str = ""
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
comment: str | None = None
|
||||
@@ -67,6 +85,16 @@ class VolunteerUpdate(ApiModel):
|
||||
artist_ids: list[uuid.UUID] | None = None
|
||||
wished_position_ids: list[uuid.UUID] | None = None
|
||||
wished_companion_ids: list[uuid.UUID] | None = None
|
||||
# Issus du formulaire public (`app/api/forms.py`), éditables ensuite
|
||||
# depuis la fiche — voir CLAUDE.md § 2 (Volunteer).
|
||||
companion_wish_note: str | None = None
|
||||
# Texte libre du badge (champ `FREE_TEXT`), saisi par la coordination.
|
||||
badge_text: str | None = None
|
||||
# Exemplaires du badge sur les planches (1 à 100 ; garde-fou contre une
|
||||
# faute de frappe qui imprimerait des centaines de pages).
|
||||
badge_count: int | None = Field(default=None, ge=1, le=100)
|
||||
self_declared_available: bool | None = None
|
||||
willing_to_pay_fee: bool | None = None
|
||||
|
||||
|
||||
class VolunteerRead(ApiModel):
|
||||
@@ -80,10 +108,56 @@ class VolunteerRead(ApiModel):
|
||||
artist_ids: list[uuid.UUID]
|
||||
wished_position_ids: list[uuid.UUID]
|
||||
wished_companion_ids: list[uuid.UUID]
|
||||
has_photo: bool
|
||||
companion_wish_note: str | None
|
||||
badge_text: str | None
|
||||
badge_count: int
|
||||
self_declared_available: bool
|
||||
willing_to_pay_fee: bool
|
||||
# Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) —
|
||||
# `fee_payment_label`/`fee_amount` : copie figée du type de paiement
|
||||
# choisi (pas la config actuelle, qui peut avoir changé depuis).
|
||||
# `fee_payment_status` reste `None` tant qu'aucun checkout n'a jamais été
|
||||
# créé pour ce bénévole.
|
||||
fee_payment_label: str | None
|
||||
fee_amount: float | None
|
||||
fee_payment_status: FeePaymentStatus | None
|
||||
fee_paid_at: dt.datetime | None
|
||||
# Transaction SumUp ayant réglé la cotisation (code du tableau de bord
|
||||
# SumUp + identifiant technique) — `None` tant que non payée.
|
||||
fee_transaction_code: str | None
|
||||
fee_transaction_id: str | None
|
||||
# Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un
|
||||
# type de paiement « mécénat » (voir `FormPaymentOption.requires_mecenat_info`).
|
||||
mecenat_address: str | None
|
||||
mecenat_city: str | None
|
||||
mecenat_postal_code: str | None
|
||||
mecenat_country: str | None
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
class VolunteerPlanningEntry(ApiModel):
|
||||
"""Un créneau affecté, enrichi (poste, lieu, référent à contacter) — pour
|
||||
l'écran bénévole (`GET .../volunteers/{id}/planning`), sans jamais
|
||||
exposer la liste complète des postes/créneaux de l'édition à un compte
|
||||
VOLUNTEER (lecture restreinte à sa propre fiche, cf. § 2 matrice)."""
|
||||
|
||||
assignment_id: uuid.UUID
|
||||
shift_id: uuid.UUID
|
||||
starts_at: dt.datetime
|
||||
ends_at: dt.datetime
|
||||
position_name: str
|
||||
position_location: str | None
|
||||
# None si le poste n'a pas (encore) de référent, ou si son compte n'est
|
||||
# pas rattaché à une fiche bénévole (pas de téléphone à donner alors).
|
||||
referent_name: str | None
|
||||
referent_phone: str | None
|
||||
# Message du poste (voir `Position.message`).
|
||||
position_message: str | None
|
||||
position_message_updated_at: dt.datetime | None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Artist (programmation) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -128,6 +202,8 @@ class PositionUpdate(ApiModel):
|
||||
location: str | None = None
|
||||
category: PositionCategory | None = None
|
||||
lead_account_id: uuid.UUID | None = None
|
||||
# Vide ou blanc = plus de message.
|
||||
message: str | None = Field(default=None, max_length=MESSAGE_MAX_LENGTH)
|
||||
|
||||
|
||||
class PositionRead(ApiModel):
|
||||
@@ -137,6 +213,8 @@ class PositionRead(ApiModel):
|
||||
location: str | None
|
||||
category: PositionCategory
|
||||
lead_account_id: uuid.UUID | None
|
||||
message: str | None
|
||||
message_updated_at: dt.datetime | None
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
@@ -208,33 +286,6 @@ class DayPartUpdate(ApiModel):
|
||||
order: int | None = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Availability (disponibilités déclarées par le bénévole) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class AvailabilityWindowIn(ApiModel):
|
||||
date: dt.date
|
||||
start_time: dt.time
|
||||
# end_time <= start_time ⇒ la fenêtre se termine le lendemain.
|
||||
end_time: dt.time
|
||||
|
||||
|
||||
class AvailabilitySet(ApiModel):
|
||||
"""Remplace l'intégralité des disponibilités d'un bénévole."""
|
||||
|
||||
windows: list[AvailabilityWindowIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AvailabilityRead(ApiModel):
|
||||
id: uuid.UUID
|
||||
edition_id: uuid.UUID
|
||||
volunteer_id: uuid.UUID
|
||||
date: dt.date
|
||||
start_time: dt.time
|
||||
end_time: dt.time
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Assignment #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -257,10 +308,17 @@ class AssignmentRead(ApiModel):
|
||||
shift_id: uuid.UUID
|
||||
source: AssignmentSource
|
||||
locked: bool
|
||||
attendance: AttendanceStatus | None
|
||||
attendance_marked_at: dt.datetime | None
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
class AttendanceUpdate(ApiModel):
|
||||
# `None` = annuler le pointage.
|
||||
attendance: AttendanceStatus | None
|
||||
|
||||
|
||||
class AssignmentWriteResult(ApiModel):
|
||||
assignment: AssignmentRead
|
||||
warnings: list[Violation] = Field(default_factory=list)
|
||||
@@ -316,6 +374,77 @@ class MatrixResponse(ApiModel):
|
||||
positions: list[MatrixPosition]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Badges (planches PDF) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class BadgeFieldIn(ApiModel):
|
||||
face: BadgeFace = BadgeFace.FRONT
|
||||
field_type: BadgeFieldType
|
||||
x_mm: float
|
||||
y_mm: float
|
||||
width_mm: float
|
||||
height_mm: float
|
||||
font_size: float = 10.0
|
||||
align: BadgeAlign = BadgeAlign.LEFT
|
||||
bold: bool = False
|
||||
color: str = "#000000"
|
||||
# `FREE_TEXT` seulement (voir `BadgeField.text`), ignoré sinon.
|
||||
text: str | None = None
|
||||
# Fond de la zone (`#rrggbb`), `None` = transparent — voir `BadgeField`.
|
||||
bg_color: str | None = None
|
||||
auto_fit: bool = False
|
||||
# `PHOTO` seulement, ignoré sinon.
|
||||
photo_shape: BadgePhotoShape = BadgePhotoShape.RECT
|
||||
# Ordre d'empilement sur la face (voir `BadgeField.z_order`).
|
||||
z_order: int = 0
|
||||
|
||||
|
||||
class BadgeFieldsSet(ApiModel):
|
||||
"""Remplace l'intégralité des champs du recto et du verso — au plus un
|
||||
par (`face`, `field_type`) (décocher un champ dans l'éditeur retire sa
|
||||
ligne)."""
|
||||
|
||||
fields: list[BadgeFieldIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BadgeFieldRead(BadgeFieldIn):
|
||||
id: uuid.UUID
|
||||
edition_id: uuid.UUID
|
||||
|
||||
|
||||
class BadgeTemplateRead(ApiModel):
|
||||
edition_id: uuid.UUID
|
||||
has_front_image: bool
|
||||
has_back_image: bool
|
||||
# Dérivée de l'image du recto (voir `app/badges/pdf.py:image_size_mm`) ;
|
||||
# format CR80 par défaut tant qu'aucune image n'est importée.
|
||||
badge_width_mm: float
|
||||
badge_height_mm: float
|
||||
fields: list[BadgeFieldRead]
|
||||
|
||||
|
||||
class BadgeSizeUpdate(ApiModel):
|
||||
"""Ajuste la taille du badge indépendamment d'un nouvel import d'image —
|
||||
ex. agrandir proportionnellement une image importée trop petite (voir
|
||||
`app/api/badges.py:set_size`). Écrase la taille déduite de l'image."""
|
||||
|
||||
badge_width_mm: float
|
||||
badge_height_mm: float
|
||||
|
||||
|
||||
class BadgeGenerateRequest(ApiModel):
|
||||
# None/absent = tous les bénévoles de l'édition.
|
||||
volunteer_ids: list[uuid.UUID] | None = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Feuilles de poste (PDF) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class PositionSheetsRequest(ApiModel):
|
||||
# None/absent = tous les postes de l'édition.
|
||||
position_ids: list[uuid.UUID] | None = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Authentification et comptes #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -363,6 +492,12 @@ class AccountCreate(ApiModel):
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class AccountUpdate(ApiModel):
|
||||
# Seul le rôle est modifiable, et seulement entre LEAD et VOLUNTEER (pas
|
||||
# de mot de passe en jeu) — voir `api/accounts.py:update_account`.
|
||||
role: Role | None = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Notifications par email #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -379,3 +514,322 @@ class PlanningNotificationResult(ApiModel):
|
||||
queued: int
|
||||
skipped_no_email: int
|
||||
recipients: list[uuid.UUID] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TestEmailRequest(ApiModel):
|
||||
email: str
|
||||
# Modèle du récapitulatif de planning en cours d'édition (mêmes
|
||||
# variables que `PlanningNotificationRequest`) ; absent = modèle par
|
||||
# défaut.
|
||||
subject: str | None = None
|
||||
body: str | None = None
|
||||
# Bénévole dont les données remplissent les variables ; absent = le
|
||||
# premier (par nom) qui a au moins un créneau.
|
||||
volunteer_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class TestEmailResult(ApiModel):
|
||||
"""`status` : `sent` / `disabled` / `connection_failed` / `rejected` —
|
||||
voir `app/email/sender.py:deliver_test`. `detail` : message du serveur
|
||||
SMTP en cas d'échec."""
|
||||
|
||||
status: str
|
||||
detail: str | None = None
|
||||
smtp_host: str
|
||||
smtp_from: str
|
||||
# Bénévole dont les données ont rempli le modèle (`None` : exemple
|
||||
# fictif, l'édition n'a encore aucun bénévole).
|
||||
example_volunteer_id: uuid.UUID | None = None
|
||||
example_volunteer_name: str
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Formulaire public d'inscription #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class FormFieldsSet(ApiModel):
|
||||
"""Remplace l'intégralité des champs activés du formulaire — nom/prénom
|
||||
toujours collectés, pas dans cette liste (voir `FormFieldType`)."""
|
||||
|
||||
fields: list[FormFieldType] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FormTemplateRead(ApiModel):
|
||||
edition_id: uuid.UUID
|
||||
is_open: bool
|
||||
fields: list[FormFieldType]
|
||||
has_logo: bool
|
||||
description: str | None
|
||||
sumup_merchant_code: str | None
|
||||
# `sumup_api_key` (secret) n'est jamais renvoyé — seulement s'il est
|
||||
# défini, comme `has_logo` pour le logo.
|
||||
sumup_api_key_set: bool
|
||||
# Secret nécessaire pour atteindre le formulaire public
|
||||
# (`/inscription/<access_token>`, voir CLAUDE.md § 2, `FormTemplate`) —
|
||||
# exposé seulement ici (COORDINATION), jamais par `PublicFormConfig`.
|
||||
access_token: str
|
||||
|
||||
|
||||
class FormTemplateUpdate(ApiModel):
|
||||
"""`None`/absent = inchangé pour chaque champ (`exclude_unset`) — permet
|
||||
de changer `is_open` sans toucher `description`/`sumup_*`, etc. Chaîne
|
||||
vide sur `sumup_api_key` = efface le secret enregistré."""
|
||||
|
||||
is_open: bool | None = None
|
||||
description: str | None = None
|
||||
sumup_merchant_code: str | None = None
|
||||
sumup_api_key: str | None = None
|
||||
|
||||
|
||||
class FormPaymentOptionRead(ApiModel):
|
||||
id: uuid.UUID
|
||||
edition_id: uuid.UUID
|
||||
label: str
|
||||
amount: float
|
||||
order: int
|
||||
requires_mecenat_info: bool
|
||||
free_amount: bool
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
class FormPaymentOptionCreate(ApiModel):
|
||||
label: str
|
||||
amount: float
|
||||
order: int = 0
|
||||
requires_mecenat_info: bool = False
|
||||
free_amount: bool = False
|
||||
|
||||
|
||||
class FormPaymentOptionUpdate(ApiModel):
|
||||
label: str | None = None
|
||||
amount: float | None = None
|
||||
order: int | None = None
|
||||
requires_mecenat_info: bool | None = None
|
||||
free_amount: bool | None = None
|
||||
|
||||
|
||||
class PublicPositionOption(ApiModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
|
||||
|
||||
class PublicArtistOption(ApiModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
performance_date: dt.date
|
||||
|
||||
|
||||
class PublicPaymentOption(ApiModel):
|
||||
"""Type de paiement proposé au bénévole sur le formulaire public — un
|
||||
montant n'est pas un secret, contrairement aux identifiants SumUp."""
|
||||
|
||||
id: uuid.UUID
|
||||
label: str
|
||||
# Montant fixe, ou minimum si `free_amount`.
|
||||
amount: float
|
||||
requires_mecenat_info: bool
|
||||
free_amount: bool
|
||||
|
||||
|
||||
class PublicFormConfig(ApiModel):
|
||||
"""Tout ce qu'il faut pour afficher le formulaire public — jamais plus :
|
||||
ni les bénévoles déjà inscrits, ni les créneaux/affectations de
|
||||
l'édition (voir `app/api/forms.py:get_public_form`)."""
|
||||
|
||||
is_open: bool
|
||||
fields: list[FormFieldType]
|
||||
positions: list[PublicPositionOption]
|
||||
artists: list[PublicArtistOption]
|
||||
has_logo: bool
|
||||
description: str | None
|
||||
# `True` si l'édition a configuré SumUp (identifiants marchand + au
|
||||
# moins un type de paiement) — utilisé par le front-end pour afficher le
|
||||
# choix de paiement près de `WILLING_TO_PAY_FEE`, sans jamais exposer les
|
||||
# identifiants eux-mêmes.
|
||||
sumup_enabled: bool
|
||||
payment_options: list[PublicPaymentOption]
|
||||
|
||||
|
||||
class FormSubmissionResult(ApiModel):
|
||||
"""Trois formes possibles, selon le choix de paiement (voir
|
||||
`app/api/forms.py:submit_public_form`) :
|
||||
- aucun paiement en ligne choisi (case désactivée, ou « je paierai sur
|
||||
place ») : `volunteer_id` seul, le bénévole est déjà en base ;
|
||||
- paiement en ligne, type « mécénat » : `pending_token` +
|
||||
`requires_mecenat_info=True`, rien en base encore — direction l'étape
|
||||
d'adresse (`submit_mecenat_info`) ;
|
||||
- paiement en ligne, type simple : `pending_token` + `sumup_checkout_url`,
|
||||
direction la page de paiement SumUp. Toujours rien en base tant que le
|
||||
paiement n'est pas confirmé (voir `PendingFormSubmission`)."""
|
||||
|
||||
volunteer_id: uuid.UUID | None = None
|
||||
pending_token: uuid.UUID | None = None
|
||||
requires_mecenat_info: bool = False
|
||||
sumup_checkout_url: str | None = None
|
||||
|
||||
|
||||
class MecenatInfoSubmit(ApiModel):
|
||||
"""Étape d'adresse postale pour un type de paiement « mécénat »
|
||||
(`POST .../form/public/pending/{token}/mecenat-info`) — tous obligatoires,
|
||||
nécessaires à l'édition d'un reçu fiscal (cerfa)."""
|
||||
|
||||
address: str
|
||||
city: str
|
||||
postal_code: str
|
||||
country: str
|
||||
|
||||
|
||||
class PendingSubmissionStatus(ApiModel):
|
||||
"""Réponse de `GET .../form/public/pending/{token}` — interrogée en
|
||||
direct par la page de confirmation jusqu'à ce que le statut ne soit plus
|
||||
`PENDING`. Relit toujours l'état réel auprès de SumUp (jamais seulement
|
||||
la dernière valeur connue), comme le webhook."""
|
||||
|
||||
status: FeePaymentStatus
|
||||
label: str
|
||||
amount: float
|
||||
volunteer_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class SumUpWebhookPayload(ApiModel):
|
||||
"""Corps envoyé par SumUp à `return_url` — voir la doc SumUp sur les
|
||||
webhooks. Le seul événement existant à ce jour."""
|
||||
|
||||
event_type: str
|
||||
id: str
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Statistiques d'une édition (`GET /editions/{id}/stats`, app/api/stats.py) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class StatsRegistrationDay(ApiModel):
|
||||
date: dt.date
|
||||
count: int
|
||||
cumulative: int
|
||||
|
||||
|
||||
class StatsRegistrations(ApiModel):
|
||||
"""Bénévoles en base, par jour d'ajout (`Volunteer.created_at`) — jours
|
||||
sans inscription inclus (à 0), pour une courbe sans trou."""
|
||||
|
||||
total: int
|
||||
last_7_days: int
|
||||
by_day: list[StatsRegistrationDay]
|
||||
|
||||
|
||||
class StatsAnswers(ApiModel):
|
||||
"""Nombre de bénévoles ayant renseigné chaque information (sur `total`)."""
|
||||
|
||||
total: int
|
||||
email: int
|
||||
phone: int
|
||||
photo: int
|
||||
comment: int
|
||||
companion_note: int
|
||||
available: int
|
||||
position_wishes: int
|
||||
artist_wishes: int
|
||||
companion_wishes: int
|
||||
|
||||
|
||||
class StatsPaymentOption(ApiModel):
|
||||
label: str
|
||||
mecenat: bool
|
||||
count: int
|
||||
amount_total: float
|
||||
|
||||
|
||||
class StatsPayments(ApiModel):
|
||||
"""Mode de paiement (même règle que `lib/fee.ts:feeMode`) : `online`
|
||||
(paiement en ligne confirmé), `on_site`, `none`. `pending_unfinished` :
|
||||
formulaires remplis dont le paiement en ligne n'a jamais abouti — aucun
|
||||
bénévole créé pour eux (`PendingFormSubmission` non promue)."""
|
||||
|
||||
online: int
|
||||
on_site: int
|
||||
none: int
|
||||
pending_unfinished: int
|
||||
total_collected: float
|
||||
mecenat_count: int
|
||||
mecenat_total: float
|
||||
by_option: list[StatsPaymentOption]
|
||||
|
||||
|
||||
class StatsArtistWish(ApiModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
date: dt.date
|
||||
count: int
|
||||
|
||||
|
||||
class StatsNeedDay(ApiModel):
|
||||
"""Par journée de festival (`validation/engine.py:festival_day`) : heures
|
||||
× personnes demandées (effectif minimal / idéal), heures déjà affectées."""
|
||||
|
||||
date: dt.date
|
||||
shifts: int
|
||||
min_hours: float
|
||||
ideal_hours: float
|
||||
assigned_hours: float
|
||||
|
||||
|
||||
class StatsNeedPosition(ApiModel):
|
||||
"""Places (personnes × créneaux) demandées et pourvues pour un poste, et
|
||||
nombre de bénévoles qui l'ont mis dans leurs vœux."""
|
||||
|
||||
position_id: uuid.UUID
|
||||
name: str
|
||||
shifts: int
|
||||
min_slots: int
|
||||
ideal_slots: int
|
||||
assigned: int
|
||||
# Places pourvues, plafonnées créneau par créneau à l'effectif idéal : un
|
||||
# sureffectif ne compense jamais un trou ailleurs.
|
||||
filled: int
|
||||
wishes: int
|
||||
|
||||
|
||||
class StatsNeeds(ApiModel):
|
||||
by_day: list[StatsNeedDay]
|
||||
by_position: list[StatsNeedPosition]
|
||||
volunteers_with_assignment: int
|
||||
|
||||
|
||||
class StatsAttendanceCounts(ApiModel):
|
||||
"""Pointage (`Assignment.attendance`) d'un ensemble d'affectations :
|
||||
`assigned` = `present` + `absent` + `unmarked`."""
|
||||
|
||||
assigned: int = 0
|
||||
present: int = 0
|
||||
absent: int = 0
|
||||
unmarked: int = 0
|
||||
|
||||
|
||||
class StatsAttendancePosition(ApiModel):
|
||||
position_id: uuid.UUID
|
||||
name: str
|
||||
# Créneaux terminés (`ends_at <= as_of`) et créneaux en cours.
|
||||
past: StatsAttendanceCounts
|
||||
live: StatsAttendanceCounts
|
||||
live_shifts: int
|
||||
|
||||
|
||||
class StatsAttendance(ApiModel):
|
||||
"""Présents / absents pointés par les référents, par poste : sur les
|
||||
créneaux déjà terminés et sur ceux en cours à `as_of` (heure du calcul,
|
||||
locale). Les créneaux à venir ne comptent pas."""
|
||||
|
||||
as_of: dt.datetime
|
||||
past: StatsAttendanceCounts
|
||||
live: StatsAttendanceCounts
|
||||
live_shifts: int
|
||||
by_position: list[StatsAttendancePosition]
|
||||
|
||||
|
||||
class EditionStats(ApiModel):
|
||||
registrations: StatsRegistrations
|
||||
answers: StatsAnswers
|
||||
payments: StatsPayments
|
||||
artist_wishes: list[StatsArtistWish]
|
||||
needs: StatsNeeds
|
||||
attendance: StatsAttendance
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
+230
-66
@@ -5,24 +5,42 @@ L'email n'est pas unique : plusieurs bénévoles peuvent en partager un même
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api._common import owned_or_404, replace_links
|
||||
from app.api._common import (
|
||||
owned_or_404,
|
||||
read_photo,
|
||||
replace_links,
|
||||
resolve_owned_ids,
|
||||
set_artists,
|
||||
set_wished_positions,
|
||||
)
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import VolunteerCreate, VolunteerRead, VolunteerUpdate
|
||||
from app.api.schemas import (
|
||||
VolunteerCreate,
|
||||
VolunteerPlanningEntry,
|
||||
VolunteerRead,
|
||||
VolunteerUpdate,
|
||||
)
|
||||
from app.auth.deps import (
|
||||
current_account_in_edition,
|
||||
require_coordinator,
|
||||
require_staff,
|
||||
)
|
||||
from app.db import get_session
|
||||
from app.exporting import generate_volunteers_xlsx
|
||||
from app.mecenat import assign_receipt_number, generate_mecenat_receipts_pdf, receipt_for
|
||||
from app.models import (
|
||||
Account,
|
||||
Artist,
|
||||
Assignment,
|
||||
Edition,
|
||||
FeePaymentStatus,
|
||||
Position,
|
||||
Role,
|
||||
Shift,
|
||||
Volunteer,
|
||||
VolunteerArtist,
|
||||
VolunteerCompanionWish,
|
||||
@@ -85,74 +103,20 @@ def _companion_ids(session: Session, volunteer_id: uuid.UUID) -> list[uuid.UUID]
|
||||
|
||||
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
|
||||
return VolunteerRead(
|
||||
**volunteer.model_dump(),
|
||||
**volunteer.model_dump(
|
||||
exclude={"photo", "photo_content_type", "fee_checkout_id"}
|
||||
),
|
||||
artist_ids=_artist_ids(session, volunteer.id),
|
||||
wished_position_ids=_wished_position_ids(session, volunteer.id),
|
||||
wished_companion_ids=_companion_ids(session, volunteer.id),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_owned_ids(
|
||||
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID], code: str
|
||||
) -> list[uuid.UUID]:
|
||||
"""Ordre d'entrée conservé, doublons retirés ; 422 si un id est étranger."""
|
||||
wanted = list(dict.fromkeys(ids))
|
||||
if wanted:
|
||||
found = set(
|
||||
session.exec(
|
||||
select(model.id).where(
|
||||
model.edition_id == edition_id,
|
||||
model.id.in_(wanted),
|
||||
)
|
||||
)
|
||||
)
|
||||
if len(found) != len(wanted):
|
||||
raise HTTPException(status_code=422, detail={"code": code})
|
||||
return wanted
|
||||
|
||||
|
||||
def _set_artists(
|
||||
session: Session, volunteer: Volunteer, artist_ids: list[uuid.UUID]
|
||||
) -> None:
|
||||
ids = _resolve_owned_ids(
|
||||
session, Artist, volunteer.edition_id, artist_ids, "artist.unknown"
|
||||
)
|
||||
replace_links(
|
||||
session,
|
||||
VolunteerArtist,
|
||||
filter_column=VolunteerArtist.volunteer_id,
|
||||
filter_value=volunteer.id,
|
||||
pairs=[
|
||||
{"volunteer_id": volunteer.id, "artist_id": aid} for aid in ids
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _set_wished_positions(
|
||||
session: Session, volunteer: Volunteer, position_ids: list[uuid.UUID]
|
||||
) -> None:
|
||||
ids = _resolve_owned_ids(
|
||||
session,
|
||||
Position,
|
||||
volunteer.edition_id,
|
||||
position_ids,
|
||||
"position.unknown",
|
||||
)
|
||||
replace_links(
|
||||
session,
|
||||
VolunteerPositionWish,
|
||||
filter_column=VolunteerPositionWish.volunteer_id,
|
||||
filter_value=volunteer.id,
|
||||
pairs=[
|
||||
{"volunteer_id": volunteer.id, "position_id": pid} for pid in ids
|
||||
],
|
||||
has_photo=volunteer.photo is not None,
|
||||
)
|
||||
|
||||
|
||||
def _set_companions(
|
||||
session: Session, volunteer: Volunteer, companion_ids: list[uuid.UUID]
|
||||
) -> None:
|
||||
ids = _resolve_owned_ids(
|
||||
ids = resolve_owned_ids(
|
||||
session, Volunteer, volunteer.edition_id, companion_ids, "volunteer.unknown"
|
||||
)
|
||||
if volunteer.id in ids:
|
||||
@@ -177,6 +141,8 @@ def create_volunteer(
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> VolunteerRead:
|
||||
payload.first_name = payload.first_name.title()
|
||||
payload.last_name = payload.last_name.title()
|
||||
volunteer = Volunteer(
|
||||
edition_id=edition.id,
|
||||
**payload.model_dump(
|
||||
@@ -185,8 +151,8 @@ def create_volunteer(
|
||||
)
|
||||
session.add(volunteer)
|
||||
session.flush()
|
||||
_set_artists(session, volunteer, payload.artist_ids)
|
||||
_set_wished_positions(session, volunteer, payload.wished_position_ids)
|
||||
set_artists(session, volunteer, payload.artist_ids)
|
||||
set_wished_positions(session, volunteer, payload.wished_position_ids)
|
||||
_set_companions(session, volunteer, payload.wished_companion_ids)
|
||||
session.commit()
|
||||
session.refresh(volunteer)
|
||||
@@ -207,6 +173,100 @@ def list_volunteers(
|
||||
return [_read(session, v) for v in volunteers]
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_volunteers(
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
"""Export Excel de la liste des bénévoles — vœux (postes, artistes,
|
||||
compagnons) résolus en noms lisibles, pas en UUID. Déclaré avant
|
||||
`/{volunteer_id}` : un chemin statique doit être enregistré avant le
|
||||
chemin dynamique qui le suivrait sinon FastAPI essaierait de parser
|
||||
« export » comme un UUID."""
|
||||
volunteers_orm = list(
|
||||
session.exec(
|
||||
select(Volunteer)
|
||||
.where(Volunteer.edition_id == edition.id)
|
||||
.order_by(Volunteer.last_name, Volunteer.first_name)
|
||||
)
|
||||
)
|
||||
volunteers = [_read(session, v) for v in volunteers_orm]
|
||||
position_names = {
|
||||
p.id: p.name
|
||||
for p in session.exec(
|
||||
select(Position).where(Position.edition_id == edition.id)
|
||||
)
|
||||
}
|
||||
artist_names = {
|
||||
a.id: a.name
|
||||
for a in session.exec(select(Artist).where(Artist.edition_id == edition.id))
|
||||
}
|
||||
volunteer_names = {
|
||||
v.id: f"{v.first_name} {v.last_name}" for v in volunteers_orm
|
||||
}
|
||||
|
||||
content = generate_volunteers_xlsx(
|
||||
volunteers,
|
||||
position_names=position_names,
|
||||
artist_names=artist_names,
|
||||
volunteer_names=volunteer_names,
|
||||
)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
),
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="benevoles-{edition.year}.xlsx"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/mecenat-receipts")
|
||||
def export_mecenat_receipts(
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
"""Reçus fiscaux (cerfa 11580*05, `app/mecenat/pdf.py`) des bénévoles
|
||||
ayant payé un type de paiement « mécénat » — repérés par
|
||||
`mecenat_address` renseigné (voir CLAUDE.md § 2, Paiement de la
|
||||
cotisation : ce champ n'est rempli que pour ce chemin-là). Déclaré avant
|
||||
`/{volunteer_id}`, comme `/export` juste au-dessus."""
|
||||
volunteers = list(
|
||||
session.exec(
|
||||
select(Volunteer)
|
||||
.where(Volunteer.edition_id == edition.id)
|
||||
.where(Volunteer.fee_payment_status == FeePaymentStatus.PAID)
|
||||
.where(Volunteer.mecenat_address.is_not(None))
|
||||
.order_by(Volunteer.last_name, Volunteer.first_name)
|
||||
)
|
||||
)
|
||||
if not volunteers:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "mecenat.no_receipts"}
|
||||
)
|
||||
|
||||
# Bénévoles payés avant la numérotation (ou jamais exportés) : numéro
|
||||
# attribué maintenant, dans l'ordre des paiements, puis figé.
|
||||
unnumbered = [v for v in volunteers if not v.mecenat_receipt_number]
|
||||
if unnumbered:
|
||||
for v in sorted(unnumbered, key=lambda v: v.fee_paid_at or v.created_at):
|
||||
assign_receipt_number(session, v)
|
||||
session.commit()
|
||||
|
||||
receipts = [receipt_for(v) for v in volunteers]
|
||||
content = generate_mecenat_receipts_pdf(receipts)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="recus-mecenat-{edition.year}.pdf"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{volunteer_id}", response_model=VolunteerRead)
|
||||
def get_volunteer(
|
||||
volunteer_id: uuid.UUID,
|
||||
@@ -219,6 +279,110 @@ def get_volunteer(
|
||||
return _read(session, volunteer)
|
||||
|
||||
|
||||
@router.get("/{volunteer_id}/planning", response_model=list[VolunteerPlanningEntry])
|
||||
def get_volunteer_planning(
|
||||
volunteer_id: uuid.UUID,
|
||||
edition: Edition = Depends(get_edition),
|
||||
account: Account = Depends(current_account_in_edition),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[VolunteerPlanningEntry]:
|
||||
"""Planning enrichi (poste, lieu, référent + téléphone à contacter) d'un
|
||||
bénévole — permet à l'écran bénévole (`/mobile-benevole`) d'afficher ces
|
||||
infos sans jamais donner à un compte VOLUNTEER un accès en lecture aux
|
||||
postes/créneaux de toute l'édition (réservé à COORDINATOR/LEAD)."""
|
||||
_ensure_can_read(account, volunteer_id)
|
||||
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||
|
||||
Referent = aliased(Volunteer)
|
||||
rows = session.exec(
|
||||
select(Assignment, Shift, Position, Referent)
|
||||
.join(Shift, Shift.id == Assignment.shift_id)
|
||||
.join(Position, Position.id == Shift.position_id)
|
||||
.join(Account, Account.id == Position.lead_account_id, isouter=True)
|
||||
.join(Referent, Referent.id == Account.volunteer_id, isouter=True)
|
||||
.where(
|
||||
Assignment.volunteer_id == volunteer_id,
|
||||
Assignment.edition_id == edition.id,
|
||||
)
|
||||
.order_by(Shift.starts_at)
|
||||
).all()
|
||||
|
||||
return [
|
||||
VolunteerPlanningEntry(
|
||||
assignment_id=a.id,
|
||||
shift_id=s.id,
|
||||
starts_at=s.starts_at,
|
||||
ends_at=s.ends_at,
|
||||
position_name=p.name,
|
||||
position_location=p.location,
|
||||
referent_name=f"{r.first_name} {r.last_name}" if r else None,
|
||||
referent_phone=r.phone if r else None,
|
||||
position_message=p.message,
|
||||
position_message_updated_at=p.message_updated_at,
|
||||
)
|
||||
for a, s, p, r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{volunteer_id}/photo")
|
||||
def get_volunteer_photo(
|
||||
volunteer_id: uuid.UUID,
|
||||
edition: Edition = Depends(get_edition),
|
||||
account: Account = Depends(current_account_in_edition),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
"""Photo transmise par le bénévole via le formulaire public
|
||||
(`app/api/forms.py`) — COORDINATION/RÉFÉRENT (ex. écran référent, en
|
||||
face du nom de chaque membre de l'équipe) ou le bénévole lui-même (écran
|
||||
bénévole)."""
|
||||
_ensure_can_read(account, volunteer_id)
|
||||
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||
if volunteer.photo is None:
|
||||
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
|
||||
return Response(
|
||||
content=volunteer.photo,
|
||||
media_type=volunteer.photo_content_type or "application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{volunteer_id}/photo", response_model=VolunteerRead)
|
||||
async def set_volunteer_photo(
|
||||
volunteer_id: uuid.UUID,
|
||||
file: UploadFile = File(...),
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> VolunteerRead:
|
||||
"""Ajout (ou remplacement) de la photo par la coordination, depuis la
|
||||
fiche — pour un bénévole qui n'en a pas envoyé au formulaire public ou
|
||||
ajouté à la main. Même validation que le formulaire (`read_photo`)."""
|
||||
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||
photo = await read_photo(file)
|
||||
if photo is None:
|
||||
raise HTTPException(status_code=400, detail={"code": "volunteer.empty_photo"})
|
||||
volunteer.photo, volunteer.photo_content_type = photo
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
session.refresh(volunteer)
|
||||
return _read(session, volunteer)
|
||||
|
||||
|
||||
@router.delete("/{volunteer_id}/photo", response_model=VolunteerRead)
|
||||
def delete_volunteer_photo(
|
||||
volunteer_id: uuid.UUID,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> VolunteerRead:
|
||||
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||
volunteer.photo = None
|
||||
volunteer.photo_content_type = None
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
session.refresh(volunteer)
|
||||
return _read(session, volunteer)
|
||||
|
||||
|
||||
@router.patch("/{volunteer_id}", response_model=VolunteerRead)
|
||||
def update_volunteer(
|
||||
volunteer_id: uuid.UUID,
|
||||
@@ -236,9 +400,9 @@ def update_volunteer(
|
||||
setattr(volunteer, field, value)
|
||||
session.flush()
|
||||
if artist_ids is not None:
|
||||
_set_artists(session, volunteer, artist_ids)
|
||||
set_artists(session, volunteer, artist_ids)
|
||||
if wished_position_ids is not None:
|
||||
_set_wished_positions(session, volunteer, wished_position_ids)
|
||||
set_wished_positions(session, volunteer, wished_position_ids)
|
||||
if wished_companion_ids is not None:
|
||||
_set_companions(session, volunteer, wished_companion_ids)
|
||||
session.commit()
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Limiteur de tentatives, en mémoire — protège `/auth/login` (force brute
|
||||
sur le mot de passe COORDINATION) et `/auth/magic-link` (spam d'emails vers
|
||||
une victime, ou vers le SMTP) faute d'un reverse-proxy qui le ferait à notre
|
||||
place.
|
||||
|
||||
Clé par **email seul**, pas par IP : le service tourne derrière Caddy sur le
|
||||
VPS (voir `deploy/`), qui ne transmet pas l'IP réelle du client par défaut —
|
||||
il faudrait faire confiance à `X-Forwarded-For` en configurant un proxy de
|
||||
confiance, ce que ce déploiement ne fait pas encore. Limiter par email suffit
|
||||
au risque réel ici : il n'y a qu'un très petit nombre de comptes protégés par
|
||||
mot de passe (la coordination), donc l'attaque à contrer est « deviner le
|
||||
mot de passe d'un compte connu », pas une attaque distribuée sur des emails
|
||||
au hasard.
|
||||
|
||||
En mémoire, un seul processus uvicorn (voir `Dockerfile`, pas de
|
||||
`--workers`) : un compteur partagé entre threads (les endpoints sont
|
||||
synchrones, exécutés dans un pool de threads par Starlette) suffit, pas
|
||||
besoin de Redis pour ce volume."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(self, max_attempts: int, window_seconds: float) -> None:
|
||||
self.max_attempts = max_attempts
|
||||
self.window_seconds = window_seconds
|
||||
self._hits: dict[str, list[float]] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def hit(self, key: str) -> None:
|
||||
"""Enregistre une tentative pour `key` ; lève 429 si la limite du
|
||||
créneau glissant est dépassée."""
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
recent = [
|
||||
t for t in self._hits.get(key, []) if now - t < self.window_seconds
|
||||
]
|
||||
if len(recent) >= self.max_attempts:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail={"code": "error.too_many_requests"},
|
||||
)
|
||||
recent.append(now)
|
||||
self._hits[key] = recent
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Tests uniquement."""
|
||||
with self._lock:
|
||||
self._hits.clear()
|
||||
|
||||
|
||||
# 5 tentatives/minute : large pour un vrai utilisateur qui se trompe, serré
|
||||
# pour de la force brute (bcrypt ralentit déjà chaque essai, ceci coupe
|
||||
# l'automatisation par-dessus).
|
||||
login_limiter = RateLimiter(max_attempts=5, window_seconds=60)
|
||||
# 3 demandes/5 min : un bénévole qui n'a pas reçu son email réessaie deux ou
|
||||
# trois fois ; au-delà c'est soit un abus, soit un email qui n'arrivera pas
|
||||
# plus vite en insistant.
|
||||
magic_link_limiter = RateLimiter(max_attempts=3, window_seconds=300)
|
||||
# Formulaire public d'inscription (`app/api/forms.py`) : pas d'identité
|
||||
# fiable à clé (n'importe qui peut soumettre des emails différents à chaque
|
||||
# fois, et l'IP réelle du client n'est pas encore transmise par Caddy — voir
|
||||
# plus haut) ; hCaptcha est la défense principale contre les robots, ceci
|
||||
# n'est qu'un filet de sécurité global côté édition contre un emballement
|
||||
# malgré tout (30 inscriptions/5 min est très large pour un usage réel).
|
||||
form_submission_limiter = RateLimiter(max_attempts=30, window_seconds=300)
|
||||
# Interrogation du statut d'une inscription en attente de paiement
|
||||
# (`GET .../form/public/pending/{token}`) : relit toujours l'état réel
|
||||
# auprès de SumUp (voir `app/api/forms.py:get_pending_status`), donc chaque
|
||||
# appel coûte une requête à l'API SumUp de l'édition. Clé par jeton (pas par
|
||||
# édition, contrairement à `form_submission_limiter`) : plusieurs
|
||||
# inscriptions en cours en même temps ne doivent pas s'épuiser mutuellement
|
||||
# leur quota de sondage. 60/5 min couvre largement le sondage légitime (la
|
||||
# page de confirmation interroge toutes les 3 s, jusqu'à 40 fois — voir
|
||||
# `PublicSignupPage.tsx`), tout en bornant un sondage en boucle serrée qui
|
||||
# n'aurait d'autre effet que de solliciter inutilement l'API SumUp.
|
||||
pending_status_limiter = RateLimiter(max_attempts=60, window_seconds=300)
|
||||
@@ -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()
|
||||
+35
-1
@@ -1,7 +1,14 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Valeur de développement uniquement (versionnée dans `.env.example`) : un
|
||||
# secret connu de quiconque lit le dépôt ne protège plus rien. Voir le
|
||||
# contrôle dans `Settings.check_production_secrets`.
|
||||
_DEV_JWT_SECRET = "dev-insecure-secret-change-me-please-32b"
|
||||
_MIN_JWT_SECRET_LENGTH = 32
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configuration applicative, alimentée par l'environnement."""
|
||||
@@ -20,7 +27,7 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
# Authentification (lien magique + session JWT). Secret >= 32 octets.
|
||||
jwt_secret: str = "dev-insecure-secret-change-me-please-32b"
|
||||
jwt_secret: str = _DEV_JWT_SECRET
|
||||
magic_link_ttl_minutes: int = 30
|
||||
session_ttl_hours: int = 12
|
||||
|
||||
@@ -42,6 +49,12 @@ class Settings(BaseSettings):
|
||||
# (ou un alias validé). En cas de rejet, mettre SMTP_FROM = SMTP_USER.
|
||||
smtp_from: str = "Festival des bénévoles <no-reply@example.test>"
|
||||
|
||||
# Formulaire public d'inscription (`app/api/forms.py`). Secret hCaptcha
|
||||
# seulement (la clé de site est publique, elle vit côté front-end en
|
||||
# VITE_HCAPTCHA_SITE_KEY, figée au build). None ⇒ vérification
|
||||
# désactivée (dev/tests uniquement, voir `app/forms/captcha.py`).
|
||||
hcaptcha_secret_key: str | None = None
|
||||
|
||||
log_level: str = "INFO"
|
||||
cors_origins: list[str] = ["http://localhost:5173"]
|
||||
|
||||
@@ -49,6 +62,27 @@ class Settings(BaseSettings):
|
||||
def is_production(self) -> bool:
|
||||
return self.environment == "production"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_production_secrets(self) -> "Settings":
|
||||
"""Un secret JWT par défaut, vide ou trop court n'importe pas en dev
|
||||
(confort), mais en production il permettrait de forger un jeton pour
|
||||
n'importe quel compte (y compris COORDINATION) — donc de fabriquer
|
||||
soi-même un jeton valide sans jamais s'authentifier. On refuse de
|
||||
démarrer plutôt que de tourner silencieusement sans protection."""
|
||||
if not self.is_production:
|
||||
return self
|
||||
if (
|
||||
not self.jwt_secret
|
||||
or self.jwt_secret == _DEV_JWT_SECRET
|
||||
or len(self.jwt_secret) < _MIN_JWT_SECRET_LENGTH
|
||||
):
|
||||
raise ValueError(
|
||||
"JWT_SECRET manquant, par défaut ou trop court (>= "
|
||||
f"{_MIN_JWT_SECRET_LENGTH} caractères) en production. "
|
||||
"Générer une vraie valeur : openssl rand -hex 32"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -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)
|
||||
@@ -5,11 +5,13 @@
|
||||
"error.unauthorized": "Authentification requise",
|
||||
"error.validation_failed": "La validation a échoué",
|
||||
"error.conflict": "Conflit avec une ressource existante.",
|
||||
"error.too_many_requests": "Trop de tentatives, réessayez dans un instant",
|
||||
"edition.dates_inconsistent": "La date de fin doit être postérieure ou égale à la date de début.",
|
||||
"day_part.code_required": "Le code du créneau type ne peut pas être vide.",
|
||||
"day_part.duplicate_code": "Un créneau type porte déjà ce code dans l'édition.",
|
||||
"shift.ends_not_after_starts": "La fin du créneau doit être postérieure à son début.",
|
||||
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition.",
|
||||
"position.lead_must_be_referent_account": "Le référent d'un poste doit être un compte référent (rôle LEAD) de cette édition.",
|
||||
"artist.name_required": "Le nom de l'artiste ne peut pas être vide.",
|
||||
"artist.duplicate": "Un artiste porte déjà ce nom à cette date dans l'édition.",
|
||||
"artist.unknown": "Un des artistes choisis n'appartient pas à cette édition.",
|
||||
@@ -25,6 +27,7 @@
|
||||
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition.",
|
||||
"account.password_required": "Un compte coordination requiert un mot de passe d'au moins {min} caractères.",
|
||||
"account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe.",
|
||||
"account.role_change_not_allowed": "Un compte coordination ne peut pas changer de rôle ainsi.",
|
||||
"import.error.empty_file": "Le fichier est vide.",
|
||||
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
|
||||
"import.error.unreadable_file": "Fichier illisible ou format non pris en charge.",
|
||||
@@ -40,16 +43,73 @@
|
||||
"import.row.updated_existing": "Bénévole existant mis à jour.",
|
||||
"import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l'édition.",
|
||||
"validation.SHIFT_OVERLAP": "Chevauchement avec un autre créneau du bénévole.",
|
||||
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées le {date} ({uncovered_minutes} min non couvertes).",
|
||||
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h d'affectation cumulées le {date}.",
|
||||
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux.",
|
||||
"notification.no_recipients": "Aucun bénévole ciblé.",
|
||||
"email.magic_link.subject": "Ton lien de connexion",
|
||||
"email.magic_link.body": "Bonjour,\n\nVoici ton lien de connexion, valable {minutes} minutes :\n\n{link}\n\nSi tu n'es pas à l'origine de cette demande, ignore simplement cet email.",
|
||||
"email.registration_confirmation.subject": "Ton inscription bénévole est confirmée",
|
||||
"email.registration_confirmation.body": "Bonjour {prenom},\n\nTon inscription comme bénévole a bien été enregistrée. La coordination du festival prendra contact avec toi si besoin.\n\nÀ bientôt !",
|
||||
"email.registration_confirmation_paid.subject": "Ton inscription bénévole est confirmée — paiement reçu",
|
||||
"email.registration_confirmation_paid.body": "Bonjour {prenom},\n\nTon inscription comme bénévole a bien été enregistrée, et nous confirmons la réception de ton paiement :\n\n{label} — {montant} €\n\nLa coordination du festival prendra contact avec toi si besoin.\n\nÀ bientôt !",
|
||||
"email.registration_confirmation_paid.mecenat_receipt_note": "Tu trouveras en pièce jointe ton reçu fiscal au titre des dons (cerfa n° 11580*05), à conserver pour ta déclaration de revenus.",
|
||||
"email.planning.default_subject": "Ton planning bénévole",
|
||||
"email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !",
|
||||
"email.test.subject_prefix": "[Test] {subject}",
|
||||
"email.test.example_first_name": "Camille",
|
||||
"email.test.example_last_name": "Exemple",
|
||||
"notifications.invalid_email": "Adresse email invalide.",
|
||||
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
|
||||
"email.planning.shift_line": "- {position} : {start} → {end}",
|
||||
"availability.date_outside_edition": "La date {date} est hors des dates de l'édition.",
|
||||
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes."
|
||||
"email.planning.table.time": "Horaire",
|
||||
"email.planning.table.position": "Poste",
|
||||
"email.planning.table.referent": "Référent",
|
||||
"email.planning.table.no_referent": "à désigner",
|
||||
"email.planning.table.text_line": " {time} · {position} · référent : {referent}",
|
||||
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
|
||||
"badge.image_unreadable": "L'image est illisible ou corrompue.",
|
||||
"badge.empty_image": "L'image est vide.",
|
||||
"badge.image_too_large": "L'image est trop volumineuse.",
|
||||
"badge.duplicate_field_type": "Le champ « {field_type} » ne peut apparaître qu'une fois.",
|
||||
"badge.invalid_size": "La taille du badge doit être strictement positive.",
|
||||
"badge.invalid_color": "La couleur « {color} » n'est pas valide (format attendu : #rrggbb).",
|
||||
"badge.no_fields": "Aucun champ n'est configuré sur le badge.",
|
||||
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
|
||||
"badge.role.referent": "Référent",
|
||||
"badge.role.volunteer": "Bénévole",
|
||||
"position_sheets.footer_title": "Message de l'organisation",
|
||||
"badge.table.day_label": "{weekday} {day}",
|
||||
"badge.weekday.0": "Lundi",
|
||||
"badge.weekday.1": "Mardi",
|
||||
"badge.weekday.2": "Mercredi",
|
||||
"badge.weekday.3": "Jeudi",
|
||||
"badge.weekday.4": "Vendredi",
|
||||
"badge.weekday.5": "Samedi",
|
||||
"badge.weekday.6": "Dimanche",
|
||||
"position.category.bar": "Bar",
|
||||
"position.category.ticketing": "Billetterie",
|
||||
"position.category.welcome": "Accueil",
|
||||
"position.category.parking": "Parking",
|
||||
"position.category.cleaning": "Propreté",
|
||||
"position.category.catering": "Catering",
|
||||
"position.category.setup": "Montage",
|
||||
"position.category.stage": "Régie",
|
||||
"position.category.other": "Autre",
|
||||
"form.closed": "Les inscriptions sont actuellement fermées.",
|
||||
"form.captcha_failed": "Vérification anti-robot invalide, réessayez.",
|
||||
"form.invalid_payload": "Données du formulaire invalides.",
|
||||
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
|
||||
"volunteer.empty_photo": "Le fichier de la photo est vide.",
|
||||
"form.photo_too_large": "La photo est trop volumineuse.",
|
||||
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
|
||||
"form.logo_empty": "Le logo est vide.",
|
||||
"form.logo_too_large": "Le logo est trop volumineux.",
|
||||
"form.invalid_sumup_amount": "Le montant doit être supérieur à 0.",
|
||||
"form.invalid_free_amount": "Le montant doit être compris entre {min} € et {max} €, avec au plus deux décimales.",
|
||||
"form.unknown_payment_option": "Ce type de paiement n'existe pas pour cette édition.",
|
||||
"form.duplicate_payment_option_label": "Un type de paiement porte déjà ce libellé.",
|
||||
"form.pending_already_paid": "Cette inscription est déjà payée.",
|
||||
"form.pending_not_awaiting_info": "Cette inscription n'attend pas ces informations.",
|
||||
"form.too_many_position_wishes": "Deux vœux de poste au maximum.",
|
||||
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.",
|
||||
"position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.",
|
||||
"position_sheets.no_shifts": "Aucun créneau à imprimer pour ces postes.",
|
||||
"mecenat.no_receipts": "Aucun bénévole n'a payé la cotisation en mécénat pour cette édition."
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from contextvars import ContextVar
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
@@ -7,6 +8,19 @@ from app.config import get_settings
|
||||
# téléphone dans les logs. On journalise des identifiants (UUID/entier) et des
|
||||
# codes. Toute nouvelle instruction de log doit respecter cette règle.
|
||||
|
||||
# Identifiant de la requête HTTP en cours (voir `app/request_logging.py`),
|
||||
# repris sur chaque ligne de log émise pendant son traitement — y compris
|
||||
# dans ses tâches de fond (`BackgroundTasks`, même contexte) — pour retrouver
|
||||
# d'un `grep` tout ce qui s'est passé lors de l'erreur qu'un utilisateur
|
||||
# signale (la référence lui est affichée, voir `frontend/.../state.tsx`).
|
||||
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
|
||||
|
||||
|
||||
class RequestIdFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.request_id = request_id_var.get()
|
||||
return True
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
# `force=True` : on réinstalle le handler racine même si l'import de
|
||||
@@ -14,6 +28,17 @@ def configure_logging() -> None:
|
||||
# sinon les logs `app.*` n'apparaissent pas dans la sortie du conteneur.
|
||||
logging.basicConfig(
|
||||
level=get_settings().log_level,
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
format="%(asctime)s %(levelname)s [%(request_id)s] %(name)s %(message)s",
|
||||
force=True,
|
||||
)
|
||||
# Filtre posé sur le handler (pas sur un logger) : il voit aussi les
|
||||
# lignes des bibliothèques (uvicorn, sqlalchemy…) propagées à la racine.
|
||||
for handler in logging.getLogger().handlers:
|
||||
handler.addFilter(RequestIdFilter())
|
||||
# Journal d'accès d'uvicorn remplacé par `app/request_logging.py` : le
|
||||
# sien écrit l'URL complète, chaîne de requête comprise — donc le jeton
|
||||
# `access_token` du formulaire public (CLAUDE.md § 2, `FormTemplate`).
|
||||
logging.getLogger("uvicorn.access").disabled = True
|
||||
# Bavard au niveau INFO (une ligne par appel SumUp/hCaptcha), sans
|
||||
# intérêt à côté des logs de l'appli.
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
+33
-2
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
@@ -5,24 +7,48 @@ from app.api.accounts import router as account_router
|
||||
from app.api.artists import router as artist_router
|
||||
from app.api.assignments import router as assignment_router
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.availability import router as availability_router
|
||||
from app.api.badges import router as badge_router
|
||||
from app.api.day_parts import router as day_part_router
|
||||
from app.api.editions import router as edition_router
|
||||
from app.api.forms import router as form_router
|
||||
from app.api.health import router as health_router
|
||||
from app.api.imports import router as import_router
|
||||
from app.api.matrix import router as matrix_router
|
||||
from app.api.notifications import router as notification_router
|
||||
from app.api.position_sheets import router as position_sheets_router
|
||||
from app.api.positions import router as position_router
|
||||
from app.api.shifts import router as shift_router
|
||||
from app.api.solver import router as solver_router
|
||||
from app.api.stats import router as stats_router
|
||||
from app.api.volunteers import router as volunteer_router
|
||||
from app.config import get_settings
|
||||
from app.logging_config import configure_logging
|
||||
from app.request_logging import REQUEST_ID_HEADER, install_request_logging
|
||||
|
||||
configure_logging()
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
if settings.is_production and not settings.email_enabled:
|
||||
# Avec EMAIL_ENABLED=false, le jeton de lien magique est journalisé en
|
||||
# clair (confort de dev, voir `api/auth.py`) — acceptable en local, jamais
|
||||
# en production : quiconque lit les logs du conteneur peut alors se
|
||||
# connecter à la place de n'importe quel bénévole ou référent.
|
||||
logging.getLogger("app.main").warning(
|
||||
"EMAIL_ENABLED=false en production : les liens magiques sont "
|
||||
"journalisés en clair au lieu d'être envoyés par email. Configurer "
|
||||
"le SMTP avant l'ouverture au public."
|
||||
)
|
||||
|
||||
if settings.is_production and not settings.hcaptcha_secret_key:
|
||||
# Le formulaire public (`app/api/forms.py`) reste accessible sans
|
||||
# HCAPTCHA_SECRET_KEY, mais sans aucune protection anti-robot au-delà du
|
||||
# filet de sécurité global par édition (`form_submission_limiter`).
|
||||
logging.getLogger("app.main").warning(
|
||||
"HCAPTCHA_SECRET_KEY absente en production : le formulaire public "
|
||||
"d'inscription n'est pas protégé par captcha."
|
||||
)
|
||||
|
||||
app = FastAPI(title="Gestion des bénévoles", version="0.1.0")
|
||||
|
||||
app.add_middleware(
|
||||
@@ -31,7 +57,9 @@ app.add_middleware(
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=[REQUEST_ID_HEADER],
|
||||
)
|
||||
install_request_logging(app)
|
||||
|
||||
app.include_router(health_router)
|
||||
app.include_router(auth_router)
|
||||
@@ -41,10 +69,13 @@ app.include_router(account_router)
|
||||
app.include_router(import_router)
|
||||
app.include_router(artist_router)
|
||||
app.include_router(volunteer_router)
|
||||
app.include_router(availability_router)
|
||||
app.include_router(position_router)
|
||||
app.include_router(shift_router)
|
||||
app.include_router(assignment_router)
|
||||
app.include_router(solver_router)
|
||||
app.include_router(matrix_router)
|
||||
app.include_router(stats_router)
|
||||
app.include_router(notification_router)
|
||||
app.include_router(badge_router)
|
||||
app.include_router(form_router)
|
||||
app.include_router(position_sheets_router)
|
||||
|
||||
@@ -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
|
||||
@@ -49,30 +41,20 @@ _ARTIST_CONFLICT_PENALTY = 80
|
||||
# n'importe quel poste) : limite les enchaînements de shifts plutôt que de
|
||||
# faire tourner un poste en particulier.
|
||||
_CHAIN_PENALTY = 40
|
||||
# Par compagnon souhaité déjà affecté au créneau (0 à N vœux par bénévole).
|
||||
# Par compagnon souhaité déjà affecté au créneau (0 à N vœux par bénévole,
|
||||
# rendus réciproques par le loader — voir `SolverVolunteer.wished_companion_ids`).
|
||||
_COMPANION_BONUS_PER_MATCH = 25
|
||||
# Le bénévole est déjà affecté (affectation décidée) à un créneau qui
|
||||
# chevauche celui-ci. Supérieur à tous les bonus cumulables et à la base
|
||||
# normale : il n'est proposé que pour un créneau sous son minimum, et
|
||||
# seulement faute de mieux.
|
||||
_ALREADY_BUSY_PENALTY = 300
|
||||
|
||||
|
||||
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
|
||||
return a.starts_at < b.ends_at and b.starts_at < a.ends_at
|
||||
|
||||
|
||||
def _has_insufficient_rest(volunteer: SolverVolunteer, shift: SolverShift) -> bool:
|
||||
candidate = shift.shift_info
|
||||
for other in volunteer.existing_shifts:
|
||||
if _overlaps(candidate, other):
|
||||
continue
|
||||
if other.ends_at <= candidate.starts_at:
|
||||
gap = candidate.starts_at - other.ends_at
|
||||
elif candidate.ends_at <= other.starts_at:
|
||||
gap = other.starts_at - candidate.ends_at
|
||||
else:
|
||||
continue
|
||||
if gap < dt.timedelta(minutes=_EIGHT_HOURS_MIN):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _chained_from_predecessor(volunteer: SolverVolunteer, shift: SolverShift) -> bool:
|
||||
"""Le bénévole a déjà (affectation décidée) l'un des créneaux qui
|
||||
précèdent immédiatement celui-ci, le même jour — n'importe quel poste :
|
||||
@@ -87,20 +69,14 @@ def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int:
|
||||
"""Score d'affinité (bénévole, créneau) : plus haut = meilleur candidat.
|
||||
|
||||
Purement additif (aucune interaction entre deux affectations proposées),
|
||||
ce qui garde le modèle CP-SAT linéaire simple. Repos, chaînage et
|
||||
compagnons ne sont vérifiés que contre les affectations déjà décidées,
|
||||
pas entre deux suggestions du même calcul : la validation finale (à la
|
||||
confirmation) reste seule juge en cas de cas limite entre deux
|
||||
suggestions (et deux compagnons suggérés ensemble dans le même calcul ne
|
||||
se renforcent pas mutuellement, faute d'interaction entre variables).
|
||||
ce qui garde le modèle CP-SAT linéaire simple. Chaînage et compagnons ne
|
||||
sont vérifiés que contre les affectations déjà décidées, pas entre deux
|
||||
suggestions du même calcul (deux compagnons suggérés ensemble ne se
|
||||
renforcent pas mutuellement, faute d'interaction entre variables).
|
||||
"""
|
||||
score = _URGENT_BASE if shift.already_assigned < shift.min_staff else _NORMAL_BASE
|
||||
if shift.position_id in volunteer.wished_position_ids:
|
||||
score += _WISH_BONUS
|
||||
missed = uncovered_minutes(shift.shift_info, volunteer.availability)
|
||||
score -= (missed // 30) * _AVAIL_PENALTY_PER_HALF_HOUR
|
||||
if _has_insufficient_rest(volunteer, shift):
|
||||
score -= _REST_PENALTY
|
||||
if _chained_from_predecessor(volunteer, shift):
|
||||
score -= _CHAIN_PENALTY
|
||||
companions_present = len(volunteer.wished_companion_ids & shift.assigned_volunteer_ids)
|
||||
@@ -109,6 +85,8 @@ def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int:
|
||||
_overlaps(shift.shift_info, passage) for passage in volunteer.wished_artist_windows
|
||||
):
|
||||
score -= _ARTIST_CONFLICT_PENALTY
|
||||
if any(_overlaps(shift.shift_info, other) for other in volunteer.existing_shifts):
|
||||
score -= _ALREADY_BUSY_PENALTY
|
||||
score -= (_FAIRNESS_PENALTY_PER_HOUR * volunteer.existing_minutes) // 60
|
||||
return score
|
||||
|
||||
@@ -129,7 +107,8 @@ def solve(
|
||||
if gap <= 0:
|
||||
continue
|
||||
for vi, volunteer in enumerate(data.volunteers):
|
||||
if any(_overlaps(shift.shift_info, other) for other in volunteer.existing_shifts):
|
||||
# Déjà affecté à ce créneau : rien à suggérer.
|
||||
if any(other.id == shift.id for other in volunteer.existing_shifts):
|
||||
continue
|
||||
key = (vi, si)
|
||||
variables[key] = model.NewBoolVar(f"x_{vi}_{si}")
|
||||
@@ -146,8 +125,8 @@ def solve(
|
||||
if shift_vars and gap > 0:
|
||||
model.Add(sum(shift_vars) <= gap)
|
||||
|
||||
# Aucun chevauchement entre deux créneaux nouvellement suggérés au même
|
||||
# bénévole (règle `SHIFT_OVERLAP`, bloquante).
|
||||
# Jamais deux créneaux qui se chevauchent suggérés au même bénévole dans
|
||||
# un même calcul (contre les affectations décidées : malus, cf. `_score`).
|
||||
for vi in range(len(data.volunteers)):
|
||||
my_shifts = [si for si in range(len(data.shifts)) if (vi, si) in variables]
|
||||
for a in range(len(my_shifts)):
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlmodel import Session, select
|
||||
from app.models import (
|
||||
Artist,
|
||||
Assignment,
|
||||
Availability,
|
||||
DayPart,
|
||||
Edition,
|
||||
Shift,
|
||||
Volunteer,
|
||||
@@ -18,7 +18,8 @@ from app.models import (
|
||||
VolunteerPositionWish,
|
||||
)
|
||||
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
|
||||
from app.validation.models import AvailabilityWindow, ShiftInfo
|
||||
from app.validation.engine import day_boundary, festival_day
|
||||
from app.validation.models import ShiftInfo
|
||||
|
||||
|
||||
def _artist_window(artist: Artist) -> ShiftInfo:
|
||||
@@ -41,6 +42,11 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
||||
)
|
||||
shifts_by_id = {s.id: s for s in shifts}
|
||||
|
||||
# « Même jour » = même journée de festival (CLAUDE.md § 3) : 00h–01h
|
||||
# enchaîne bien 23h–00h de la veille au calendrier.
|
||||
boundary = day_boundary(
|
||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||
)
|
||||
# Prédécesseur(s) : créneaux (n'importe quel poste) du même jour dont la
|
||||
# fin coïncide avec le début de celui-ci — sert au malus de chaînage
|
||||
# (voir `engine._score`), pour limiter les enchaînements de shifts plutôt
|
||||
@@ -49,7 +55,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
||||
predecessor_ids: dict = {}
|
||||
by_day: dict = defaultdict(list)
|
||||
for s in shifts:
|
||||
by_day[s.starts_at.date()].append(s)
|
||||
by_day[festival_day(s.starts_at, boundary)].append(s)
|
||||
for day_shifts in by_day.values():
|
||||
ids_by_end: dict = defaultdict(list)
|
||||
for s in day_shifts:
|
||||
@@ -86,14 +92,6 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
||||
if assigned_count.get(s.id, 0) < s.ideal_staff
|
||||
)
|
||||
|
||||
availabilities: dict = defaultdict(list)
|
||||
for a in session.exec(
|
||||
select(Availability).where(Availability.edition_id == edition.id)
|
||||
):
|
||||
availabilities[a.volunteer_id].append(
|
||||
AvailabilityWindow(date=a.date, start_time=a.start_time, end_time=a.end_time)
|
||||
)
|
||||
|
||||
wishes: dict = defaultdict(set)
|
||||
for w in session.exec(
|
||||
select(VolunteerPositionWish).join(
|
||||
@@ -102,6 +100,9 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
||||
):
|
||||
wishes[w.volunteer_id].add(str(w.position_id))
|
||||
|
||||
# Traité comme réciproque pour le solveur : si A souhaite B, B profite
|
||||
# aussi de la présence de A, même s'il ne l'a pas demandé (voir CLAUDE.md
|
||||
# section 4).
|
||||
companion_wishes: dict = defaultdict(set)
|
||||
for w in session.exec(
|
||||
select(VolunteerCompanionWish).join(
|
||||
@@ -109,6 +110,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
||||
).where(Volunteer.edition_id == edition.id)
|
||||
):
|
||||
companion_wishes[w.volunteer_id].add(str(w.companion_id))
|
||||
companion_wishes[w.companion_id].add(str(w.volunteer_id))
|
||||
|
||||
artists_by_id = {
|
||||
a.id: a
|
||||
@@ -130,7 +132,6 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
||||
solver_volunteers = tuple(
|
||||
SolverVolunteer(
|
||||
id=str(v.id),
|
||||
availability=tuple(availabilities.get(v.id, ())),
|
||||
wished_position_ids=frozenset(wishes.get(v.id, ())),
|
||||
wished_companion_ids=frozenset(companion_wishes.get(v.id, ())),
|
||||
wished_artist_windows=tuple(artist_wishes.get(v.id, ())),
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
Le solveur ne connaît que ces structures simples (jamais les modèles
|
||||
SQLModel), comme le moteur de validation dont il réutilise les briques
|
||||
(`app.validation.models`, `app.validation.engine.uncovered_minutes`).
|
||||
(`app.validation.models`).
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.validation.models import AvailabilityWindow, ShiftInfo
|
||||
from app.validation.models import ShiftInfo
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -39,16 +39,17 @@ class SolverShift:
|
||||
@dataclass(frozen=True)
|
||||
class SolverVolunteer:
|
||||
id: str
|
||||
availability: tuple[AvailabilityWindow, ...] = ()
|
||||
wished_position_ids: frozenset[str] = frozenset()
|
||||
# Bénévoles (0 à N) avec qui il voudrait être affecté — à sens unique.
|
||||
# Bénévoles (0 à N) avec qui il voudrait être affecté. Le vœu est saisi à
|
||||
# sens unique (`VolunteerCompanionWish`) mais rendu réciproque ici par le
|
||||
# loader : si A souhaite B, l'ensemble de B contient aussi A.
|
||||
wished_companion_ids: frozenset[str] = frozenset()
|
||||
# Passages des artistes que le bénévole veut voir (heure de passage),
|
||||
# sous forme de fenêtres datées : un créneau qui chevauche l'une d'elles
|
||||
# lui ferait manquer le passage.
|
||||
wished_artist_windows: tuple[ShiftInfo, ...] = ()
|
||||
# Créneaux déjà occupés (affectations existantes, décisions figées) :
|
||||
# comptent pour chevauchement, repos, chaînage.
|
||||
# comptent pour chevauchement et chaînage.
|
||||
existing_shifts: tuple[ShiftInfo, ...] = ()
|
||||
# Total déjà affecté sur l'édition (minutes), pour répartir la charge.
|
||||
existing_minutes: int = 0
|
||||
|
||||
@@ -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,6 +7,12 @@ from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata)
|
||||
from app.auth.deps import get_current_account
|
||||
from app.auth.rate_limit import (
|
||||
form_submission_limiter,
|
||||
login_limiter,
|
||||
magic_link_limiter,
|
||||
pending_status_limiter,
|
||||
)
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.main import app
|
||||
@@ -23,6 +29,18 @@ def _never_use_real_smtp(monkeypatch):
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_rate_limiters():
|
||||
"""Les limiteurs de `/auth/login` et `/auth/magic-link` sont des
|
||||
singletons en mémoire (voir `app/auth/rate_limit.py`) : un test qui en
|
||||
déclenche un ne doit pas polluer les suivants."""
|
||||
login_limiter.reset()
|
||||
magic_link_limiter.reset()
|
||||
form_submission_limiter.reset()
|
||||
pending_status_limiter.reset()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def engine():
|
||||
"""Vraie base PostgreSQL de test (voir docker/postgres-init)."""
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,51 @@
|
||||
import uuid
|
||||
|
||||
from app.models import Role
|
||||
|
||||
|
||||
def _url(edition, suffix=""):
|
||||
return f"/editions/{edition.id}/accounts{suffix}"
|
||||
|
||||
|
||||
def test_promotes_a_volunteer_account_to_lead(client, edition, make_account):
|
||||
account = make_account(Role.VOLUNTEER, email="v@ex.fr")
|
||||
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "LEAD"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["role"] == "LEAD"
|
||||
assert resp.json()["auth_method"] == "magic_link"
|
||||
|
||||
|
||||
def test_demotes_a_lead_account_to_volunteer(client, edition, make_account):
|
||||
account = make_account(Role.LEAD, email="l@ex.fr")
|
||||
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "VOLUNTEER"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["role"] == "VOLUNTEER"
|
||||
|
||||
|
||||
def test_cannot_change_role_of_a_coordinator_account(client, edition, make_account):
|
||||
account = make_account(Role.COORDINATOR, email="c@ex.fr", password="password123")
|
||||
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "LEAD"})
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "account.role_change_not_allowed"
|
||||
|
||||
|
||||
def test_cannot_promote_a_volunteer_to_coordinator(client, edition, make_account):
|
||||
account = make_account(Role.VOLUNTEER, email="v2@ex.fr")
|
||||
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "COORDINATOR"})
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "account.role_change_not_allowed"
|
||||
|
||||
|
||||
def test_unknown_account_is_404(client, edition):
|
||||
assert (
|
||||
client.patch(_url(edition, f"/{uuid.uuid4()}"), json={"role": "LEAD"}).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
|
||||
def test_volunteer_role_forbidden(edition, make_account, as_account):
|
||||
volunteer_account = make_account(Role.VOLUNTEER, email="v3@ex.fr")
|
||||
other = make_account(Role.VOLUNTEER, email="v4@ex.fr")
|
||||
api = as_account(volunteer_account)
|
||||
resp = api.patch(_url(edition, f"/{other.id}"), json={"role": "LEAD"})
|
||||
assert resp.status_code == 403
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import uuid
|
||||
|
||||
from app.models import Role
|
||||
|
||||
|
||||
def _url(edition, suffix=""):
|
||||
return f"/editions/{edition.id}/positions{suffix}"
|
||||
@@ -35,3 +37,75 @@ def test_unknown_edition_is_404(client):
|
||||
assert (
|
||||
client.get(f"/editions/{uuid.uuid4()}/positions").status_code == 404
|
||||
)
|
||||
|
||||
|
||||
def test_sets_a_referent(client, edition, make_account):
|
||||
lead = make_account(Role.LEAD, email="referent@ex.fr")
|
||||
created = client.post(
|
||||
_url(edition),
|
||||
json={"name": "Bar", "category": "BAR"},
|
||||
)
|
||||
pid = created.json()["id"]
|
||||
|
||||
resp = client.patch(_url(edition, f"/{pid}"), json={"lead_account_id": str(lead.id)})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["lead_account_id"] == str(lead.id)
|
||||
|
||||
|
||||
def test_referent_must_have_the_lead_role(client, edition, make_account):
|
||||
volunteer_account = make_account(Role.VOLUNTEER, email="v@ex.fr")
|
||||
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
|
||||
pid = created.json()["id"]
|
||||
|
||||
resp = client.patch(
|
||||
_url(edition, f"/{pid}"), json={"lead_account_id": str(volunteer_account.id)}
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "position.lead_must_be_referent_account"
|
||||
|
||||
|
||||
def test_referent_must_belong_to_the_same_edition(client, edition, session, make_account):
|
||||
import datetime as dt
|
||||
|
||||
from app.models import Edition
|
||||
|
||||
other_edition = Edition(year=2027, start_date=dt.date(2027, 7, 1), end_date=dt.date(2027, 7, 4))
|
||||
session.add(other_edition)
|
||||
session.commit()
|
||||
session.refresh(other_edition)
|
||||
|
||||
other_lead = make_account(Role.LEAD, email="stranger@ex.fr")
|
||||
other_lead.edition_id = other_edition.id
|
||||
session.add(other_lead)
|
||||
session.commit()
|
||||
|
||||
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
|
||||
pid = created.json()["id"]
|
||||
|
||||
resp = client.patch(
|
||||
_url(edition, f"/{pid}"), json={"lead_account_id": str(other_lead.id)}
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "position.lead_must_be_referent_account"
|
||||
|
||||
|
||||
def test_unknown_referent_is_422(client, edition):
|
||||
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
|
||||
pid = created.json()["id"]
|
||||
|
||||
resp = client.patch(
|
||||
_url(edition, f"/{pid}"), json={"lead_account_id": str(uuid.uuid4())}
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "position.lead_must_be_referent_account"
|
||||
|
||||
|
||||
def test_clearing_the_referent_is_allowed(client, edition, make_account):
|
||||
lead = make_account(Role.LEAD, email="referent2@ex.fr")
|
||||
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
|
||||
pid = created.json()["id"]
|
||||
client.patch(_url(edition, f"/{pid}"), json={"lead_account_id": str(lead.id)})
|
||||
|
||||
resp = client.patch(_url(edition, f"/{pid}"), json={"lead_account_id": None})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["lead_account_id"] is None
|
||||
|
||||
@@ -102,6 +102,38 @@ def test_prefers_volunteer_whose_wished_companion_is_already_on_the_shift(
|
||||
assert str(stranger.id) not in suggestion["volunteer_ids"]
|
||||
|
||||
|
||||
def test_companion_bonus_applies_even_to_the_one_who_did_not_ask(client, edition, session):
|
||||
# Le vœu de compagnon est à sens unique en base, mais traité comme
|
||||
# réciproque par le solveur : « wisher » (déjà affecté) a souhaité être
|
||||
# avec « wished », qui lui n'a rien demandé — il en profite quand même.
|
||||
from app.models import Assignment
|
||||
|
||||
position = make_position(session, edition)
|
||||
shift = make_shift(
|
||||
session, edition, position,
|
||||
start=dt_at(10, 14), end=dt_at(10, 18), min_staff=1, ideal_staff=2,
|
||||
)
|
||||
wisher = make_volunteer(session, edition, email="wisher@ex.fr")
|
||||
session.add(
|
||||
Assignment(edition_id=edition.id, volunteer_id=wisher.id, shift_id=shift.id)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
wished = make_volunteer(session, edition, email="wished@ex.fr")
|
||||
assert client.patch(
|
||||
f"/editions/{edition.id}/volunteers/{wisher.id}",
|
||||
json={"wished_companion_ids": [str(wished.id)]},
|
||||
).status_code == 200
|
||||
stranger = make_volunteer(session, edition, email="stranger@ex.fr")
|
||||
|
||||
resp = client.post(_url(edition, "/suggestions"))
|
||||
suggestion = next(
|
||||
s for s in resp.json()["suggestions"] if s["shift_id"] == str(shift.id)
|
||||
)
|
||||
assert suggestion["volunteer_ids"] == [str(wished.id)]
|
||||
assert str(stranger.id) not in suggestion["volunteer_ids"]
|
||||
|
||||
|
||||
def test_chain_penalty_applies_across_different_positions(client, edition, session):
|
||||
from app.models import Assignment, PositionCategory
|
||||
|
||||
|
||||
@@ -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, Volunteer
|
||||
from app.models import Assignment, FeePaymentStatus, Role, Volunteer
|
||||
from tests.factories import (
|
||||
dt_at,
|
||||
make_artist,
|
||||
make_availability,
|
||||
make_position,
|
||||
make_shift,
|
||||
make_volunteer,
|
||||
@@ -205,14 +207,6 @@ def test_delete_is_a_full_cascade(client, edition, session):
|
||||
shift = make_shift(
|
||||
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
|
||||
)
|
||||
make_availability(
|
||||
session,
|
||||
edition,
|
||||
volunteer,
|
||||
date=dt.date(2026, 7, 10),
|
||||
start_time=dt.time(8, 0),
|
||||
end_time=dt.time(13, 0),
|
||||
)
|
||||
session.add(
|
||||
Assignment(
|
||||
edition_id=edition.id,
|
||||
@@ -227,8 +221,447 @@ def test_delete_is_a_full_cascade(client, edition, session):
|
||||
assert client.get(_url(edition, f"/{volunteer.id}")).status_code == 404
|
||||
assert session.exec(select(Volunteer)).all() == []
|
||||
assert session.exec(select(Assignment)).all() == []
|
||||
assert session.exec(select(Availability)).all() == []
|
||||
# Le créneau subsiste.
|
||||
from app.models import Shift
|
||||
|
||||
assert session.get(Shift, shift.id) is not None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Planning enrichi (poste, lieu, référent) — écran bénévole #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_planning_is_empty_without_assignment(client, edition, session):
|
||||
volunteer = make_volunteer(session, edition, email="v@ex.fr")
|
||||
resp = client.get(_url(edition, f"/{volunteer.id}/planning"))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
def test_planning_includes_position_and_referent(client, edition, session):
|
||||
from app.models import Account
|
||||
|
||||
volunteer = make_volunteer(session, edition, email="v@ex.fr")
|
||||
referent_volunteer = make_volunteer(
|
||||
session, edition, first_name="Rita", last_name="Referente", email="r@ex.fr"
|
||||
)
|
||||
referent_volunteer.phone = "0600000000"
|
||||
session.add(referent_volunteer)
|
||||
referent_account = Account(
|
||||
edition_id=edition.id,
|
||||
email="rita@ex.fr",
|
||||
role=Role.LEAD,
|
||||
volunteer_id=referent_volunteer.id,
|
||||
)
|
||||
session.add(referent_account)
|
||||
session.commit()
|
||||
session.refresh(referent_account)
|
||||
|
||||
position = make_position(session, edition, name="Bar central")
|
||||
position.location = "Chapiteau"
|
||||
position.lead_account_id = referent_account.id
|
||||
session.add(position)
|
||||
session.commit()
|
||||
|
||||
shift = make_shift(
|
||||
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
|
||||
)
|
||||
session.add(
|
||||
Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
resp = client.get(_url(edition, f"/{volunteer.id}/planning"))
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert len(body) == 1
|
||||
entry = body[0]
|
||||
assert entry["shift_id"] == str(shift.id)
|
||||
assert entry["position_name"] == "Bar central"
|
||||
assert entry["position_location"] == "Chapiteau"
|
||||
assert entry["referent_name"] == "Rita Referente"
|
||||
assert entry["referent_phone"] == "0600000000"
|
||||
|
||||
|
||||
def test_planning_referent_fields_are_none_without_referent(
|
||||
client, edition, session
|
||||
):
|
||||
volunteer = make_volunteer(session, edition, email="v@ex.fr")
|
||||
position = make_position(session, edition)
|
||||
shift = make_shift(
|
||||
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
|
||||
)
|
||||
session.add(
|
||||
Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
resp = client.get(_url(edition, f"/{volunteer.id}/planning"))
|
||||
entry = resp.json()[0]
|
||||
assert entry["referent_name"] is None
|
||||
assert entry["referent_phone"] is None
|
||||
|
||||
|
||||
def test_planning_referent_fields_are_none_when_lead_has_no_volunteer_record(
|
||||
client, edition, session
|
||||
):
|
||||
"""Un référent peut être un compte LEAD sans fiche bénévole associée :
|
||||
pas de nom/téléphone à afficher dans ce cas."""
|
||||
from app.models import Account
|
||||
|
||||
volunteer = make_volunteer(session, edition, email="v@ex.fr")
|
||||
referent_account = Account(
|
||||
edition_id=edition.id, email="lead@ex.fr", role=Role.LEAD
|
||||
)
|
||||
session.add(referent_account)
|
||||
session.commit()
|
||||
session.refresh(referent_account)
|
||||
|
||||
position = make_position(session, edition)
|
||||
position.lead_account_id = referent_account.id
|
||||
session.add(position)
|
||||
session.commit()
|
||||
|
||||
shift = make_shift(
|
||||
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
|
||||
)
|
||||
session.add(
|
||||
Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
resp = client.get(_url(edition, f"/{volunteer.id}/planning"))
|
||||
entry = resp.json()[0]
|
||||
assert entry["referent_name"] is None
|
||||
assert entry["referent_phone"] is None
|
||||
|
||||
|
||||
def test_planning_is_ordered_by_start_time(client, edition, session):
|
||||
volunteer = make_volunteer(session, edition, email="v@ex.fr")
|
||||
position = make_position(session, edition)
|
||||
late = make_shift(
|
||||
session, edition, position, start=dt_at(11, 14), end=dt_at(11, 18)
|
||||
)
|
||||
early = make_shift(
|
||||
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
|
||||
)
|
||||
session.add_all(
|
||||
[
|
||||
Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=late.id),
|
||||
Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=early.id),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
resp = client.get(_url(edition, f"/{volunteer.id}/planning"))
|
||||
body = resp.json()
|
||||
assert [e["shift_id"] for e in body] == [str(early.id), str(late.id)]
|
||||
|
||||
|
||||
def test_planning_is_404_for_unknown_volunteer(client, edition):
|
||||
resp = client.get(_url(edition, f"/{uuid.uuid4()}/planning"))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_volunteer_can_read_own_planning_but_not_anothers(
|
||||
session, edition, make_account, as_account
|
||||
):
|
||||
me = make_volunteer(session, edition, email="me@e.fr")
|
||||
other = make_volunteer(session, edition, email="other@e.fr")
|
||||
account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="me@e.fr")
|
||||
client = as_account(account)
|
||||
|
||||
assert client.get(_url(edition, f"/{me.id}/planning")).status_code == 200
|
||||
assert client.get(_url(edition, f"/{other.id}/planning")).status_code == 403
|
||||
|
||||
|
||||
def test_lead_can_read_any_volunteers_planning(
|
||||
session, edition, make_account, as_account
|
||||
):
|
||||
other = make_volunteer(session, edition, email="other@e.fr")
|
||||
lead_account = make_account(Role.LEAD)
|
||||
client = as_account(lead_account)
|
||||
|
||||
assert client.get(_url(edition, f"/{other.id}/planning")).status_code == 200
|
||||
|
||||
|
||||
def test_export_volunteers_as_excel(client, edition, session):
|
||||
position = make_position(session, edition, name="Bar central")
|
||||
artist = make_artist(session, edition, name="Chilla")
|
||||
volunteer = make_volunteer(
|
||||
session, edition, first_name="Alex", last_name="Martin", email="alex@ex.fr"
|
||||
)
|
||||
client.patch(
|
||||
_url(edition, f"/{volunteer.id}"),
|
||||
json={
|
||||
"wished_position_ids": [str(position.id)],
|
||||
"artist_ids": [str(artist.id)],
|
||||
},
|
||||
)
|
||||
|
||||
resp = client.get(_url(edition, "/export"))
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == (
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
)
|
||||
assert f"benevoles-{edition.year}.xlsx" in resp.headers["content-disposition"]
|
||||
|
||||
workbook = load_workbook(io.BytesIO(resp.content))
|
||||
sheet = workbook.active
|
||||
rows = list(sheet.iter_rows(values_only=True))
|
||||
assert rows[0][0] == "Prénom"
|
||||
row = rows[1]
|
||||
assert row[0] == "Alex"
|
||||
assert row[1] == "Martin"
|
||||
assert row[2] == "alex@ex.fr"
|
||||
assert row[5] == "Bar central"
|
||||
assert row[6] == "Chilla"
|
||||
|
||||
|
||||
def test_export_says_how_each_volunteer_pays(client, edition, session):
|
||||
on_site = make_volunteer(session, edition, first_name="Alex", last_name="A")
|
||||
on_site.willing_to_pay_fee = True
|
||||
online = make_volunteer(session, edition, first_name="Sam", last_name="B")
|
||||
_mark_mecenat_paid(session, online)
|
||||
make_volunteer(session, edition, first_name="Jo", last_name="C")
|
||||
session.add(on_site)
|
||||
session.commit()
|
||||
|
||||
resp = client.get(_url(edition, "/export"))
|
||||
rows = list(load_workbook(io.BytesIO(resp.content)).active.iter_rows(values_only=True))
|
||||
column = rows[0].index("Mode de paiement")
|
||||
by_first_name = {row[0]: row[column] for row in rows[1:]}
|
||||
assert by_first_name == {"Alex": "Sur place", "Sam": "En ligne", "Jo": None}
|
||||
|
||||
|
||||
def test_export_neutralizes_formula_injection(client, edition, session):
|
||||
# CWE-1236 : un bénévole inscrit via le formulaire public (sans
|
||||
# authentification) contrôle son nom, son commentaire, etc. — ouverts
|
||||
# tels quels dans Excel par la coordination, une valeur comme
|
||||
# `=cmd|'/c calc'!A0` y serait exécutée comme une formule. Un préfixe
|
||||
# `'` force l'interprétation en texte (voir `app/exporting/xlsx.py`).
|
||||
volunteer = make_volunteer(
|
||||
session, edition, first_name="=cmd|'/c calc'!A0", last_name="Martin"
|
||||
)
|
||||
client.patch(
|
||||
_url(edition, f"/{volunteer.id}"),
|
||||
json={"comment": "@SUM(1+1)*cmd|' /C calc'!A0"},
|
||||
)
|
||||
|
||||
resp = client.get(_url(edition, "/export"))
|
||||
workbook = load_workbook(io.BytesIO(resp.content))
|
||||
header, row = list(workbook.active.iter_rows(values_only=True))[:2]
|
||||
assert row[0] == "'=cmd|'/c calc'!A0"
|
||||
assert row[header.index("Commentaire")] == "'@SUM(1+1)*cmd|' /C calc'!A0"
|
||||
|
||||
|
||||
def test_export_volunteers_is_coordinator_only(edition, make_account, as_account):
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
resp = as_account(lead).get(_url(edition, "/export"))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def _mark_mecenat_paid(session, volunteer, *, amount=50.0, paid_at=dt.datetime(2026, 3, 5, 10, 0)):
|
||||
"""Simule l'état d'un bénévole ayant payé un type de paiement « mécénat »
|
||||
(voir `app/api/forms.py:_promote_pending`) — `mecenat_address` est le
|
||||
marqueur qui distingue ce chemin d'un simple paiement classique."""
|
||||
volunteer.fee_payment_status = FeePaymentStatus.PAID
|
||||
volunteer.fee_amount = amount
|
||||
volunteer.fee_paid_at = paid_at
|
||||
volunteer.mecenat_address = "12 rue des Lilas"
|
||||
volunteer.mecenat_city = "Paris"
|
||||
volunteer.mecenat_postal_code = "75020"
|
||||
volunteer.mecenat_country = "France"
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_mecenat_receipts_pdf_includes_only_paid_mecenat_donors(
|
||||
client, edition, session
|
||||
):
|
||||
paid_mecenat = make_volunteer(
|
||||
session, edition, first_name="Alex", last_name="Martin"
|
||||
)
|
||||
_mark_mecenat_paid(session, paid_mecenat)
|
||||
|
||||
# Cotisation classique payée (pas de mécénat) : ne doit pas apparaître.
|
||||
paid_classique = make_volunteer(
|
||||
session, edition, first_name="Sam", last_name="Petit"
|
||||
)
|
||||
paid_classique.fee_payment_status = FeePaymentStatus.PAID
|
||||
paid_classique.fee_amount = 15.0
|
||||
paid_classique.fee_paid_at = dt.datetime(2026, 3, 5, 10, 0)
|
||||
session.add(paid_classique)
|
||||
session.commit()
|
||||
|
||||
# Mécénat choisi mais paiement non abouti : ne doit pas apparaître.
|
||||
failed_mecenat = make_volunteer(
|
||||
session, edition, first_name="Jo", last_name="Dupuis"
|
||||
)
|
||||
failed_mecenat.fee_payment_status = FeePaymentStatus.FAILED
|
||||
failed_mecenat.mecenat_address = "1 rue X"
|
||||
session.add(failed_mecenat)
|
||||
session.commit()
|
||||
|
||||
resp = client.get(_url(edition, "/mecenat-receipts"))
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "application/pdf"
|
||||
assert f"recus-mecenat-{edition.year}.pdf" in resp.headers["content-disposition"]
|
||||
|
||||
reader = PdfReader(io.BytesIO(resp.content))
|
||||
# Un reçu = 2 pages (le cerfa officiel) — un seul bénévole qualifie ici.
|
||||
assert len(reader.pages) == 2
|
||||
donor_page_text = reader.pages[1].extract_text()
|
||||
assert "Martin" in donor_page_text
|
||||
assert "Alex" in donor_page_text
|
||||
assert "50,00" in donor_page_text
|
||||
assert "05/03/2026" in donor_page_text
|
||||
assert "Petit" not in donor_page_text
|
||||
|
||||
|
||||
def test_mecenat_receipts_are_numbered_by_payment_year_and_order(client, edition, session):
|
||||
later = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
_mark_mecenat_paid(session, later, paid_at=dt.datetime(2026, 4, 1, 9, 0))
|
||||
earlier = make_volunteer(session, edition, first_name="Sam", last_name="Petit")
|
||||
_mark_mecenat_paid(session, earlier, paid_at=dt.datetime(2026, 3, 1, 9, 0))
|
||||
|
||||
resp = client.get(_url(edition, "/mecenat-receipts"))
|
||||
assert resp.status_code == 200
|
||||
|
||||
session.refresh(later)
|
||||
session.refresh(earlier)
|
||||
# Attribués dans l'ordre des paiements, pas de l'ordre alphabétique.
|
||||
assert earlier.mecenat_receipt_number == "202600001"
|
||||
assert later.mecenat_receipt_number == "202600002"
|
||||
|
||||
reader = PdfReader(io.BytesIO(resp.content))
|
||||
# Trié par nom : Martin (pages 1-2) puis Petit (pages 3-4).
|
||||
assert "202600002" in reader.pages[0].extract_text()
|
||||
assert "202600001" in reader.pages[2].extract_text()
|
||||
|
||||
|
||||
def test_mecenat_receipt_number_is_stable_across_regenerations(client, edition, session):
|
||||
volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
_mark_mecenat_paid(session, volunteer)
|
||||
client.get(_url(edition, "/mecenat-receipts"))
|
||||
session.refresh(volunteer)
|
||||
first = volunteer.mecenat_receipt_number
|
||||
|
||||
newcomer = make_volunteer(session, edition, first_name="Sam", last_name="Petit")
|
||||
_mark_mecenat_paid(session, newcomer)
|
||||
client.get(_url(edition, "/mecenat-receipts"))
|
||||
session.refresh(volunteer)
|
||||
session.refresh(newcomer)
|
||||
assert volunteer.mecenat_receipt_number == first
|
||||
assert newcomer.mecenat_receipt_number == "202600002"
|
||||
|
||||
|
||||
def test_mecenat_receipt_numbers_continue_the_existing_sequence(client, edition, session):
|
||||
numbered = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
_mark_mecenat_paid(session, numbered)
|
||||
numbered.mecenat_receipt_number = "202600041"
|
||||
session.add(numbered)
|
||||
session.commit()
|
||||
other = make_volunteer(session, edition, first_name="Sam", last_name="Petit")
|
||||
_mark_mecenat_paid(session, other)
|
||||
# Une autre année repart à 1.
|
||||
next_year = make_volunteer(session, edition, first_name="Jo", last_name="Dupuis")
|
||||
_mark_mecenat_paid(session, next_year, paid_at=dt.datetime(2027, 1, 2, 9, 0))
|
||||
|
||||
client.get(_url(edition, "/mecenat-receipts"))
|
||||
session.refresh(other)
|
||||
session.refresh(next_year)
|
||||
assert other.mecenat_receipt_number == "202600042"
|
||||
assert next_year.mecenat_receipt_number == "202700001"
|
||||
|
||||
|
||||
def test_mecenat_receipts_is_422_without_any_paid_donor(client, edition):
|
||||
resp = client.get(_url(edition, "/mecenat-receipts"))
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "mecenat.no_receipts"
|
||||
|
||||
|
||||
def test_mecenat_receipts_is_coordinator_only(
|
||||
edition, session, make_account, as_account
|
||||
):
|
||||
volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
_mark_mecenat_paid(session, volunteer)
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
resp = as_account(lead).get(_url(edition, "/mecenat-receipts"))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_export_gives_the_sumup_transaction(client, edition, session):
|
||||
volunteer = make_volunteer(session, edition, first_name="Sam", last_name="B")
|
||||
_mark_mecenat_paid(session, volunteer)
|
||||
volunteer.fee_transaction_code = "TEENSK4W2K"
|
||||
volunteer.fee_transaction_id = "4ba8a7ba-0f61-4c8a-9b5c-2f3e0b4a8a11"
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
|
||||
resp = client.get(_url(edition, "/export"))
|
||||
rows = list(load_workbook(io.BytesIO(resp.content)).active.iter_rows(values_only=True))
|
||||
code = rows[0].index("Transaction SumUp (code)")
|
||||
ident = rows[0].index("Transaction SumUp (identifiant)")
|
||||
assert rows[1][code] == "TEENSK4W2K"
|
||||
assert rows[1][ident] == "4ba8a7ba-0f61-4c8a-9b5c-2f3e0b4a8a11"
|
||||
|
||||
fiche = client.get(_url(edition, f"/{volunteer.id}")).json()
|
||||
assert fiche["fee_transaction_code"] == "TEENSK4W2K"
|
||||
# L'identifiant du checkout, lui, reste interne.
|
||||
assert "fee_checkout_id" not in fiche
|
||||
|
||||
|
||||
def _png() -> bytes:
|
||||
from PIL import Image
|
||||
|
||||
out = io.BytesIO()
|
||||
Image.new("RGB", (4, 4), "red").save(out, format="PNG")
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def test_coordinator_adds_then_removes_a_volunteer_photo(client, edition, session):
|
||||
volunteer = make_volunteer(session, edition)
|
||||
url = _url(edition, f"/{volunteer.id}/photo")
|
||||
assert client.get(url).status_code == 404
|
||||
|
||||
resp = client.put(url, files={"file": ("photo.png", _png(), "image/png")})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["has_photo"] is True
|
||||
photo = client.get(url)
|
||||
assert photo.status_code == 200
|
||||
assert photo.headers["content-type"] == "image/png"
|
||||
|
||||
resp = client.delete(url)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["has_photo"] is False
|
||||
assert client.get(url).status_code == 404
|
||||
|
||||
|
||||
def test_adding_a_fake_photo_is_rejected(client, edition, session):
|
||||
volunteer = make_volunteer(session, edition)
|
||||
resp = client.put(
|
||||
_url(edition, f"/{volunteer.id}/photo"),
|
||||
files={"file": ("photo.png", b"pas une image", "image/png")},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "form.photo_type_not_supported"
|
||||
|
||||
|
||||
def test_only_the_coordination_can_change_a_photo(
|
||||
session, edition, make_account, as_account
|
||||
):
|
||||
volunteer = make_volunteer(session, edition)
|
||||
lead = as_account(make_account(Role.LEAD, email="lead@e.fr"))
|
||||
url = _url(edition, f"/{volunteer.id}/photo")
|
||||
|
||||
assert lead.put(url, files={"file": ("p.png", _png(), "image/png")}).status_code == 403
|
||||
assert lead.delete(url).status_code == 403
|
||||
|
||||
|
||||
def test_manual_add_requires_no_field(client, edition):
|
||||
resp = client.post(_url(edition), json={})
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["first_name"] == ""
|
||||
assert body["last_name"] == ""
|
||||
assert client.get(_url(edition)).status_code == 200
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user