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,
|
# Alternative OVH si le 587/STARTTLS échoue : SMTP_PORT=465, SMTP_SSL=true,
|
||||||
# SMTP_STARTTLS=false
|
# SMTP_STARTTLS=false
|
||||||
|
|
||||||
|
# Formulaire public d'inscription (/inscription). Vide en dev ⇒ vérification
|
||||||
|
# hCaptcha désactivée (voir app/forms/captcha.py) — aucun compte requis pour
|
||||||
|
# développer en local. Couple site/secret d'un même compte https://hcaptcha.com.
|
||||||
|
HCAPTCHA_SECRET_KEY=
|
||||||
|
HCAPTCHA_SITE_KEY=
|
||||||
|
|
||||||
# Frontend (service `frontend` du docker-compose).
|
# Frontend (service `frontend` du docker-compose).
|
||||||
# mock (défaut) : jeu de démonstration en mémoire, aucun backend requis.
|
# mock (défaut) : jeu de démonstration en mémoire, aucun backend requis.
|
||||||
# http : API réelle — renseigner alors VITE_EDITION_ID avec l'UUID
|
# http : API réelle — renseigner alors VITE_EDITION_ID avec l'UUID
|
||||||
|
|||||||
@@ -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 Code (worktrees, agents locaux)
|
||||||
.claude/
|
.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 |
|
| `backend/` | API FastAPI, modèle SQLModel, migrations Alembic |
|
||||||
| `frontend/` | Interface React + Vite + TS + Tailwind (service `frontend`) |
|
| `frontend/` | Interface React + Vite + TS + Tailwind (service `frontend`) |
|
||||||
| `deploy/` | Configuration de déploiement production (compose, Caddy, runbook) |
|
| `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_LINK_MODE=copy \
|
||||||
UV_COMPILE_BYTECODE=1 \
|
UV_COMPILE_BYTECODE=1 \
|
||||||
UV_PROJECT_ENVIRONMENT=/app/.venv \
|
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
|
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
|
import uuid
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException, UploadFile
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models import Artist, Position, VolunteerArtist, VolunteerPositionWish, local_now
|
||||||
|
|
||||||
|
|
||||||
def owned_or_404(instance, edition_id: uuid.UUID):
|
def owned_or_404(instance, edition_id: uuid.UUID):
|
||||||
@@ -23,3 +33,101 @@ def replace_links(
|
|||||||
)
|
)
|
||||||
for row in pairs:
|
for row in pairs:
|
||||||
session.add(link_model(**row))
|
session.add(link_model(**row))
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_owned_ids(
|
||||||
|
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID], code: str
|
||||||
|
) -> list[uuid.UUID]:
|
||||||
|
"""Ordre d'entrée conservé, doublons retirés ; 422 si un id est étranger."""
|
||||||
|
wanted = list(dict.fromkeys(ids))
|
||||||
|
if wanted:
|
||||||
|
found = set(
|
||||||
|
session.exec(
|
||||||
|
select(model.id).where(
|
||||||
|
model.edition_id == edition_id,
|
||||||
|
model.id.in_(wanted),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(found) != len(wanted):
|
||||||
|
raise HTTPException(status_code=422, detail={"code": code})
|
||||||
|
return wanted
|
||||||
|
|
||||||
|
|
||||||
|
def set_artists(session: Session, volunteer, artist_ids: list[uuid.UUID]) -> None:
|
||||||
|
ids = resolve_owned_ids(
|
||||||
|
session, Artist, volunteer.edition_id, artist_ids, "artist.unknown"
|
||||||
|
)
|
||||||
|
replace_links(
|
||||||
|
session,
|
||||||
|
VolunteerArtist,
|
||||||
|
filter_column=VolunteerArtist.volunteer_id,
|
||||||
|
filter_value=volunteer.id,
|
||||||
|
pairs=[{"volunteer_id": volunteer.id, "artist_id": aid} for aid in ids],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_wished_positions(
|
||||||
|
session: Session, volunteer, position_ids: list[uuid.UUID]
|
||||||
|
) -> None:
|
||||||
|
ids = resolve_owned_ids(
|
||||||
|
session, Position, volunteer.edition_id, position_ids, "position.unknown"
|
||||||
|
)
|
||||||
|
replace_links(
|
||||||
|
session,
|
||||||
|
VolunteerPositionWish,
|
||||||
|
filter_column=VolunteerPositionWish.volunteer_id,
|
||||||
|
filter_value=volunteer.id,
|
||||||
|
pairs=[{"volunteer_id": volunteer.id, "position_id": pid} for pid in ids],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 sqlmodel import Session, select
|
||||||
|
|
||||||
from app.models import DayPart, Edition, Position, Shift
|
from app.models import DayPart, Edition, Position, Shift
|
||||||
|
from app.validation.engine import day_boundary
|
||||||
|
|
||||||
|
|
||||||
def _dates(edition: Edition) -> list[dt.date]:
|
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)]
|
return [edition.start_date + dt.timedelta(days=i) for i in range(span + 1)]
|
||||||
|
|
||||||
|
|
||||||
def _window(day: dt.date, part: DayPart) -> tuple[dt.datetime, dt.datetime]:
|
def _window(day: dt.date, part: DayPart, boundary: dt.time) -> tuple[dt.datetime, dt.datetime]:
|
||||||
start = dt.datetime.combine(day, part.start_time)
|
actual_day = day + dt.timedelta(days=1) if part.start_time < boundary else day
|
||||||
|
start = dt.datetime.combine(actual_day, part.start_time)
|
||||||
end_day = (
|
end_day = (
|
||||||
day if part.end_time > part.start_time else day + dt.timedelta(days=1)
|
actual_day if part.end_time > part.start_time else actual_day + dt.timedelta(days=1)
|
||||||
)
|
)
|
||||||
return start, dt.datetime.combine(end_day, part.end_time)
|
return start, dt.datetime.combine(end_day, part.end_time)
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ def generate_missing_shifts(
|
|||||||
day_parts = list(
|
day_parts = list(
|
||||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||||
)
|
)
|
||||||
|
boundary = day_boundary(day_parts)
|
||||||
existing = {
|
existing = {
|
||||||
(s.day_part_id, s.starts_at.date())
|
(s.day_part_id, s.starts_at.date())
|
||||||
for s in session.exec(
|
for s in session.exec(
|
||||||
@@ -37,9 +40,9 @@ def generate_missing_shifts(
|
|||||||
created = 0
|
created = 0
|
||||||
for day in _dates(edition):
|
for day in _dates(edition):
|
||||||
for part in day_parts:
|
for part in day_parts:
|
||||||
if (part.id, day) in existing:
|
start, end = _window(day, part, boundary)
|
||||||
|
if (part.id, start.date()) in existing:
|
||||||
continue
|
continue
|
||||||
start, end = _window(day, part)
|
|
||||||
session.add(
|
session.add(
|
||||||
Shift(
|
Shift(
|
||||||
edition_id=edition.id,
|
edition_id=edition.id,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlmodel import Session, select
|
|||||||
|
|
||||||
from app.api._common import owned_or_404
|
from app.api._common import owned_or_404
|
||||||
from app.api.deps import get_edition
|
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.deps import require_coordinator
|
||||||
from app.auth.passwords import MIN_LENGTH, hash_password
|
from app.auth.passwords import MIN_LENGTH, hash_password
|
||||||
from app.db import get_session
|
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)
|
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_account(
|
def delete_account(
|
||||||
account_id: uuid.UUID,
|
account_id: uuid.UUID,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from app.api.schemas import (
|
|||||||
AssignmentValidateRequest,
|
AssignmentValidateRequest,
|
||||||
AssignmentValidateResult,
|
AssignmentValidateResult,
|
||||||
AssignmentWriteResult,
|
AssignmentWriteResult,
|
||||||
|
AttendanceUpdate,
|
||||||
)
|
)
|
||||||
from app.auth.deps import (
|
from app.auth.deps import (
|
||||||
current_account_in_edition,
|
current_account_in_edition,
|
||||||
@@ -24,7 +25,7 @@ from app.auth.deps import (
|
|||||||
require_staff,
|
require_staff,
|
||||||
)
|
)
|
||||||
from app.db import get_session
|
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 import has_blocking, validate
|
||||||
from app.validation.loader import build_validation_input
|
from app.validation.loader import build_validation_input
|
||||||
|
|
||||||
@@ -206,6 +207,30 @@ def update_assignment(
|
|||||||
return AssignmentWriteResult(assignment=assignment, warnings=warnings)
|
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)
|
@router.delete("/{assignment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_assignment(
|
def delete_assignment(
|
||||||
assignment_id: uuid.UUID,
|
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.deps import get_current_account
|
||||||
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
|
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.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.auth.tokens import create_access_token, hash_token
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.email import render_magic_link, send
|
from app.email import edition_logo, render_magic_link, send
|
||||||
from app.models import Account, MagicLinkToken, local_now
|
from app.models import Account, MagicLinkToken, local_now
|
||||||
|
|
||||||
router = APIRouter(tags=["auth"])
|
router = APIRouter(tags=["auth"])
|
||||||
@@ -76,13 +79,24 @@ def login(
|
|||||||
payload: LoginRequest,
|
payload: LoginRequest,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> TokenResponse:
|
) -> TokenResponse:
|
||||||
|
login_limiter.hit(f"{edition_id}:{payload.email.strip().lower()}")
|
||||||
account = _account_by_email(session, edition_id, payload.email)
|
account = _account_by_email(session, edition_id, payload.email)
|
||||||
if (
|
if (
|
||||||
account is None
|
account is None
|
||||||
or account.password_hash is None
|
or account.password_hash is None
|
||||||
or not verify_password(payload.password, account.password_hash)
|
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()
|
raise _invalid_credentials()
|
||||||
|
logger.info("Connexion par mot de passe : compte %s (édition %s)", account.id, edition_id)
|
||||||
return _issue(account)
|
return _issue(account)
|
||||||
|
|
||||||
|
|
||||||
@@ -131,8 +145,21 @@ def request_magic_link(
|
|||||||
"""Émet un lien magique si un compte **sans mot de passe** correspond.
|
"""É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
|
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)."""
|
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)
|
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:
|
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()
|
settings = get_settings()
|
||||||
raw = issue_magic_link_token(session, account)
|
raw = issue_magic_link_token(session, account)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -140,7 +167,9 @@ def request_magic_link(
|
|||||||
link=build_magic_link_url(raw),
|
link=build_magic_link_url(raw),
|
||||||
minutes=settings.magic_link_ttl_minutes,
|
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:
|
if not settings.email_enabled:
|
||||||
# Aide au dev sans SMTP : le jeton (non nominatif) est journalisé.
|
# Aide au dev sans SMTP : le jeton (non nominatif) est journalisé.
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -157,6 +186,16 @@ def verify_magic_link(
|
|||||||
payload: VerifyRequest,
|
payload: VerifyRequest,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> TokenResponse:
|
) -> 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(
|
token = session.exec(
|
||||||
select(MagicLinkToken).where(
|
select(MagicLinkToken).where(
|
||||||
MagicLinkToken.token_hash == hash_token(payload.token.strip())
|
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.used_at is not None
|
||||||
or token.expires_at < local_now()
|
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()
|
raise _invalid_token()
|
||||||
account = session.get(Account, token.account_id)
|
account = session.get(Account, token.account_id)
|
||||||
if account is None or account.edition_id != edition_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()
|
raise _invalid_token()
|
||||||
token.used_at = local_now()
|
token.used_at = local_now()
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(account)
|
session.refresh(account)
|
||||||
|
logger.info("Connexion par lien magique : compte %s (édition %s)", account.id, edition_id)
|
||||||
return _issue(account)
|
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 fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.api._common import set_message
|
||||||
from app.api.deps import get_edition
|
from app.api.deps import get_edition
|
||||||
from app.api.schemas import EditionRead, EditionUpdate
|
from app.api.schemas import EditionRead, EditionUpdate
|
||||||
from app.auth.deps import current_account_in_edition, require_coordinator
|
from app.auth.deps import current_account_in_edition, require_coordinator
|
||||||
@@ -27,7 +28,10 @@ def update_edition(
|
|||||||
_guard: Account = Depends(require_coordinator),
|
_guard: Account = Depends(require_coordinator),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> Edition:
|
) -> 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)
|
setattr(edition, field, value)
|
||||||
if edition.end_date < edition.start_date:
|
if edition.end_date < edition.start_date:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
|||||||
"""Endpoints d'import : analyse puis exécution."""
|
"""Endpoints d'import : analyse puis exécution."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from sqlmodel import Session
|
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.importing.service import run_import, suggest_mapping, validate_mapping
|
||||||
from app.models import Account, Edition
|
from app.models import Account, Edition
|
||||||
|
|
||||||
|
logger = logging.getLogger("app.importing")
|
||||||
|
|
||||||
router = APIRouter(prefix="/editions/{edition_id}/import", tags=["import"])
|
router = APIRouter(prefix="/editions/{edition_id}/import", tags=["import"])
|
||||||
|
|
||||||
_PREVIEW_ROWS = 5
|
_PREVIEW_ROWS = 5
|
||||||
@@ -107,9 +111,19 @@ async def execute(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return run_import(
|
report = run_import(
|
||||||
session=session,
|
session=session,
|
||||||
edition=edition,
|
edition=edition,
|
||||||
table=table,
|
table=table,
|
||||||
mapping=parsed_mapping,
|
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)."""
|
"""Envoi d'emails d'information aux bénévoles (récapitulatif de planning)."""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
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 sqlmodel import Session, select
|
||||||
|
|
||||||
from app.api.deps import get_edition
|
from app.api.deps import get_edition
|
||||||
from app.api.schemas import (
|
from app.api.schemas import (
|
||||||
PlanningNotificationRequest,
|
PlanningNotificationRequest,
|
||||||
PlanningNotificationResult,
|
PlanningNotificationResult,
|
||||||
|
TestEmailRequest,
|
||||||
|
TestEmailResult,
|
||||||
)
|
)
|
||||||
from app.auth.deps import require_coordinator
|
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.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.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(
|
router = APIRouter(
|
||||||
prefix="/editions/{edition_id}/notifications", tags=["notifications"]
|
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:
|
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(
|
account = session.exec(
|
||||||
select(Account)
|
select(Account)
|
||||||
.where(Account.edition_id == edition.id)
|
.where(Account.edition_id == edition.id)
|
||||||
.where(Account.volunteer_id == volunteer.id)
|
.where(Account.volunteer_id == volunteer.id)
|
||||||
.where(Account.password_hash.is_(None))
|
|
||||||
).first()
|
).first()
|
||||||
if account is None:
|
if account is None and volunteer.email:
|
||||||
|
email = volunteer.email.strip()
|
||||||
|
taken = session.exec(
|
||||||
|
select(Account.id)
|
||||||
|
.where(Account.edition_id == edition.id)
|
||||||
|
.where(func.lower(Account.email) == email.lower())
|
||||||
|
).first()
|
||||||
|
if taken is None:
|
||||||
|
account = Account(
|
||||||
|
edition_id=edition.id, email=email, role=Role.VOLUNTEER, volunteer_id=volunteer.id
|
||||||
|
)
|
||||||
|
session.add(account)
|
||||||
|
session.flush()
|
||||||
|
logger.info(
|
||||||
|
"Compte créé pour le lien de connexion : bénévole %s (compte %s)",
|
||||||
|
volunteer.id,
|
||||||
|
account.id,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
account is None
|
||||||
|
or account.password_hash is not None
|
||||||
|
or account.role not in (Role.VOLUNTEER, Role.LEAD)
|
||||||
|
):
|
||||||
|
return _login_page_url()
|
||||||
|
return build_magic_link_url(permanent_login_token(account))
|
||||||
|
|
||||||
|
|
||||||
|
def _login_page_url() -> str:
|
||||||
|
"""Page de connexion, sans jeton."""
|
||||||
return build_magic_link_url("").split("?")[0]
|
return build_magic_link_url("").split("?")[0]
|
||||||
return build_magic_link_url(issue_magic_link_token(session, account))
|
|
||||||
|
|
||||||
|
# Repère du tableau des créneaux dans la version HTML (caractère d'usage
|
||||||
|
# privé : jamais saisi dans un modèle), remplacé par le vrai tableau.
|
||||||
|
_SHIFTS_MARKER = "\ue000creneaux\ue000"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _PlanningContext:
|
||||||
|
"""Ce qui sert à composer le récapitulatif de chaque bénévole, chargé
|
||||||
|
une fois par envoi."""
|
||||||
|
|
||||||
|
positions: dict[uuid.UUID, Position]
|
||||||
|
# Nom + téléphone du référent de chaque poste qui en a un.
|
||||||
|
referents: dict[uuid.UUID, str]
|
||||||
|
shifts_by_volunteer: dict[uuid.UUID, list[Shift]]
|
||||||
|
boundary: dt.time
|
||||||
|
|
||||||
|
|
||||||
|
def _load_context(session: Session, edition: Edition) -> _PlanningContext:
|
||||||
|
positions = {
|
||||||
|
p.id: p for p in session.exec(select(Position).where(Position.edition_id == edition.id))
|
||||||
|
}
|
||||||
|
referents: dict[uuid.UUID, str] = {}
|
||||||
|
for position_id, referent in session.exec(
|
||||||
|
select(Position.id, Volunteer)
|
||||||
|
.join(Account, Account.id == Position.lead_account_id)
|
||||||
|
.join(Volunteer, Volunteer.id == Account.volunteer_id)
|
||||||
|
.where(Position.edition_id == edition.id)
|
||||||
|
):
|
||||||
|
name = f"{referent.first_name} {referent.last_name}".strip()
|
||||||
|
referents[position_id] = f"{name} · {referent.phone}" if referent.phone else name
|
||||||
|
shifts_by_volunteer: dict[uuid.UUID, list[Shift]] = defaultdict(list)
|
||||||
|
for volunteer_id, shift in session.exec(
|
||||||
|
select(Assignment.volunteer_id, Shift)
|
||||||
|
.join(Shift, Shift.id == Assignment.shift_id)
|
||||||
|
.where(Assignment.edition_id == edition.id)
|
||||||
|
):
|
||||||
|
shifts_by_volunteer[volunteer_id].append(shift)
|
||||||
|
boundary = day_boundary(session.exec(select(DayPart).where(DayPart.edition_id == edition.id)))
|
||||||
|
return _PlanningContext(positions, referents, shifts_by_volunteer, boundary)
|
||||||
|
|
||||||
|
|
||||||
|
def _day_label(day: dt.date) -> str:
|
||||||
|
"""« Samedi 11 » — même en-tête de jour que le tableau des badges."""
|
||||||
|
return t("badge.table.day_label", weekday=t(f"badge.weekday.{day.weekday()}"), day=day.day)
|
||||||
|
|
||||||
|
|
||||||
|
def _shift_sections(
|
||||||
|
shifts: list[Shift], ctx: _PlanningContext
|
||||||
|
) -> list[tuple[str, list[tuple[str, str, str]]]]:
|
||||||
|
"""Créneaux regroupés par journée de festival (une nuit reste sur le
|
||||||
|
jour où elle a commencé), comme le tableau des badges :
|
||||||
|
`[(jour, [(horaire, poste, référent)])]`."""
|
||||||
|
by_day: dict[dt.date, list[tuple[str, str, str]]] = defaultdict(list)
|
||||||
|
for s in sorted(shifts, key=lambda s: s.starts_at):
|
||||||
|
by_day[festival_day(s.starts_at, ctx.boundary)].append(
|
||||||
|
(
|
||||||
|
f"{s.starts_at:%H:%M}–{s.ends_at:%H:%M}",
|
||||||
|
ctx.positions[s.position_id].name,
|
||||||
|
ctx.referents.get(s.position_id) or t("email.planning.table.no_referent"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [(_day_label(day), rows) for day, rows in sorted(by_day.items())]
|
||||||
|
|
||||||
|
|
||||||
|
def _shifts_text(sections: list[tuple[str, list[tuple[str, str, str]]]]) -> str:
|
||||||
|
"""Version texte du tableau (clients mail sans HTML) : le jour, puis
|
||||||
|
une ligne par créneau."""
|
||||||
|
if not sections:
|
||||||
|
return t("email.planning.no_shifts")
|
||||||
|
return "\n\n".join(
|
||||||
|
day
|
||||||
|
+ "\n"
|
||||||
|
+ "\n".join(
|
||||||
|
t("email.planning.table.text_line", time=time, position=position, referent=referent)
|
||||||
|
for time, position, referent in rows
|
||||||
|
)
|
||||||
|
for day, rows in sections
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _PlanningEmail:
|
||||||
|
subject: str
|
||||||
|
body: str
|
||||||
|
# Corps de la version HTML : `{creneaux}` y devient un vrai tableau.
|
||||||
|
html_body: str
|
||||||
|
html_blocks: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
def _planning_email(
|
||||||
|
*,
|
||||||
|
ctx: _PlanningContext,
|
||||||
|
subject_template: str,
|
||||||
|
body_template: str,
|
||||||
|
first_name: str,
|
||||||
|
last_name: str,
|
||||||
|
shifts: list[Shift],
|
||||||
|
login_link: str,
|
||||||
|
) -> _PlanningEmail:
|
||||||
|
"""Récapitulatif de planning d'un bénévole — partagé par l'envoi réel
|
||||||
|
et l'email de test. `{creneaux}` : tableau par jour (horaire, poste,
|
||||||
|
référent avec son téléphone), en HTML et en texte."""
|
||||||
|
minutes = sum((s.ends_at - s.starts_at).total_seconds() / 60 for s in shifts)
|
||||||
|
sections = _shift_sections(shifts, ctx)
|
||||||
|
|
||||||
|
def render(shifts_text: str) -> tuple[str, str]:
|
||||||
|
return render_planning(
|
||||||
|
subject_template=subject_template,
|
||||||
|
body_template=body_template,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
shift_count=len(shifts),
|
||||||
|
hours=round(minutes / 60, 1),
|
||||||
|
shifts_text=shifts_text,
|
||||||
|
login_link=login_link,
|
||||||
|
)
|
||||||
|
|
||||||
|
subject, body = render(_shifts_text(sections))
|
||||||
|
if not sections:
|
||||||
|
return _PlanningEmail(subject, body, body, {})
|
||||||
|
_, html_body = render(_SHIFTS_MARKER)
|
||||||
|
table = render_planning_table(
|
||||||
|
sections,
|
||||||
|
(
|
||||||
|
t("email.planning.table.time"),
|
||||||
|
t("email.planning.table.position"),
|
||||||
|
t("email.planning.table.referent"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return _PlanningEmail(subject, body, html_body, {_SHIFTS_MARKER: table})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test", response_model=TestEmailResult)
|
||||||
|
def send_test_email(
|
||||||
|
payload: TestEmailRequest,
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_coordinator),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> TestEmailResult:
|
||||||
|
"""Envoie tout de suite à l'adresse saisie le récapitulatif de planning
|
||||||
|
**tel que les bénévoles le recevront** : le modèle en cours d'édition
|
||||||
|
(`subject`/`body`), variables remplies avec les données d'un vrai
|
||||||
|
bénévole, même mise en forme et même logo — sujet préfixé « [Test] ».
|
||||||
|
Renvoie le résultat réel du serveur SMTP, pour vérifier à la fois le
|
||||||
|
contenu et la configuration d'envoi depuis l'écran « Messages ».
|
||||||
|
`{lien_connexion}` y est remplacé par l'adresse de la page de connexion,
|
||||||
|
**sans jeton** : un vrai lien magique ouvrirait le compte du bénévole à
|
||||||
|
quiconque lit la boîte de test."""
|
||||||
|
email = payload.email.strip()
|
||||||
|
if "@" not in email or email.startswith("@") or email.endswith("@") or " " in email:
|
||||||
|
raise HTTPException(status_code=422, detail={"code": "notifications.invalid_email"})
|
||||||
|
|
||||||
|
ctx = _load_context(session, edition)
|
||||||
|
shifts_by_volunteer = ctx.shifts_by_volunteer
|
||||||
|
volunteers = list(
|
||||||
|
session.exec(
|
||||||
|
select(Volunteer)
|
||||||
|
.where(Volunteer.edition_id == edition.id)
|
||||||
|
.order_by(Volunteer.last_name, Volunteer.first_name)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
example = next((v for v in volunteers if v.id == payload.volunteer_id), None) or next(
|
||||||
|
(v for v in volunteers if shifts_by_volunteer.get(v.id)),
|
||||||
|
volunteers[0] if volunteers else None,
|
||||||
|
)
|
||||||
|
composed = _planning_email(
|
||||||
|
ctx=ctx,
|
||||||
|
subject_template=payload.subject or t("email.planning.default_subject"),
|
||||||
|
body_template=payload.body or t("email.planning.default_body"),
|
||||||
|
first_name=example.first_name if example else t("email.test.example_first_name"),
|
||||||
|
last_name=example.last_name if example else t("email.test.example_last_name"),
|
||||||
|
shifts=shifts_by_volunteer.get(example.id, []) if example else [],
|
||||||
|
login_link=_login_page_url(),
|
||||||
|
)
|
||||||
|
message = build_message(
|
||||||
|
email,
|
||||||
|
t("email.test.subject_prefix", subject=composed.subject),
|
||||||
|
composed.body,
|
||||||
|
logo=edition_logo(session, edition.id),
|
||||||
|
html_body=composed.html_body,
|
||||||
|
html_blocks=composed.html_blocks,
|
||||||
|
)
|
||||||
|
status, detail = deliver_test(message)
|
||||||
|
settings = get_settings()
|
||||||
|
return TestEmailResult(
|
||||||
|
status=status,
|
||||||
|
detail=detail,
|
||||||
|
smtp_host=f"{settings.smtp_host}:{settings.smtp_port}",
|
||||||
|
smtp_from=settings.smtp_from,
|
||||||
|
example_volunteer_id=example.id if example else None,
|
||||||
|
example_volunteer_name=(
|
||||||
|
f"{example.first_name} {example.last_name}".strip() if example else ""
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/planning", response_model=PlanningNotificationResult)
|
@router.post("/planning", response_model=PlanningNotificationResult)
|
||||||
@@ -57,64 +299,51 @@ def send_planning_notification(
|
|||||||
stmt = stmt.where(Volunteer.id.in_(payload.volunteer_ids))
|
stmt = stmt.where(Volunteer.id.in_(payload.volunteer_ids))
|
||||||
volunteers = list(session.exec(stmt.order_by(Volunteer.last_name)))
|
volunteers = list(session.exec(stmt.order_by(Volunteer.last_name)))
|
||||||
|
|
||||||
positions = {
|
ctx = _load_context(session, edition)
|
||||||
p.id: p
|
logo = edition_logo(session, edition.id)
|
||||||
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)
|
|
||||||
|
|
||||||
items: list[tuple[str, str, str]] = []
|
messages: list[EmailMessage] = []
|
||||||
recipients: list[uuid.UUID] = []
|
recipients: list[uuid.UUID] = []
|
||||||
skipped_no_email = 0
|
skipped_no_email = 0
|
||||||
for volunteer in volunteers:
|
for volunteer in volunteers:
|
||||||
if not volunteer.email:
|
if not volunteer.email:
|
||||||
skipped_no_email += 1
|
skipped_no_email += 1
|
||||||
continue
|
continue
|
||||||
shifts = sorted(
|
composed = _planning_email(
|
||||||
shifts_by_volunteer.get(volunteer.id, []),
|
ctx=ctx,
|
||||||
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(
|
|
||||||
subject_template=subject_template,
|
subject_template=subject_template,
|
||||||
body_template=body_template,
|
body_template=body_template,
|
||||||
first_name=volunteer.first_name,
|
first_name=volunteer.first_name,
|
||||||
last_name=volunteer.last_name,
|
last_name=volunteer.last_name,
|
||||||
shift_count=len(shifts),
|
shifts=ctx.shifts_by_volunteer.get(volunteer.id, []),
|
||||||
hours=round(minutes / 60, 1),
|
|
||||||
shifts_text=shifts_text,
|
|
||||||
login_link=(
|
login_link=(
|
||||||
_login_link(session, edition, volunteer)
|
_login_link(session, edition, volunteer)
|
||||||
if wants_login_link
|
if wants_login_link
|
||||||
else ""
|
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)
|
recipients.append(volunteer.id)
|
||||||
|
|
||||||
session.commit() # persiste les éventuels MagicLinkToken émis
|
session.commit() # persiste les comptes créés pour les liens de connexion
|
||||||
background.add_task(send_many, items)
|
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(
|
return PlanningNotificationResult(
|
||||||
queued=len(items),
|
queued=len(messages),
|
||||||
skipped_no_email=skipped_no_email,
|
skipped_no_email=skipped_no_email,
|
||||||
recipients=recipients,
|
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
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from sqlmodel import Session, select
|
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._shiftgen import generate_missing_shifts
|
||||||
from app.api.deps import get_edition
|
from app.api.deps import get_edition
|
||||||
from app.api.schemas import (
|
from app.api.schemas import (
|
||||||
@@ -21,13 +26,28 @@ from app.auth.deps import (
|
|||||||
require_staff,
|
require_staff,
|
||||||
)
|
)
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models import Account, Edition, Position
|
from app.models import Account, Edition, Position, Role
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/editions/{edition_id}/positions", tags=["positions"]
|
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)
|
@router.post("", response_model=PositionRead, status_code=status.HTTP_201_CREATED)
|
||||||
def create_position(
|
def create_position(
|
||||||
payload: PositionCreate,
|
payload: PositionCreate,
|
||||||
@@ -35,11 +55,12 @@ def create_position(
|
|||||||
_guard: Account = Depends(require_coordinator),
|
_guard: Account = Depends(require_coordinator),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> Position:
|
) -> Position:
|
||||||
|
_check_lead_account(session, edition, payload.lead_account_id)
|
||||||
position = Position(edition_id=edition.id, **payload.model_dump())
|
position = Position(edition_id=edition.id, **payload.model_dump())
|
||||||
session.add(position)
|
session.add(position)
|
||||||
session.flush()
|
# Pas de créneaux générés automatiquement : la coordination choisit
|
||||||
# Un créneau à besoin 0/0 par jour et par créneau type de l'édition.
|
# elle-même ceux dont elle a besoin (créés à la main, ou en repartant des
|
||||||
generate_missing_shifts(session, edition, position)
|
# créneaux types via `generate_shifts` ci-dessous).
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(position)
|
session.refresh(position)
|
||||||
return position
|
return position
|
||||||
@@ -80,7 +101,11 @@ def update_position(
|
|||||||
) -> Position:
|
) -> Position:
|
||||||
position = owned_or_404(session.get(Position, position_id), edition.id)
|
position = owned_or_404(session.get(Position, position_id), edition.id)
|
||||||
ensure_can_write_position(account, position)
|
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)
|
setattr(position, field, value)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(position)
|
session.refresh(position)
|
||||||
|
|||||||
+483
-29
@@ -7,6 +7,13 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
|
AttendanceStatus,
|
||||||
|
BadgeAlign,
|
||||||
|
BadgeFace,
|
||||||
|
BadgeFieldType,
|
||||||
|
BadgePhotoShape,
|
||||||
|
FeePaymentStatus,
|
||||||
|
FormFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Role,
|
Role,
|
||||||
)
|
)
|
||||||
@@ -29,23 +36,34 @@ class EditionRead(ApiModel):
|
|||||||
start_date: dt.date
|
start_date: dt.date
|
||||||
end_date: dt.date
|
end_date: dt.date
|
||||||
retention_days: int | None
|
retention_days: int | None
|
||||||
|
volunteer_message: str | None
|
||||||
|
volunteer_message_updated_at: dt.datetime | None
|
||||||
created_at: dt.datetime
|
created_at: dt.datetime
|
||||||
updated_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):
|
class EditionUpdate(ApiModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
year: int | None = None
|
year: int | None = None
|
||||||
start_date: dt.date | None = None
|
start_date: dt.date | None = None
|
||||||
end_date: dt.date | None = None
|
end_date: dt.date | None = None
|
||||||
retention_days: int | 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 #
|
# Volunteer #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
class VolunteerCreate(ApiModel):
|
class VolunteerCreate(ApiModel):
|
||||||
first_name: str
|
# Facultatifs à l'ajout manuel par la coordination (fiche complétée plus
|
||||||
last_name: str
|
# tard) ; le formulaire public et l'import les exigent toujours.
|
||||||
|
first_name: str = ""
|
||||||
|
last_name: str = ""
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
@@ -67,6 +85,16 @@ class VolunteerUpdate(ApiModel):
|
|||||||
artist_ids: list[uuid.UUID] | None = None
|
artist_ids: list[uuid.UUID] | None = None
|
||||||
wished_position_ids: list[uuid.UUID] | None = None
|
wished_position_ids: list[uuid.UUID] | None = None
|
||||||
wished_companion_ids: list[uuid.UUID] | None = None
|
wished_companion_ids: list[uuid.UUID] | None = None
|
||||||
|
# Issus du formulaire public (`app/api/forms.py`), éditables ensuite
|
||||||
|
# depuis la fiche — voir CLAUDE.md § 2 (Volunteer).
|
||||||
|
companion_wish_note: str | None = None
|
||||||
|
# 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):
|
class VolunteerRead(ApiModel):
|
||||||
@@ -80,10 +108,56 @@ class VolunteerRead(ApiModel):
|
|||||||
artist_ids: list[uuid.UUID]
|
artist_ids: list[uuid.UUID]
|
||||||
wished_position_ids: list[uuid.UUID]
|
wished_position_ids: list[uuid.UUID]
|
||||||
wished_companion_ids: list[uuid.UUID]
|
wished_companion_ids: list[uuid.UUID]
|
||||||
|
has_photo: bool
|
||||||
|
companion_wish_note: str | None
|
||||||
|
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
|
created_at: dt.datetime
|
||||||
updated_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) #
|
# Artist (programmation) #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -128,6 +202,8 @@ class PositionUpdate(ApiModel):
|
|||||||
location: str | None = None
|
location: str | None = None
|
||||||
category: PositionCategory | None = None
|
category: PositionCategory | None = None
|
||||||
lead_account_id: uuid.UUID | 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):
|
class PositionRead(ApiModel):
|
||||||
@@ -137,6 +213,8 @@ class PositionRead(ApiModel):
|
|||||||
location: str | None
|
location: str | None
|
||||||
category: PositionCategory
|
category: PositionCategory
|
||||||
lead_account_id: uuid.UUID | None
|
lead_account_id: uuid.UUID | None
|
||||||
|
message: str | None
|
||||||
|
message_updated_at: dt.datetime | None
|
||||||
created_at: dt.datetime
|
created_at: dt.datetime
|
||||||
updated_at: dt.datetime
|
updated_at: dt.datetime
|
||||||
|
|
||||||
@@ -208,33 +286,6 @@ class DayPartUpdate(ApiModel):
|
|||||||
order: int | None = None
|
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 #
|
# Assignment #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -257,10 +308,17 @@ class AssignmentRead(ApiModel):
|
|||||||
shift_id: uuid.UUID
|
shift_id: uuid.UUID
|
||||||
source: AssignmentSource
|
source: AssignmentSource
|
||||||
locked: bool
|
locked: bool
|
||||||
|
attendance: AttendanceStatus | None
|
||||||
|
attendance_marked_at: dt.datetime | None
|
||||||
created_at: dt.datetime
|
created_at: dt.datetime
|
||||||
updated_at: dt.datetime
|
updated_at: dt.datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AttendanceUpdate(ApiModel):
|
||||||
|
# `None` = annuler le pointage.
|
||||||
|
attendance: AttendanceStatus | None
|
||||||
|
|
||||||
|
|
||||||
class AssignmentWriteResult(ApiModel):
|
class AssignmentWriteResult(ApiModel):
|
||||||
assignment: AssignmentRead
|
assignment: AssignmentRead
|
||||||
warnings: list[Violation] = Field(default_factory=list)
|
warnings: list[Violation] = Field(default_factory=list)
|
||||||
@@ -316,6 +374,77 @@ class MatrixResponse(ApiModel):
|
|||||||
positions: list[MatrixPosition]
|
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 #
|
# Authentification et comptes #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -363,6 +492,12 @@ class AccountCreate(ApiModel):
|
|||||||
password: str | None = None
|
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 #
|
# Notifications par email #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -379,3 +514,322 @@ class PlanningNotificationResult(ApiModel):
|
|||||||
queued: int
|
queued: int
|
||||||
skipped_no_email: int
|
skipped_no_email: int
|
||||||
recipients: list[uuid.UUID] = Field(default_factory=list)
|
recipients: list[uuid.UUID] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
coordination affecte elle-même, dans l'écran habituel, en tenant compte des
|
||||||
bénévoles remontés en tête de liste."""
|
bénévoles remontés en tête de liste."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
@@ -14,6 +16,8 @@ from app.db import get_session
|
|||||||
from app.models import Account, Edition
|
from app.models import Account, Edition
|
||||||
from app.solver import build_solver_input, solve
|
from app.solver import build_solver_input, solve
|
||||||
|
|
||||||
|
logger = logging.getLogger("app.solver")
|
||||||
|
|
||||||
router = APIRouter(prefix="/editions/{edition_id}/solver", tags=["solver"])
|
router = APIRouter(prefix="/editions/{edition_id}/solver", tags=["solver"])
|
||||||
|
|
||||||
|
|
||||||
@@ -23,8 +27,20 @@ def run_solver(
|
|||||||
_guard: Account = Depends(require_staff),
|
_guard: Account = Depends(require_staff),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> SolverRunResult:
|
) -> SolverRunResult:
|
||||||
|
started = time.monotonic()
|
||||||
data = build_solver_input(session=session, edition=edition)
|
data = build_solver_input(session=session, edition=edition)
|
||||||
|
loaded = time.monotonic()
|
||||||
solution = solve(data)
|
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(
|
return SolverRunResult(
|
||||||
suggestions=[
|
suggestions=[
|
||||||
SolverSuggestion(
|
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
|
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 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.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 (
|
from app.auth.deps import (
|
||||||
current_account_in_edition,
|
current_account_in_edition,
|
||||||
require_coordinator,
|
require_coordinator,
|
||||||
require_staff,
|
require_staff,
|
||||||
)
|
)
|
||||||
from app.db import get_session
|
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 (
|
from app.models import (
|
||||||
Account,
|
Account,
|
||||||
Artist,
|
Artist,
|
||||||
|
Assignment,
|
||||||
Edition,
|
Edition,
|
||||||
|
FeePaymentStatus,
|
||||||
Position,
|
Position,
|
||||||
Role,
|
Role,
|
||||||
|
Shift,
|
||||||
Volunteer,
|
Volunteer,
|
||||||
VolunteerArtist,
|
VolunteerArtist,
|
||||||
VolunteerCompanionWish,
|
VolunteerCompanionWish,
|
||||||
@@ -85,74 +103,20 @@ def _companion_ids(session: Session, volunteer_id: uuid.UUID) -> list[uuid.UUID]
|
|||||||
|
|
||||||
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
|
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
|
||||||
return VolunteerRead(
|
return VolunteerRead(
|
||||||
**volunteer.model_dump(),
|
**volunteer.model_dump(
|
||||||
|
exclude={"photo", "photo_content_type", "fee_checkout_id"}
|
||||||
|
),
|
||||||
artist_ids=_artist_ids(session, volunteer.id),
|
artist_ids=_artist_ids(session, volunteer.id),
|
||||||
wished_position_ids=_wished_position_ids(session, volunteer.id),
|
wished_position_ids=_wished_position_ids(session, volunteer.id),
|
||||||
wished_companion_ids=_companion_ids(session, volunteer.id),
|
wished_companion_ids=_companion_ids(session, volunteer.id),
|
||||||
)
|
has_photo=volunteer.photo is not None,
|
||||||
|
|
||||||
|
|
||||||
def _resolve_owned_ids(
|
|
||||||
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID], code: str
|
|
||||||
) -> list[uuid.UUID]:
|
|
||||||
"""Ordre d'entrée conservé, doublons retirés ; 422 si un id est étranger."""
|
|
||||||
wanted = list(dict.fromkeys(ids))
|
|
||||||
if wanted:
|
|
||||||
found = set(
|
|
||||||
session.exec(
|
|
||||||
select(model.id).where(
|
|
||||||
model.edition_id == edition_id,
|
|
||||||
model.id.in_(wanted),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if len(found) != len(wanted):
|
|
||||||
raise HTTPException(status_code=422, detail={"code": code})
|
|
||||||
return wanted
|
|
||||||
|
|
||||||
|
|
||||||
def _set_artists(
|
|
||||||
session: Session, volunteer: Volunteer, artist_ids: list[uuid.UUID]
|
|
||||||
) -> None:
|
|
||||||
ids = _resolve_owned_ids(
|
|
||||||
session, Artist, volunteer.edition_id, artist_ids, "artist.unknown"
|
|
||||||
)
|
|
||||||
replace_links(
|
|
||||||
session,
|
|
||||||
VolunteerArtist,
|
|
||||||
filter_column=VolunteerArtist.volunteer_id,
|
|
||||||
filter_value=volunteer.id,
|
|
||||||
pairs=[
|
|
||||||
{"volunteer_id": volunteer.id, "artist_id": aid} for aid in ids
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _set_wished_positions(
|
|
||||||
session: Session, volunteer: Volunteer, position_ids: list[uuid.UUID]
|
|
||||||
) -> None:
|
|
||||||
ids = _resolve_owned_ids(
|
|
||||||
session,
|
|
||||||
Position,
|
|
||||||
volunteer.edition_id,
|
|
||||||
position_ids,
|
|
||||||
"position.unknown",
|
|
||||||
)
|
|
||||||
replace_links(
|
|
||||||
session,
|
|
||||||
VolunteerPositionWish,
|
|
||||||
filter_column=VolunteerPositionWish.volunteer_id,
|
|
||||||
filter_value=volunteer.id,
|
|
||||||
pairs=[
|
|
||||||
{"volunteer_id": volunteer.id, "position_id": pid} for pid in ids
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _set_companions(
|
def _set_companions(
|
||||||
session: Session, volunteer: Volunteer, companion_ids: list[uuid.UUID]
|
session: Session, volunteer: Volunteer, companion_ids: list[uuid.UUID]
|
||||||
) -> None:
|
) -> None:
|
||||||
ids = _resolve_owned_ids(
|
ids = resolve_owned_ids(
|
||||||
session, Volunteer, volunteer.edition_id, companion_ids, "volunteer.unknown"
|
session, Volunteer, volunteer.edition_id, companion_ids, "volunteer.unknown"
|
||||||
)
|
)
|
||||||
if volunteer.id in ids:
|
if volunteer.id in ids:
|
||||||
@@ -177,6 +141,8 @@ def create_volunteer(
|
|||||||
_guard: Account = Depends(require_coordinator),
|
_guard: Account = Depends(require_coordinator),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> VolunteerRead:
|
) -> VolunteerRead:
|
||||||
|
payload.first_name = payload.first_name.title()
|
||||||
|
payload.last_name = payload.last_name.title()
|
||||||
volunteer = Volunteer(
|
volunteer = Volunteer(
|
||||||
edition_id=edition.id,
|
edition_id=edition.id,
|
||||||
**payload.model_dump(
|
**payload.model_dump(
|
||||||
@@ -185,8 +151,8 @@ def create_volunteer(
|
|||||||
)
|
)
|
||||||
session.add(volunteer)
|
session.add(volunteer)
|
||||||
session.flush()
|
session.flush()
|
||||||
_set_artists(session, volunteer, payload.artist_ids)
|
set_artists(session, volunteer, payload.artist_ids)
|
||||||
_set_wished_positions(session, volunteer, payload.wished_position_ids)
|
set_wished_positions(session, volunteer, payload.wished_position_ids)
|
||||||
_set_companions(session, volunteer, payload.wished_companion_ids)
|
_set_companions(session, volunteer, payload.wished_companion_ids)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(volunteer)
|
session.refresh(volunteer)
|
||||||
@@ -207,6 +173,100 @@ def list_volunteers(
|
|||||||
return [_read(session, v) for v in 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)
|
@router.get("/{volunteer_id}", response_model=VolunteerRead)
|
||||||
def get_volunteer(
|
def get_volunteer(
|
||||||
volunteer_id: uuid.UUID,
|
volunteer_id: uuid.UUID,
|
||||||
@@ -219,6 +279,110 @@ def get_volunteer(
|
|||||||
return _read(session, 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)
|
@router.patch("/{volunteer_id}", response_model=VolunteerRead)
|
||||||
def update_volunteer(
|
def update_volunteer(
|
||||||
volunteer_id: uuid.UUID,
|
volunteer_id: uuid.UUID,
|
||||||
@@ -236,9 +400,9 @@ def update_volunteer(
|
|||||||
setattr(volunteer, field, value)
|
setattr(volunteer, field, value)
|
||||||
session.flush()
|
session.flush()
|
||||||
if artist_ids is not None:
|
if artist_ids is not None:
|
||||||
_set_artists(session, volunteer, artist_ids)
|
set_artists(session, volunteer, artist_ids)
|
||||||
if wished_position_ids is not None:
|
if wished_position_ids is not None:
|
||||||
_set_wished_positions(session, volunteer, wished_position_ids)
|
set_wished_positions(session, volunteer, wished_position_ids)
|
||||||
if wished_companion_ids is not None:
|
if wished_companion_ids is not None:
|
||||||
_set_companions(session, volunteer, wished_companion_ids)
|
_set_companions(session, volunteer, wished_companion_ids)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import uuid
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ def _forbidden() -> HTTPException:
|
|||||||
|
|
||||||
|
|
||||||
def get_current_account(
|
def get_current_account(
|
||||||
|
request: Request,
|
||||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> Account:
|
) -> Account:
|
||||||
@@ -44,6 +45,9 @@ def get_current_account(
|
|||||||
account = session.get(Account, account_id)
|
account = session.get(Account, account_id)
|
||||||
if account is None:
|
if account is None:
|
||||||
raise _unauthorized()
|
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
|
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 functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic import model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
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):
|
class Settings(BaseSettings):
|
||||||
"""Configuration applicative, alimentée par l'environnement."""
|
"""Configuration applicative, alimentée par l'environnement."""
|
||||||
@@ -20,7 +27,7 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Authentification (lien magique + session JWT). Secret >= 32 octets.
|
# 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
|
magic_link_ttl_minutes: int = 30
|
||||||
session_ttl_hours: int = 12
|
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.
|
# (ou un alias validé). En cas de rejet, mettre SMTP_FROM = SMTP_USER.
|
||||||
smtp_from: str = "Festival des bénévoles <no-reply@example.test>"
|
smtp_from: str = "Festival des bénévoles <no-reply@example.test>"
|
||||||
|
|
||||||
|
# Formulaire public d'inscription (`app/api/forms.py`). Secret hCaptcha
|
||||||
|
# seulement (la clé de site est publique, elle vit côté front-end en
|
||||||
|
# VITE_HCAPTCHA_SITE_KEY, figée au build). None ⇒ vérification
|
||||||
|
# désactivée (dev/tests uniquement, voir `app/forms/captcha.py`).
|
||||||
|
hcaptcha_secret_key: str | None = None
|
||||||
|
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
cors_origins: list[str] = ["http://localhost:5173"]
|
cors_origins: list[str] = ["http://localhost:5173"]
|
||||||
|
|
||||||
@@ -49,6 +62,27 @@ class Settings(BaseSettings):
|
|||||||
def is_production(self) -> bool:
|
def is_production(self) -> bool:
|
||||||
return self.environment == "production"
|
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
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -4,7 +4,21 @@
|
|||||||
(sans destinataire ni corps — RGPD). Voir CLAUDE.md.
|
(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.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>"""
|
||||||
+108
-13
@@ -8,10 +8,11 @@ Pour diagnostiquer : `python -m app.email.check <destinataire>`."""
|
|||||||
import logging
|
import logging
|
||||||
import smtplib
|
import smtplib
|
||||||
import ssl
|
import ssl
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable, Mapping, Sequence
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
|
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
|
from app.email.html import LOGO_CID, render_html
|
||||||
|
|
||||||
logger = logging.getLogger("app.email")
|
logger = logging.getLogger("app.email")
|
||||||
|
|
||||||
@@ -32,12 +33,59 @@ def _smtp_error_summary(exc: smtplib.SMTPException) -> str:
|
|||||||
return exc.__class__.__name__
|
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 = EmailMessage()
|
||||||
message["From"] = get_settings().smtp_from
|
message["From"] = get_settings().smtp_from
|
||||||
message["To"] = to
|
message["To"] = to
|
||||||
message["Subject"] = subject
|
message["Subject"] = subject
|
||||||
message.set_content(body)
|
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
|
return message
|
||||||
|
|
||||||
|
|
||||||
@@ -102,19 +150,66 @@ def deliver_many(messages: list[EmailMessage]) -> None:
|
|||||||
message["From"],
|
message["From"],
|
||||||
_smtp_error_summary(exc),
|
_smtp_error_summary(exc),
|
||||||
)
|
)
|
||||||
# RGPD : ni destinataire ni corps, seulement le compte et le sujet.
|
# RGPD : ni destinataire, ni corps, ni sujet — un sujet de récapitulatif
|
||||||
logger.info(
|
# de planning (`app/api/notifications.py`) peut être personnalisé par la
|
||||||
"Emails remis : %d/%d — sujet=%r",
|
# coordination avec le nom du bénévole (`{prenom}`/`{nom}`), ce ne serait
|
||||||
sent,
|
# alors plus un simple texte d'interface.
|
||||||
len(messages),
|
logger.info("Emails remis : %d/%d", sent, len(messages))
|
||||||
messages[0]["Subject"],
|
|
||||||
|
|
||||||
|
def deliver_test(message: EmailMessage) -> tuple[str, str | None]:
|
||||||
|
"""Envoi d'un email de test, **synchrone** et sans « best effort » :
|
||||||
|
renvoie `(statut, détail)` au lieu de seulement journaliser, pour que la
|
||||||
|
coordination voie tout de suite ce qui ne va pas (`POST
|
||||||
|
.../notifications/test`). Statuts : `sent`, `disabled`
|
||||||
|
(`EMAIL_ENABLED=false`), `connection_failed` (connexion ou
|
||||||
|
authentification), `rejected` (le serveur a refusé le message). Détail :
|
||||||
|
message du serveur ou type d'erreur — jamais l'adresse du destinataire
|
||||||
|
(`_smtp_error_summary`)."""
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.email_enabled:
|
||||||
|
logger.info("Email de test non envoyé (EMAIL_ENABLED=false)")
|
||||||
|
return "disabled", None
|
||||||
|
try:
|
||||||
|
smtp = open_smtp(settings)
|
||||||
|
except smtplib.SMTPException as exc:
|
||||||
|
logger.error("Email de test : connexion SMTP refusée : %s", _smtp_error_summary(exc))
|
||||||
|
return "connection_failed", _smtp_error_summary(exc)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error("Email de test : SMTP injoignable : %s", exc)
|
||||||
|
return "connection_failed", f"{exc.__class__.__name__} : {exc}"
|
||||||
|
with smtp:
|
||||||
|
try:
|
||||||
|
smtp.send_message(message)
|
||||||
|
except smtplib.SMTPException as exc:
|
||||||
|
logger.error(
|
||||||
|
"Email de test refusé (From=%s) : %s", message["From"], _smtp_error_summary(exc)
|
||||||
)
|
)
|
||||||
|
return "rejected", _smtp_error_summary(exc)
|
||||||
|
logger.info("Email de test remis")
|
||||||
|
return "sent", None
|
||||||
|
|
||||||
|
|
||||||
def send(to: str, subject: str, body: str) -> None:
|
def send(
|
||||||
deliver_many([build_message(to, subject, body)])
|
to: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
*,
|
||||||
|
logo: tuple[bytes, str] | None = None,
|
||||||
|
attachments: Sequence[Attachment] = (),
|
||||||
|
) -> None:
|
||||||
|
deliver_many([build_message(to, subject, body, logo=logo, attachments=attachments)])
|
||||||
|
|
||||||
|
|
||||||
def send_many(items: Iterable[tuple[str, str, str]]) -> None:
|
def send_messages(messages: list[EmailMessage]) -> None:
|
||||||
"""`items` = itérable de `(destinataire, sujet, corps)`."""
|
"""Remet des messages déjà construits (`build_message`) — ex. le
|
||||||
deliver_many([build_message(*item) for item in items])
|
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:
|
def _fill(template: str, values: dict[str, object]) -> str:
|
||||||
out = template
|
out = template
|
||||||
for key in _PLANNING_PLACEHOLDERS:
|
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.unauthorized": "Authentification requise",
|
||||||
"error.validation_failed": "La validation a échoué",
|
"error.validation_failed": "La validation a échoué",
|
||||||
"error.conflict": "Conflit avec une ressource existante.",
|
"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.",
|
"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.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.",
|
"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.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.",
|
"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.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.duplicate": "Un artiste porte déjà ce nom à cette date dans l'édition.",
|
||||||
"artist.unknown": "Un des artistes choisis n'appartient pas à cette é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.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_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.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.empty_file": "Le fichier est vide.",
|
||||||
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
|
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
|
||||||
"import.error.unreadable_file": "Fichier illisible ou format non pris en charge.",
|
"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.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.",
|
"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.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é.",
|
"notification.no_recipients": "Aucun bénévole ciblé.",
|
||||||
"email.magic_link.subject": "Ton lien de connexion",
|
"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.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_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.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.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
|
||||||
"email.planning.shift_line": "- {position} : {start} → {end}",
|
"email.planning.table.time": "Horaire",
|
||||||
"availability.date_outside_edition": "La date {date} est hors des dates de l'édition.",
|
"email.planning.table.position": "Poste",
|
||||||
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes."
|
"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
|
import logging
|
||||||
|
from contextvars import ContextVar
|
||||||
|
|
||||||
from app.config import get_settings
|
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
|
# 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.
|
# 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:
|
def configure_logging() -> None:
|
||||||
# `force=True` : on réinstalle le handler racine même si l'import de
|
# `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.
|
# sinon les logs `app.*` n'apparaissent pas dans la sortie du conteneur.
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=get_settings().log_level,
|
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,
|
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 import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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.artists import router as artist_router
|
||||||
from app.api.assignments import router as assignment_router
|
from app.api.assignments import router as assignment_router
|
||||||
from app.api.auth import router as auth_router
|
from app.api.auth import router as auth_router
|
||||||
from app.api.availability import router as availability_router
|
from app.api.badges import router as badge_router
|
||||||
from app.api.day_parts import router as day_part_router
|
from app.api.day_parts import router as day_part_router
|
||||||
from app.api.editions import router as edition_router
|
from app.api.editions import router as edition_router
|
||||||
|
from app.api.forms import router as form_router
|
||||||
from app.api.health import router as health_router
|
from app.api.health import router as health_router
|
||||||
from app.api.imports import router as import_router
|
from app.api.imports import router as import_router
|
||||||
from app.api.matrix import router as matrix_router
|
from app.api.matrix import router as matrix_router
|
||||||
from app.api.notifications import router as notification_router
|
from app.api.notifications import router as notification_router
|
||||||
|
from app.api.position_sheets import router as position_sheets_router
|
||||||
from app.api.positions import router as position_router
|
from app.api.positions import router as position_router
|
||||||
from app.api.shifts import router as shift_router
|
from app.api.shifts import router as shift_router
|
||||||
from app.api.solver import router as solver_router
|
from app.api.solver import router as solver_router
|
||||||
|
from app.api.stats import router as stats_router
|
||||||
from app.api.volunteers import router as volunteer_router
|
from app.api.volunteers import router as volunteer_router
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.logging_config import configure_logging
|
from app.logging_config import configure_logging
|
||||||
|
from app.request_logging import REQUEST_ID_HEADER, install_request_logging
|
||||||
|
|
||||||
configure_logging()
|
configure_logging()
|
||||||
|
|
||||||
settings = get_settings()
|
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 = FastAPI(title="Gestion des bénévoles", version="0.1.0")
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -31,7 +57,9 @@ app.add_middleware(
|
|||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
expose_headers=[REQUEST_ID_HEADER],
|
||||||
)
|
)
|
||||||
|
install_request_logging(app)
|
||||||
|
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
@@ -41,10 +69,13 @@ app.include_router(account_router)
|
|||||||
app.include_router(import_router)
|
app.include_router(import_router)
|
||||||
app.include_router(artist_router)
|
app.include_router(artist_router)
|
||||||
app.include_router(volunteer_router)
|
app.include_router(volunteer_router)
|
||||||
app.include_router(availability_router)
|
|
||||||
app.include_router(position_router)
|
app.include_router(position_router)
|
||||||
app.include_router(shift_router)
|
app.include_router(shift_router)
|
||||||
app.include_router(assignment_router)
|
app.include_router(assignment_router)
|
||||||
app.include_router(solver_router)
|
app.include_router(solver_router)
|
||||||
app.include_router(matrix_router)
|
app.include_router(matrix_router)
|
||||||
|
app.include_router(stats_router)
|
||||||
app.include_router(notification_router)
|
app.include_router(notification_router)
|
||||||
|
app.include_router(badge_router)
|
||||||
|
app.include_router(form_router)
|
||||||
|
app.include_router(position_sheets_router)
|
||||||
|
|||||||
@@ -0,0 +1,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,
|
Account,
|
||||||
Artist,
|
Artist,
|
||||||
Assignment,
|
Assignment,
|
||||||
Availability,
|
BadgeField,
|
||||||
|
BadgeTemplate,
|
||||||
DayPart,
|
DayPart,
|
||||||
Edition,
|
Edition,
|
||||||
|
FormField,
|
||||||
|
FormPaymentOption,
|
||||||
|
FormTemplate,
|
||||||
MagicLinkToken,
|
MagicLinkToken,
|
||||||
|
PendingFormSubmission,
|
||||||
Position,
|
Position,
|
||||||
Shift,
|
Shift,
|
||||||
Volunteer,
|
Volunteer,
|
||||||
@@ -22,6 +27,13 @@ from app.models.domain import (
|
|||||||
)
|
)
|
||||||
from app.models.enums import (
|
from app.models.enums import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
|
AttendanceStatus,
|
||||||
|
BadgeAlign,
|
||||||
|
BadgeFace,
|
||||||
|
BadgeFieldType,
|
||||||
|
BadgePhotoShape,
|
||||||
|
FeePaymentStatus,
|
||||||
|
FormFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Role,
|
Role,
|
||||||
)
|
)
|
||||||
@@ -31,10 +43,22 @@ __all__ = [
|
|||||||
"Artist",
|
"Artist",
|
||||||
"Assignment",
|
"Assignment",
|
||||||
"AssignmentSource",
|
"AssignmentSource",
|
||||||
"Availability",
|
"AttendanceStatus",
|
||||||
|
"BadgeAlign",
|
||||||
|
"BadgeFace",
|
||||||
|
"BadgeField",
|
||||||
|
"BadgeFieldType",
|
||||||
|
"BadgePhotoShape",
|
||||||
|
"BadgeTemplate",
|
||||||
"DayPart",
|
"DayPart",
|
||||||
"Edition",
|
"Edition",
|
||||||
|
"FeePaymentStatus",
|
||||||
|
"FormField",
|
||||||
|
"FormFieldType",
|
||||||
|
"FormPaymentOption",
|
||||||
|
"FormTemplate",
|
||||||
"MagicLinkToken",
|
"MagicLinkToken",
|
||||||
|
"PendingFormSubmission",
|
||||||
"Position",
|
"Position",
|
||||||
"PositionCategory",
|
"PositionCategory",
|
||||||
"Role",
|
"Role",
|
||||||
|
|||||||
+403
-37
@@ -7,15 +7,23 @@ au niveau base (`ondelete="CASCADE"`) ; les relations ORM utilisent
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import Boolean, CheckConstraint, Column, Integer, Time
|
from sqlalchemy import Boolean, CheckConstraint, Column, Integer, LargeBinary, Time
|
||||||
from sqlalchemy import Enum as SAEnum
|
from sqlalchemy import Enum as SAEnum
|
||||||
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
|
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
|
||||||
|
|
||||||
from app.models.base import TimestampMixin
|
from app.models.base import TimestampMixin
|
||||||
from app.models.enums import (
|
from app.models.enums import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
|
AttendanceStatus,
|
||||||
|
BadgeAlign,
|
||||||
|
BadgeFace,
|
||||||
|
BadgeFieldType,
|
||||||
|
BadgePhotoShape,
|
||||||
|
FeePaymentStatus,
|
||||||
|
FormFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Role,
|
Role,
|
||||||
)
|
)
|
||||||
@@ -85,6 +93,11 @@ class Edition(TimestampMixin, table=True):
|
|||||||
end_date: dt.date
|
end_date: dt.date
|
||||||
# Durée de rétention des données nominatives, en jours. None = pas de purge.
|
# Durée de rétention des données nominatives, en jours. None = pas de purge.
|
||||||
retention_days: int | None = None
|
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(
|
day_parts: list["DayPart"] = Relationship(
|
||||||
back_populates="edition", sa_relationship_kwargs=_PASSIVE
|
back_populates="edition", sa_relationship_kwargs=_PASSIVE
|
||||||
@@ -155,6 +168,10 @@ class Artist(TimestampMixin, table=True):
|
|||||||
|
|
||||||
|
|
||||||
class Volunteer(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)
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
edition_id: uuid.UUID = Field(
|
edition_id: uuid.UUID = Field(
|
||||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||||
@@ -168,6 +185,74 @@ class Volunteer(TimestampMixin, table=True):
|
|||||||
last_name: str
|
last_name: str
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
|
# Renseigné par le formulaire public d'inscription (`app/api/forms.py`) —
|
||||||
|
# voir aussi ce module pour `photo`/`photo_content_type`.
|
||||||
|
photo: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
||||||
|
photo_content_type: str | None = None
|
||||||
|
# « Collègue que j'aime bien » saisi en texte libre à l'inscription : la
|
||||||
|
# personne visée n'existe pas forcément encore en base à ce moment-là,
|
||||||
|
# donc pas de lien direct vers un autre `Volunteer`. La coordination lit
|
||||||
|
# cette note sur la fiche et la transforme, si besoin, en un vrai
|
||||||
|
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
|
||||||
|
companion_wish_note: str | None = None
|
||||||
|
# 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(
|
wished_artists: list[Artist] = Relationship(
|
||||||
back_populates="fans",
|
back_populates="fans",
|
||||||
@@ -179,9 +264,6 @@ class Volunteer(TimestampMixin, table=True):
|
|||||||
link_model=VolunteerPositionWish,
|
link_model=VolunteerPositionWish,
|
||||||
sa_relationship_kwargs=_PASSIVE,
|
sa_relationship_kwargs=_PASSIVE,
|
||||||
)
|
)
|
||||||
availabilities: list["Availability"] = Relationship(
|
|
||||||
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
|
|
||||||
)
|
|
||||||
assignments: list["Assignment"] = Relationship(
|
assignments: list["Assignment"] = Relationship(
|
||||||
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
|
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
|
||||||
)
|
)
|
||||||
@@ -212,6 +294,12 @@ class Account(TimestampMixin, table=True):
|
|||||||
nullable=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
|
@property
|
||||||
def auth_method(self) -> str:
|
def auth_method(self) -> str:
|
||||||
return "password" if self.password_hash else "magic_link"
|
return "password" if self.password_hash else "magic_link"
|
||||||
@@ -250,6 +338,10 @@ class Position(TimestampMixin, table=True):
|
|||||||
ondelete="SET NULL",
|
ondelete="SET NULL",
|
||||||
nullable=True,
|
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(
|
shifts: list["Shift"] = Relationship(
|
||||||
back_populates="position", sa_relationship_kwargs=_PASSIVE
|
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):
|
class Assignment(TimestampMixin, table=True):
|
||||||
"""Décision de l'organisation : « tu tiens tel créneau ».
|
"""Décision de l'organisation : « tu tiens tel créneau ».
|
||||||
|
|
||||||
Existe ⇒ le bénévole est affecté (pas d'étape de confirmation). Retirer une
|
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
|
affectation, c'est supprimer la ligne.
|
||||||
(déclaration du bénévole).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -361,6 +421,312 @@ class Assignment(TimestampMixin, table=True):
|
|||||||
default=False,
|
default=False,
|
||||||
sa_column=Column(Boolean, nullable=False, server_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")
|
volunteer: Volunteer = Relationship(back_populates="assignments")
|
||||||
shift: Shift = Relationship(back_populates="assignments")
|
shift: Shift = Relationship(back_populates="assignments")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Badges (planches recto/verso, voir app/badges/) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class BadgeTemplate(TimestampMixin, table=True):
|
||||||
|
"""Recto/verso du badge d'une édition. Une ligne par édition (créée à la
|
||||||
|
demande). Le verso (`back_image`) est le même pour tous les bénévoles ;
|
||||||
|
le recto combine `front_image` (fond) et les `BadgeField` positionnés
|
||||||
|
dessus. Images stockées en base (pas de volume de fichiers à gérer).
|
||||||
|
|
||||||
|
Le format du badge (`front_image_width_mm`/`height_mm`) est **dérivé de
|
||||||
|
l'image du recto** à l'import (voir `app/badges/pdf.py:image_size_mm`,
|
||||||
|
résolution supposée 300 dpi) plutôt qu'un format fixe imposé — `None`
|
||||||
|
tant qu'aucune image n'a été importée (l'éditeur retombe alors sur le
|
||||||
|
format CR80 par défaut, voir `DEFAULT_BADGE_WIDTH_MM`)."""
|
||||||
|
|
||||||
|
__tablename__ = "badge_template"
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
edition_id: uuid.UUID = Field(
|
||||||
|
foreign_key="edition.id", unique=True, ondelete="CASCADE"
|
||||||
|
)
|
||||||
|
front_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
||||||
|
front_image_content_type: str | None = None
|
||||||
|
front_image_width_mm: float | None = None
|
||||||
|
front_image_height_mm: float | None = None
|
||||||
|
back_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
||||||
|
back_image_content_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BadgeField(TimestampMixin, table=True):
|
||||||
|
"""Un champ positionné sur 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"
|
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):
|
class PositionCategory(enum.StrEnum):
|
||||||
BAR = "BAR"
|
BAR = "BAR"
|
||||||
TICKETING = "TICKETING"
|
TICKETING = "TICKETING"
|
||||||
@@ -28,3 +35,68 @@ class PositionCategory(enum.StrEnum):
|
|||||||
SETUP = "SETUP"
|
SETUP = "SETUP"
|
||||||
STAGE = "STAGE"
|
STAGE = "STAGE"
|
||||||
OTHER = "OTHER"
|
OTHER = "OTHER"
|
||||||
|
|
||||||
|
|
||||||
|
class BadgeFieldType(enum.StrEnum):
|
||||||
|
"""Champ affichable sur le recto 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 datetime as dt
|
||||||
import random
|
import random
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from sqlalchemy import delete
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.auth.passwords import hash_password
|
from app.auth.passwords import hash_password
|
||||||
@@ -22,9 +21,9 @@ from app.models import (
|
|||||||
Artist,
|
Artist,
|
||||||
Assignment,
|
Assignment,
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
Availability,
|
|
||||||
DayPart,
|
DayPart,
|
||||||
Edition,
|
Edition,
|
||||||
|
FeePaymentStatus,
|
||||||
Position,
|
Position,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Role,
|
Role,
|
||||||
@@ -34,14 +33,7 @@ from app.models import (
|
|||||||
VolunteerCompanionWish,
|
VolunteerCompanionWish,
|
||||||
VolunteerPositionWish,
|
VolunteerPositionWish,
|
||||||
)
|
)
|
||||||
from app.validation import (
|
from app.validation import Severity, validate
|
||||||
AvailabilityWindow,
|
|
||||||
DayPartWindow,
|
|
||||||
Severity,
|
|
||||||
ShiftInfo,
|
|
||||||
ValidationInput,
|
|
||||||
validate,
|
|
||||||
)
|
|
||||||
from app.validation.loader import build_validation_input
|
from app.validation.loader import build_validation_input
|
||||||
|
|
||||||
SEED = 20260709
|
SEED = 20260709
|
||||||
@@ -203,6 +195,14 @@ ARTIST_SLOTS: list[tuple[dt.time, dt.time]] = [
|
|||||||
(dt.time(21, 30), dt.time(23, 0)),
|
(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
|
@dataclass
|
||||||
class Summary:
|
class Summary:
|
||||||
@@ -216,7 +216,8 @@ class Summary:
|
|||||||
position_wishes: int = 0
|
position_wishes: int = 0
|
||||||
artist_wishes: int = 0
|
artist_wishes: int = 0
|
||||||
companion_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
|
blocking: int = 0
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
@@ -232,10 +233,9 @@ class Summary:
|
|||||||
f" Vœux de poste déclarés : {self.position_wishes}",
|
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 d'artiste déclarés : {self.artist_wishes}",
|
||||||
f" Vœux de compagnon déclarés : {self.companion_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(f" Violations bloquantes : {self.blocking}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(
|
lines.append(
|
||||||
@@ -296,9 +296,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
|||||||
]
|
]
|
||||||
session.add_all(day_parts)
|
session.add_all(day_parts)
|
||||||
session.flush()
|
session.flush()
|
||||||
dp_windows = tuple(
|
|
||||||
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order) for dp in day_parts
|
|
||||||
)
|
|
||||||
|
|
||||||
coordination = Account(
|
coordination = Account(
|
||||||
edition_id=edition.id,
|
edition_id=edition.id,
|
||||||
@@ -415,27 +412,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
n_companion_wishes += 1
|
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] = []
|
shifts: list[Shift] = []
|
||||||
for position, (_n, category, _l, _r) in zip(positions, POSITIONS, strict=True):
|
for position, (_n, category, _l, _r) in zip(positions, POSITIONS, strict=True):
|
||||||
for day in dates:
|
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}
|
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
|
n_assignments = 0
|
||||||
order = shifts[:]
|
order = shifts[:]
|
||||||
rng.shuffle(order)
|
rng.shuffle(order)
|
||||||
@@ -487,7 +441,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
|||||||
target = max(0, shift.min_staff - 1)
|
target = max(0, shift.min_staff - 1)
|
||||||
else:
|
else:
|
||||||
target = rng.randint(shift.min_staff, shift.ideal_staff)
|
target = rng.randint(shift.min_staff, shift.ideal_staff)
|
||||||
pool = assignable[:]
|
pool = volunteers[:]
|
||||||
rng.shuffle(pool)
|
rng.shuffle(pool)
|
||||||
placed = 0
|
placed = 0
|
||||||
for volunteer in pool:
|
for volunteer in pool:
|
||||||
@@ -495,8 +449,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
|||||||
break
|
break
|
||||||
if any(_overlap(shift, s) for s in assigned[volunteer.id]):
|
if any(_overlap(shift, s) for s in assigned[volunteer.id]):
|
||||||
continue
|
continue
|
||||||
if not _clean(volunteer, shift):
|
|
||||||
continue
|
|
||||||
session.add(
|
session.add(
|
||||||
Assignment(
|
Assignment(
|
||||||
edition_id=edition.id,
|
edition_id=edition.id,
|
||||||
@@ -509,9 +461,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
|||||||
placed += 1
|
placed += 1
|
||||||
n_assignments += 1
|
n_assignments += 1
|
||||||
|
|
||||||
n_assignments += _add_demo_warnings(
|
n_fee_classique, n_fee_mecenat = _add_demo_payments(session, volunteers, rng)
|
||||||
session, edition, shifts, reserved, assigned, availabilities
|
|
||||||
)
|
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
fill: Counter = Counter()
|
fill: Counter = Counter()
|
||||||
@@ -537,103 +487,49 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
|||||||
position_wishes=n_position_wishes,
|
position_wishes=n_position_wishes,
|
||||||
artist_wishes=n_artist_wishes,
|
artist_wishes=n_artist_wishes,
|
||||||
companion_wishes=n_companion_wishes,
|
companion_wishes=n_companion_wishes,
|
||||||
|
fee_paid_classique=n_fee_classique,
|
||||||
|
fee_paid_mecenat=n_fee_mecenat,
|
||||||
)
|
)
|
||||||
_verify(session, edition, summary)
|
_verify(session, edition, summary)
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
def _clear_day(session, volunteer, day, availabilities) -> None:
|
def _add_demo_payments(
|
||||||
session.execute(
|
session: Session, volunteers: list[Volunteer], rng: random.Random
|
||||||
delete(Availability)
|
) -> tuple[int, int]:
|
||||||
.where(Availability.volunteer_id == volunteer.id)
|
"""Quelques bénévoles ayant payé leur cotisation via SumUp — voir
|
||||||
.where(Availability.date == day)
|
CLAUDE.md § 2, Paiement de la cotisation. `fee_payment_label`/
|
||||||
|
`fee_amount` sont une copie figée (comme en production) : aucun
|
||||||
|
`FormPaymentOption` n'a besoin d'exister pour autant. Pour que l'export
|
||||||
|
Excel et les reçus fiscaux mécénat (`/editions/{id}/volunteers/
|
||||||
|
mecenat-receipts`) aient de quoi montrer quelque chose en démonstration."""
|
||||||
|
pool = rng.sample(volunteers, k=12)
|
||||||
|
classique, mecenat = pool[:8], pool[8:]
|
||||||
|
|
||||||
|
for volunteer in classique:
|
||||||
|
volunteer.fee_payment_label = "Cotisation classique"
|
||||||
|
volunteer.fee_amount = 15.0
|
||||||
|
volunteer.fee_payment_status = FeePaymentStatus.PAID
|
||||||
|
volunteer.fee_paid_at = dt.datetime.combine(
|
||||||
|
START - dt.timedelta(days=rng.randint(5, 45)), dt.time(rng.randint(9, 21))
|
||||||
)
|
)
|
||||||
availabilities[volunteer.id] = [
|
session.add(volunteer)
|
||||||
w for w in availabilities[volunteer.id] if w.date != day
|
|
||||||
]
|
|
||||||
|
|
||||||
|
for i, volunteer in enumerate(mecenat):
|
||||||
def _declare_window(
|
address, postal_code, city = MECENAT_ADDRESSES[i % len(MECENAT_ADDRESSES)]
|
||||||
session, edition, volunteer, day, start, end, availabilities
|
volunteer.fee_payment_label = "Mécénat"
|
||||||
) -> None:
|
volunteer.fee_amount = rng.choice([30.0, 50.0, 80.0, 120.0])
|
||||||
session.add(
|
volunteer.fee_payment_status = FeePaymentStatus.PAID
|
||||||
Availability(
|
volunteer.fee_paid_at = dt.datetime.combine(
|
||||||
edition_id=edition.id,
|
START - dt.timedelta(days=rng.randint(5, 45)), dt.time(rng.randint(9, 21))
|
||||||
volunteer_id=volunteer.id,
|
|
||||||
date=day,
|
|
||||||
start_time=start,
|
|
||||||
end_time=end,
|
|
||||||
)
|
)
|
||||||
)
|
volunteer.mecenat_address = address
|
||||||
availabilities[volunteer.id].append(AvailabilityWindow(day, start, end))
|
volunteer.mecenat_postal_code = postal_code
|
||||||
|
volunteer.mecenat_city = city
|
||||||
|
volunteer.mecenat_country = "France"
|
||||||
|
session.add(volunteer)
|
||||||
|
|
||||||
|
return len(classique), len(mecenat)
|
||||||
def _add_demo_warnings(
|
|
||||||
session, edition, shifts, reserved, assigned, availabilities
|
|
||||||
) -> int:
|
|
||||||
"""Quelques affectations qui déclenchent des avertissements (et rien
|
|
||||||
d'autre) : hors disponibilité, repos insuffisant. `reserved` = trois
|
|
||||||
bénévoles tenus à l'écart de l'affectation générale.
|
|
||||||
"""
|
|
||||||
a, b, tired = reserved
|
|
||||||
added = 0
|
|
||||||
|
|
||||||
# OUTSIDE_AVAILABILITY : deux bénévoles affectés un jour où ils n'ont
|
|
||||||
# déclaré aucune disponibilité.
|
|
||||||
plain = sorted(shifts, key=lambda s: s.starts_at)
|
|
||||||
for volunteer, shift in ((a, plain[3]), (b, plain[7])):
|
|
||||||
_clear_day(session, volunteer, shift.starts_at.date(), availabilities)
|
|
||||||
_add_active(session, edition, volunteer, shift, assigned)
|
|
||||||
added += 1
|
|
||||||
|
|
||||||
# INSUFFICIENT_REST : deux créneaux espacés de moins de 8 h, sur deux
|
|
||||||
# journées (pour ne pas déclencher le cumul), tous deux couverts par une
|
|
||||||
# disponibilité.
|
|
||||||
pair = _find_rest_pair(shifts)
|
|
||||||
if pair is not None:
|
|
||||||
first, second = pair
|
|
||||||
for shift in (first, second):
|
|
||||||
_clear_day(session, tired, shift.starts_at.date(), availabilities)
|
|
||||||
_declare_window(
|
|
||||||
session,
|
|
||||||
edition,
|
|
||||||
tired,
|
|
||||||
shift.starts_at.date(),
|
|
||||||
dt.time(6, 0),
|
|
||||||
dt.time(4, 0),
|
|
||||||
availabilities,
|
|
||||||
)
|
|
||||||
_add_active(session, edition, tired, first, assigned)
|
|
||||||
_add_active(session, edition, tired, second, assigned)
|
|
||||||
added += 2
|
|
||||||
|
|
||||||
return added
|
|
||||||
|
|
||||||
|
|
||||||
def _find_rest_pair(shifts: list[Shift]) -> tuple[Shift, Shift] | None:
|
|
||||||
plain = sorted(shifts, key=lambda s: s.starts_at)
|
|
||||||
for i, first in enumerate(plain):
|
|
||||||
for second in plain[i + 1 :]:
|
|
||||||
gap = second.starts_at - first.ends_at
|
|
||||||
if (
|
|
||||||
first.ends_at <= second.starts_at
|
|
||||||
and dt.timedelta(0) < gap < dt.timedelta(hours=8)
|
|
||||||
and first.starts_at.date() != second.starts_at.date()
|
|
||||||
):
|
|
||||||
return first, second
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _add_active(session, edition, volunteer, shift, assigned) -> None:
|
|
||||||
session.add(
|
|
||||||
Assignment(
|
|
||||||
edition_id=edition.id,
|
|
||||||
volunteer_id=volunteer.id,
|
|
||||||
shift_id=shift.id,
|
|
||||||
source=AssignmentSource.MANUAL,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assigned[volunteer.id].append(shift)
|
|
||||||
|
|
||||||
|
|
||||||
def _verify(session: Session, edition: Edition, summary: Summary) -> None:
|
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:
|
if violation.severity is Severity.BLOCKING:
|
||||||
summary.blocking += 1
|
summary.blocking += 1
|
||||||
else:
|
|
||||||
summary.warnings[violation.code] += 1
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
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
|
coordination qui affecte (dans l'app, via l'écran habituel — le solveur se
|
||||||
contente de faire remonter ces candidats en tête de liste).
|
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
|
Contrainte dure : jamais deux suggestions qui se chevauchent pour un même
|
||||||
`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (disponibilité, repos,
|
bénévole dans un même calcul. Un bénévole déjà affecté à un autre poste au
|
||||||
chaînage entre créneaux consécutifs, vœu de poste, vœu de compagnon, vœu
|
même moment reste proposable (la validation ne fait qu'avertir,
|
||||||
d'artiste, équité de charge) est un terme de l'objectif, pas une contrainte —
|
`SHIFT_OVERLAP`) mais avec un fort malus. Tout le reste (chaînage entre
|
||||||
comme le moteur de validation, qui reste seul juge au moment où la
|
créneaux consécutifs, vœu de poste, vœu de compagnon, vœu d'artiste, équité de
|
||||||
coordination confirme une affectation.
|
charge) est un terme de l'objectif, pas une contrainte. Aucune limite d'heures
|
||||||
Pas de plafond de cumul journalier côté solveur : `DAILY_HOURS_EXCEEDED`
|
par journée ni de temps de repos : ni le solveur ni la validation n'en
|
||||||
reste vérifiée par la validation, à la confirmation.
|
tiennent compte.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime as dt
|
|
||||||
|
|
||||||
from ortools.sat.python import cp_model
|
from ortools.sat.python import cp_model
|
||||||
|
|
||||||
from app.solver.models import (
|
from app.solver.models import (
|
||||||
@@ -26,22 +24,16 @@ from app.solver.models import (
|
|||||||
SolverSolution,
|
SolverSolution,
|
||||||
SolverVolunteer,
|
SolverVolunteer,
|
||||||
)
|
)
|
||||||
from app.validation.engine import uncovered_minutes
|
|
||||||
from app.validation.models import ShiftInfo
|
from app.validation.models import ShiftInfo
|
||||||
|
|
||||||
_EIGHT_HOURS_MIN = 8 * 60
|
|
||||||
|
|
||||||
# Poids de l'objectif (unités arbitraires, cf. docstring de `_score`).
|
# Poids de l'objectif (unités arbitraires, cf. docstring de `_score`).
|
||||||
# L'écart à un effectif minimal non atteint prime sur tout le reste : le
|
# 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
|
# solveur doit toujours proposer quelqu'un pour un créneau sous-staffé, quitte
|
||||||
# à ce que la disponibilité soit imparfaite (avertissement, pas un blocage —
|
# à ce que le candidat soit mal assorti. Au-delà du minimum, un candidat mal
|
||||||
# la validation reste seule juge à la confirmation). Au-delà du minimum, un
|
# assorti peut légitimement ne pas être suggéré du tout.
|
||||||
# candidat mal assorti peut légitimement ne pas être suggéré du tout.
|
|
||||||
_URGENT_BASE = 1000 # créneau encore sous son effectif minimal
|
_URGENT_BASE = 1000 # créneau encore sous son effectif minimal
|
||||||
_NORMAL_BASE = 20 # créneau entre effectif minimal et idéal
|
_NORMAL_BASE = 20 # créneau entre effectif minimal et idéal
|
||||||
_WISH_BONUS = 30
|
_WISH_BONUS = 30
|
||||||
_AVAIL_PENALTY_PER_HALF_HOUR = 5
|
|
||||||
_REST_PENALTY = 60
|
|
||||||
_FAIRNESS_PENALTY_PER_HOUR = 2
|
_FAIRNESS_PENALTY_PER_HOUR = 2
|
||||||
# Le créneau lui ferait manquer le passage d'un artiste qu'il veut voir.
|
# Le créneau lui ferait manquer le passage d'un artiste qu'il veut voir.
|
||||||
_ARTIST_CONFLICT_PENALTY = 80
|
_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
|
# n'importe quel poste) : limite les enchaînements de shifts plutôt que de
|
||||||
# faire tourner un poste en particulier.
|
# faire tourner un poste en particulier.
|
||||||
_CHAIN_PENALTY = 40
|
_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
|
_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:
|
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
|
||||||
return a.starts_at < b.ends_at and b.starts_at < a.ends_at
|
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:
|
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
|
"""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 :
|
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.
|
"""Score d'affinité (bénévole, créneau) : plus haut = meilleur candidat.
|
||||||
|
|
||||||
Purement additif (aucune interaction entre deux affectations proposées),
|
Purement additif (aucune interaction entre deux affectations proposées),
|
||||||
ce qui garde le modèle CP-SAT linéaire simple. Repos, chaînage et
|
ce qui garde le modèle CP-SAT linéaire simple. Chaînage et compagnons ne
|
||||||
compagnons ne sont vérifiés que contre les affectations déjà décidées,
|
sont vérifiés que contre les affectations déjà décidées, pas entre deux
|
||||||
pas entre deux suggestions du même calcul : la validation finale (à la
|
suggestions du même calcul (deux compagnons suggérés ensemble ne se
|
||||||
confirmation) reste seule juge en cas de cas limite entre deux
|
renforcent pas mutuellement, faute d'interaction entre variables).
|
||||||
suggestions (et deux compagnons suggérés ensemble dans le même calcul ne
|
|
||||||
se renforcent pas mutuellement, faute d'interaction entre variables).
|
|
||||||
"""
|
"""
|
||||||
score = _URGENT_BASE if shift.already_assigned < shift.min_staff else _NORMAL_BASE
|
score = _URGENT_BASE if shift.already_assigned < shift.min_staff else _NORMAL_BASE
|
||||||
if shift.position_id in volunteer.wished_position_ids:
|
if shift.position_id in volunteer.wished_position_ids:
|
||||||
score += _WISH_BONUS
|
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):
|
if _chained_from_predecessor(volunteer, shift):
|
||||||
score -= _CHAIN_PENALTY
|
score -= _CHAIN_PENALTY
|
||||||
companions_present = len(volunteer.wished_companion_ids & shift.assigned_volunteer_ids)
|
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
|
_overlaps(shift.shift_info, passage) for passage in volunteer.wished_artist_windows
|
||||||
):
|
):
|
||||||
score -= _ARTIST_CONFLICT_PENALTY
|
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
|
score -= (_FAIRNESS_PENALTY_PER_HOUR * volunteer.existing_minutes) // 60
|
||||||
return score
|
return score
|
||||||
|
|
||||||
@@ -129,7 +107,8 @@ def solve(
|
|||||||
if gap <= 0:
|
if gap <= 0:
|
||||||
continue
|
continue
|
||||||
for vi, volunteer in enumerate(data.volunteers):
|
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
|
continue
|
||||||
key = (vi, si)
|
key = (vi, si)
|
||||||
variables[key] = model.NewBoolVar(f"x_{vi}_{si}")
|
variables[key] = model.NewBoolVar(f"x_{vi}_{si}")
|
||||||
@@ -146,8 +125,8 @@ def solve(
|
|||||||
if shift_vars and gap > 0:
|
if shift_vars and gap > 0:
|
||||||
model.Add(sum(shift_vars) <= gap)
|
model.Add(sum(shift_vars) <= gap)
|
||||||
|
|
||||||
# Aucun chevauchement entre deux créneaux nouvellement suggérés au même
|
# Jamais deux créneaux qui se chevauchent suggérés au même bénévole dans
|
||||||
# bénévole (règle `SHIFT_OVERLAP`, bloquante).
|
# un même calcul (contre les affectations décidées : malus, cf. `_score`).
|
||||||
for vi in range(len(data.volunteers)):
|
for vi in range(len(data.volunteers)):
|
||||||
my_shifts = [si for si in range(len(data.shifts)) if (vi, si) in variables]
|
my_shifts = [si for si in range(len(data.shifts)) if (vi, si) in variables]
|
||||||
for a in range(len(my_shifts)):
|
for a in range(len(my_shifts)):
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlmodel import Session, select
|
|||||||
from app.models import (
|
from app.models import (
|
||||||
Artist,
|
Artist,
|
||||||
Assignment,
|
Assignment,
|
||||||
Availability,
|
DayPart,
|
||||||
Edition,
|
Edition,
|
||||||
Shift,
|
Shift,
|
||||||
Volunteer,
|
Volunteer,
|
||||||
@@ -18,7 +18,8 @@ from app.models import (
|
|||||||
VolunteerPositionWish,
|
VolunteerPositionWish,
|
||||||
)
|
)
|
||||||
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
|
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:
|
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}
|
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
|
# 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
|
# 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
|
# (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 = {}
|
predecessor_ids: dict = {}
|
||||||
by_day: dict = defaultdict(list)
|
by_day: dict = defaultdict(list)
|
||||||
for s in shifts:
|
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():
|
for day_shifts in by_day.values():
|
||||||
ids_by_end: dict = defaultdict(list)
|
ids_by_end: dict = defaultdict(list)
|
||||||
for s in day_shifts:
|
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
|
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)
|
wishes: dict = defaultdict(set)
|
||||||
for w in session.exec(
|
for w in session.exec(
|
||||||
select(VolunteerPositionWish).join(
|
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))
|
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)
|
companion_wishes: dict = defaultdict(set)
|
||||||
for w in session.exec(
|
for w in session.exec(
|
||||||
select(VolunteerCompanionWish).join(
|
select(VolunteerCompanionWish).join(
|
||||||
@@ -109,6 +110,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
|||||||
).where(Volunteer.edition_id == edition.id)
|
).where(Volunteer.edition_id == edition.id)
|
||||||
):
|
):
|
||||||
companion_wishes[w.volunteer_id].add(str(w.companion_id))
|
companion_wishes[w.volunteer_id].add(str(w.companion_id))
|
||||||
|
companion_wishes[w.companion_id].add(str(w.volunteer_id))
|
||||||
|
|
||||||
artists_by_id = {
|
artists_by_id = {
|
||||||
a.id: a
|
a.id: a
|
||||||
@@ -130,7 +132,6 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
|||||||
solver_volunteers = tuple(
|
solver_volunteers = tuple(
|
||||||
SolverVolunteer(
|
SolverVolunteer(
|
||||||
id=str(v.id),
|
id=str(v.id),
|
||||||
availability=tuple(availabilities.get(v.id, ())),
|
|
||||||
wished_position_ids=frozenset(wishes.get(v.id, ())),
|
wished_position_ids=frozenset(wishes.get(v.id, ())),
|
||||||
wished_companion_ids=frozenset(companion_wishes.get(v.id, ())),
|
wished_companion_ids=frozenset(companion_wishes.get(v.id, ())),
|
||||||
wished_artist_windows=tuple(artist_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
|
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
|
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
|
import datetime as dt
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from app.validation.models import AvailabilityWindow, ShiftInfo
|
from app.validation.models import ShiftInfo
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -39,16 +39,17 @@ class SolverShift:
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SolverVolunteer:
|
class SolverVolunteer:
|
||||||
id: str
|
id: str
|
||||||
availability: tuple[AvailabilityWindow, ...] = ()
|
|
||||||
wished_position_ids: frozenset[str] = frozenset()
|
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()
|
wished_companion_ids: frozenset[str] = frozenset()
|
||||||
# Passages des artistes que le bénévole veut voir (heure de passage),
|
# 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
|
# sous forme de fenêtres datées : un créneau qui chevauche l'une d'elles
|
||||||
# lui ferait manquer le passage.
|
# lui ferait manquer le passage.
|
||||||
wished_artist_windows: tuple[ShiftInfo, ...] = ()
|
wished_artist_windows: tuple[ShiftInfo, ...] = ()
|
||||||
# Créneaux déjà occupés (affectations existantes, décisions figées) :
|
# 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, ...] = ()
|
existing_shifts: tuple[ShiftInfo, ...] = ()
|
||||||
# Total déjà affecté sur l'édition (minutes), pour répartir la charge.
|
# Total déjà affecté sur l'édition (minutes), pour répartir la charge.
|
||||||
existing_minutes: int = 0
|
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.engine import has_blocking, validate
|
||||||
from app.validation.models import (
|
from app.validation.models import (
|
||||||
AvailabilityWindow,
|
|
||||||
DayPartWindow,
|
DayPartWindow,
|
||||||
Severity,
|
Severity,
|
||||||
ShiftInfo,
|
ShiftInfo,
|
||||||
@@ -15,7 +14,6 @@ from app.validation.models import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AvailabilityWindow",
|
|
||||||
"DayPartWindow",
|
"DayPartWindow",
|
||||||
"Severity",
|
"Severity",
|
||||||
"ShiftInfo",
|
"ShiftInfo",
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ sortie. Règles et sémantique figées : voir CLAUDE.md § 3.
|
|||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
|
||||||
from app.validation.models import (
|
from app.validation.models import (
|
||||||
AvailabilityWindow,
|
|
||||||
Severity,
|
Severity,
|
||||||
ShiftInfo,
|
ShiftInfo,
|
||||||
ValidationInput,
|
ValidationInput,
|
||||||
@@ -15,15 +14,10 @@ from app.validation.models import (
|
|||||||
ViolationCode,
|
ViolationCode,
|
||||||
)
|
)
|
||||||
|
|
||||||
_EIGHT_HOURS = dt.timedelta(hours=8)
|
|
||||||
|
|
||||||
|
|
||||||
def validate(data: ValidationInput) -> list[Violation]:
|
def validate(data: ValidationInput) -> list[Violation]:
|
||||||
violations: list[Violation] = []
|
violations: list[Violation] = []
|
||||||
violations.extend(_check_overlap(data))
|
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
|
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:
|
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
|
||||||
# Bornes strictes : deux créneaux jointifs (fin == début) ne se chevauchent
|
# 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]:
|
_MINUTES_PER_DAY = 24 * 60
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def uncovered_minutes(
|
def day_boundary(day_parts) -> dt.time:
|
||||||
shift: ShiftInfo, windows: tuple[AvailabilityWindow, ...]
|
"""Début d'une « journée de festival » : début du plus long creux que
|
||||||
) -> int:
|
laissent les créneaux types (`DayPart`) sur le cadran de 24 h — l'heure à
|
||||||
"""Minutes du créneau qui ne tombent dans aucune fenêtre de disponibilité.
|
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
|
||||||
Le bénévole peut déclarer plusieurs fenêtres ; on regarde leur union.
|
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
|
||||||
cursor = shift.starts_at
|
commencé. Indépendant de `order`, qui n'est qu'un ordre d'affichage et
|
||||||
intervals = sorted(
|
ne suit pas les heures quand on les modifie (un créneau type d'ordre 0
|
||||||
(iv for iv in map(_window_interval, windows) if iv[1] > iv[0]),
|
déplacé à 21 h faisait commencer chaque journée à 21 h). Sans creux (les
|
||||||
key=lambda iv: iv[0],
|
créneaux types couvrent 24 h), début du premier par `order`. Accepte
|
||||||
)
|
aussi bien les `DayPartInfo` de ce module que les lignes `DayPart`.
|
||||||
gap = dt.timedelta()
|
Miroir front : `lib/planning.ts:dayBoundaryFromDayParts`."""
|
||||||
for start, end in intervals:
|
day_parts = list(day_parts)
|
||||||
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:
|
|
||||||
if not day_parts:
|
if not day_parts:
|
||||||
return dt.time(0, 0)
|
return dt.time(0, 0)
|
||||||
|
covered = [False] * _MINUTES_PER_DAY
|
||||||
|
for part in day_parts:
|
||||||
|
start = part.start_time.hour * 60 + part.start_time.minute
|
||||||
|
end = part.end_time.hour * 60 + part.end_time.minute
|
||||||
|
length = (end - start) % _MINUTES_PER_DAY or _MINUTES_PER_DAY
|
||||||
|
for offset in range(length):
|
||||||
|
covered[(start + offset) % _MINUTES_PER_DAY] = True
|
||||||
|
if all(covered):
|
||||||
return min(day_parts, key=lambda part: part.order).start_time
|
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:
|
if moment.time() >= boundary:
|
||||||
return moment.date()
|
return moment.date()
|
||||||
return moment.date() - dt.timedelta(days=1)
|
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 (
|
from app.models import (
|
||||||
Assignment,
|
Assignment,
|
||||||
Availability,
|
|
||||||
DayPart,
|
|
||||||
Edition,
|
Edition,
|
||||||
Shift,
|
Shift,
|
||||||
Volunteer,
|
Volunteer,
|
||||||
)
|
)
|
||||||
from app.validation.models import (
|
from app.validation.models import (
|
||||||
AvailabilityWindow,
|
|
||||||
DayPartWindow,
|
|
||||||
ShiftInfo,
|
ShiftInfo,
|
||||||
ValidationInput,
|
ValidationInput,
|
||||||
)
|
)
|
||||||
@@ -30,10 +26,6 @@ def build_validation_input(
|
|||||||
shift: Shift,
|
shift: Shift,
|
||||||
exclude_assignment_id: uuid.UUID | None = None,
|
exclude_assignment_id: uuid.UUID | None = None,
|
||||||
) -> ValidationInput:
|
) -> 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
|
# Toute affectation existante du bénévole compte (une affectation = une
|
||||||
# décision, toujours active).
|
# décision, toujours active).
|
||||||
stmt = (
|
stmt = (
|
||||||
@@ -49,17 +41,6 @@ def build_validation_input(
|
|||||||
for s in session.exec(stmt)
|
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(
|
return ValidationInput(
|
||||||
candidate_shift=ShiftInfo(
|
candidate_shift=ShiftInfo(
|
||||||
id=str(shift.id),
|
id=str(shift.id),
|
||||||
@@ -67,9 +48,4 @@ def build_validation_input(
|
|||||||
ends_at=shift.ends_at,
|
ends_at=shift.ends_at,
|
||||||
),
|
),
|
||||||
existing_shifts=existing_shifts,
|
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):
|
class ViolationCode(StrEnum):
|
||||||
SHIFT_OVERLAP = "SHIFT_OVERLAP"
|
SHIFT_OVERLAP = "SHIFT_OVERLAP"
|
||||||
OUTSIDE_AVAILABILITY = "OUTSIDE_AVAILABILITY"
|
|
||||||
DAILY_HOURS_EXCEEDED = "DAILY_HOURS_EXCEEDED"
|
|
||||||
INSUFFICIENT_REST = "INSUFFICIENT_REST"
|
|
||||||
|
|
||||||
|
|
||||||
SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
|
SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
|
||||||
ViolationCode.SHIFT_OVERLAP: Severity.BLOCKING,
|
# Avertissement seulement : un bénévole peut tenir deux postes à la fois
|
||||||
ViolationCode.OUTSIDE_AVAILABILITY: Severity.WARNING,
|
# (le solveur l'évite par un malus, cf. `app.solver.engine`).
|
||||||
ViolationCode.DAILY_HOURS_EXCEEDED: Severity.WARNING,
|
ViolationCode.SHIFT_OVERLAP: Severity.WARNING,
|
||||||
ViolationCode.INSUFFICIENT_REST: Severity.WARNING,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -53,8 +49,8 @@ class ShiftInfo:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DayPartWindow:
|
class DayPartWindow:
|
||||||
"""Créneau type. Ne sert plus qu'à fixer la borne de « journée » pour le
|
"""Créneau type. Ne sert plus qu'à fixer la borne de « journée de
|
||||||
cumul des 8 h (début du premier créneau type)."""
|
festival » (`engine.day_boundary`)."""
|
||||||
|
|
||||||
code: str
|
code: str
|
||||||
start_time: dt.time
|
start_time: dt.time
|
||||||
@@ -63,18 +59,6 @@ class DayPartWindow:
|
|||||||
order: int
|
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)
|
@dataclass(frozen=True)
|
||||||
class ValidationInput:
|
class ValidationInput:
|
||||||
"""Tout ce qu'il faut pour valider une affectation candidate.
|
"""Tout ce qu'il faut pour valider une affectation candidate.
|
||||||
@@ -85,5 +69,3 @@ class ValidationInput:
|
|||||||
|
|
||||||
candidate_shift: ShiftInfo
|
candidate_shift: ShiftInfo
|
||||||
existing_shifts: tuple[ShiftInfo, ...] = ()
|
existing_shifts: tuple[ShiftInfo, ...] = ()
|
||||||
availabilities: tuple[AvailabilityWindow, ...] = ()
|
|
||||||
day_parts: tuple[DayPartWindow, ...] = ()
|
|
||||||
|
|||||||
@@ -12,16 +12,23 @@ dependencies = [
|
|||||||
"pydantic-settings>=2.6",
|
"pydantic-settings>=2.6",
|
||||||
"python-multipart>=0.0.12",
|
"python-multipart>=0.0.12",
|
||||||
"openpyxl>=3.1",
|
"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",
|
"bcrypt>=4.1",
|
||||||
"ortools>=9.11",
|
"ortools>=9.11",
|
||||||
|
"reportlab>=4.2",
|
||||||
|
"pypdf>=5.1",
|
||||||
|
"pillow>=11.0",
|
||||||
|
"httpx>=0.27",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.3",
|
"pytest>=8.3",
|
||||||
"pytest-cov>=6.0",
|
"pytest-cov>=6.0",
|
||||||
"httpx>=0.27",
|
|
||||||
"ruff>=0.8",
|
"ruff>=0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -7,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 import models # noqa: F401 (enregistre les tables sur SQLModel.metadata)
|
||||||
from app.auth.deps import get_current_account
|
from app.auth.deps import get_current_account
|
||||||
|
from app.auth.rate_limit import (
|
||||||
|
form_submission_limiter,
|
||||||
|
login_limiter,
|
||||||
|
magic_link_limiter,
|
||||||
|
pending_status_limiter,
|
||||||
|
)
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.main import app
|
from app.main import app
|
||||||
@@ -23,6 +29,18 @@ def _never_use_real_smtp(monkeypatch):
|
|||||||
get_settings.cache_clear()
|
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")
|
@pytest.fixture(scope="session")
|
||||||
def engine():
|
def engine():
|
||||||
"""Vraie base PostgreSQL de test (voir docker/postgres-init)."""
|
"""Vraie base PostgreSQL de test (voir docker/postgres-init)."""
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import datetime as dt
|
|||||||
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Artist,
|
Artist,
|
||||||
Availability,
|
|
||||||
Position,
|
Position,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Shift,
|
Shift,
|
||||||
@@ -74,34 +73,5 @@ def make_volunteer(
|
|||||||
return 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:
|
def dt_at(day: int, hour: int, minute: int = 0) -> dt.datetime:
|
||||||
return dt.datetime(2026, 7, day, hour, minute)
|
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
|
import datetime as dt
|
||||||
|
|
||||||
from tests.factories import (
|
from tests.factories import (
|
||||||
declare_available_all_day,
|
|
||||||
dt_at,
|
dt_at,
|
||||||
make_position,
|
make_position,
|
||||||
make_shift,
|
make_shift,
|
||||||
@@ -13,16 +12,12 @@ def _url(edition, suffix=""):
|
|||||||
return f"/editions/{edition.id}/assignments{suffix}"
|
return f"/editions/{edition.id}/assignments{suffix}"
|
||||||
|
|
||||||
|
|
||||||
def _setup(session, edition, *, available=True):
|
def _setup(session, edition):
|
||||||
position = make_position(session, edition)
|
position = make_position(session, edition)
|
||||||
shift = make_shift(
|
shift = make_shift(
|
||||||
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18)
|
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18)
|
||||||
)
|
)
|
||||||
volunteer = make_volunteer(session, edition, email="v@ex.fr")
|
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
|
return volunteer, shift
|
||||||
|
|
||||||
|
|
||||||
@@ -39,19 +34,28 @@ def test_create_assignment_clean(client, edition, session, day_parts):
|
|||||||
assert body["assignment"]["locked"] is False
|
assert body["assignment"]["locked"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_create_assignment_returns_warnings(client, edition, session, day_parts):
|
def test_long_day_without_rest_raises_no_warning(client, edition, session, day_parts):
|
||||||
# Bénévole non déclaré disponible sur le créneau.
|
# Plus de limite d'heures par jour ni de repos minimal : 14h–18h puis
|
||||||
volunteer, shift = _setup(session, edition, available=False)
|
# 18h–04h (14 h d'affilée) passe sans avertissement.
|
||||||
|
volunteer, shift = _setup(session, edition)
|
||||||
|
position = make_position(session, edition, name="Bar")
|
||||||
|
following = make_shift(
|
||||||
|
session, edition, position, start=dt_at(10, 18), end=dt_at(11, 4)
|
||||||
|
)
|
||||||
|
for s in (shift, following):
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
_url(edition),
|
_url(edition),
|
||||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
json={"volunteer_id": str(volunteer.id), "shift_id": str(s.id)},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
codes = {w["code"] for w in resp.json()["warnings"]}
|
assert resp.json()["warnings"] == []
|
||||||
assert "OUTSIDE_AVAILABILITY" in codes
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
volunteer, shift = _setup(session, edition)
|
||||||
assert client.post(
|
assert client.post(
|
||||||
_url(edition),
|
_url(edition),
|
||||||
@@ -69,15 +73,14 @@ def test_create_assignment_blocked_by_overlap(client, edition, session, day_part
|
|||||||
"shift_id": str(overlapping.id),
|
"shift_id": str(overlapping.id),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 201
|
||||||
detail = resp.json()["detail"]
|
warnings = resp.json()["warnings"]
|
||||||
assert detail["code"] == "error.validation_failed"
|
assert [w["code"] for w in warnings] == ["SHIFT_OVERLAP"]
|
||||||
assert any(v["code"] == "SHIFT_OVERLAP" for v in detail["violations"])
|
assert warnings[0]["severity"] == "WARNING"
|
||||||
# Rien n'a été enregistré pour ce créneau.
|
|
||||||
listed = client.get(
|
listed = client.get(
|
||||||
_url(edition), params={"shift_id": str(overlapping.id)}
|
_url(edition), params={"shift_id": str(overlapping.id)}
|
||||||
).json()
|
).json()
|
||||||
assert listed == []
|
assert len(listed) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_create_duplicate_assignment_conflicts(client, edition, session, day_parts):
|
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):
|
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(
|
resp = client.post(
|
||||||
_url(edition, "/validate"),
|
_url(edition, "/validate"),
|
||||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
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
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["blocking"] is False
|
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() == []
|
assert client.get(_url(edition)).json() == []
|
||||||
|
|
||||||
|
|
||||||
def test_patch_locked_keeps_assignment_and_returns_warnings(
|
def test_patch_locked_keeps_assignment(client, edition, session, day_parts):
|
||||||
client, edition, session, day_parts
|
volunteer, shift = _setup(session, edition)
|
||||||
):
|
|
||||||
volunteer, shift = _setup(session, edition, available=False)
|
|
||||||
aid = client.post(
|
aid = client.post(
|
||||||
_url(edition),
|
_url(edition),
|
||||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
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})
|
resp = client.patch(_url(edition, f"/{aid}"), json={"locked": True})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["assignment"]["locked"] is True
|
assert resp.json()["assignment"]["locked"] is True
|
||||||
assert any(
|
assert resp.json()["warnings"] == []
|
||||||
w["code"] == "OUTSIDE_AVAILABILITY" for w in resp.json()["warnings"]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_assignment(client, edition, session, day_parts):
|
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 app.models import Assignment, MagicLinkToken, Role
|
||||||
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
||||||
|
from tests.mail import plain_text
|
||||||
|
|
||||||
|
|
||||||
def _url(edition):
|
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}
|
by_to = {m["To"]: m for m in outbox}
|
||||||
assert by_to["ana@e.fr"]["Subject"] == "Planning de Ana"
|
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 "Tu as 2 créneau(x), 7.0 h." in ana_body
|
||||||
assert "Bar" 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):
|
def test_default_template_is_used_when_body_omitted(client, edition, session, outbox):
|
||||||
make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
|
||||||
resp = client.post(_url(edition), json={})
|
resp = client.post(_url(edition), json={})
|
||||||
assert resp.status_code == 200
|
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(
|
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}"}
|
_url(edition), json={"body": "Connecte-toi : {lien_connexion}"}
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "/connexion?token=" in outbox[0].get_content()
|
assert "/connexion?token=pl." in plain_text(outbox[0])
|
||||||
assert len(session.exec(select(MagicLinkToken)).all()) == 1
|
# Lien permanent : rien de stocké, pas de jeton à usage unique.
|
||||||
|
assert session.exec(select(MagicLinkToken)).all() == []
|
||||||
|
|
||||||
|
|
||||||
def test_no_magic_link_token_without_placeholder(
|
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")
|
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||||
resp = as_account(lead).post(_url(edition), json={})
|
resp = as_account(lead).post(_url(edition), json={})
|
||||||
assert resp.status_code == 403
|
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
|
import uuid
|
||||||
|
|
||||||
|
from app.models import Role
|
||||||
|
|
||||||
|
|
||||||
def _url(edition, suffix=""):
|
def _url(edition, suffix=""):
|
||||||
return f"/editions/{edition.id}/positions{suffix}"
|
return f"/editions/{edition.id}/positions{suffix}"
|
||||||
@@ -35,3 +37,75 @@ def test_unknown_edition_is_404(client):
|
|||||||
assert (
|
assert (
|
||||||
client.get(f"/editions/{uuid.uuid4()}/positions").status_code == 404
|
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"]
|
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):
|
def test_chain_penalty_applies_across_different_positions(client, edition, session):
|
||||||
from app.models import Assignment, PositionCategory
|
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 datetime as dt
|
||||||
|
import io
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
from pypdf import PdfReader
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
from app.models import Assignment, Availability, Volunteer
|
from app.models import Assignment, FeePaymentStatus, Role, Volunteer
|
||||||
from tests.factories import (
|
from tests.factories import (
|
||||||
dt_at,
|
dt_at,
|
||||||
make_artist,
|
make_artist,
|
||||||
make_availability,
|
|
||||||
make_position,
|
make_position,
|
||||||
make_shift,
|
make_shift,
|
||||||
make_volunteer,
|
make_volunteer,
|
||||||
@@ -205,14 +207,6 @@ def test_delete_is_a_full_cascade(client, edition, session):
|
|||||||
shift = make_shift(
|
shift = make_shift(
|
||||||
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
|
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(
|
session.add(
|
||||||
Assignment(
|
Assignment(
|
||||||
edition_id=edition.id,
|
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 client.get(_url(edition, f"/{volunteer.id}")).status_code == 404
|
||||||
assert session.exec(select(Volunteer)).all() == []
|
assert session.exec(select(Volunteer)).all() == []
|
||||||
assert session.exec(select(Assignment)).all() == []
|
assert session.exec(select(Assignment)).all() == []
|
||||||
assert session.exec(select(Availability)).all() == []
|
|
||||||
# Le créneau subsiste.
|
# Le créneau subsiste.
|
||||||
from app.models import Shift
|
from app.models import Shift
|
||||||
|
|
||||||
assert session.get(Shift, shift.id) is not None
|
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