Étape 2 : modèle de données complet et migration initiale
- Tables SQLModel : Edition, DayPart, Volunteer, Skill, Position, Shift, Availability, Assignment, Account, MagicLinkToken + tables d'association - Multi-édition (edition_id partout), énumérations en VARCHAR + CHECK, suppressions ON DELETE CASCADE (droit à l'effacement) - Dates/heures naïves en heure locale Europe/Paris (tzdata + TZ conteneur) - Migration Alembic 0001 écrite à la main, vérifiée par `alembic check` (aucun écart avec les modèles) et par un aller-retour downgrade/upgrade - Tests : graphe complet, unicité email par édition, N-N compétences, aller-retour d'énumération, cascade de suppression d'un bénévole Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
09d7d70076
commit
cb2e84adf0
@@ -51,25 +51,49 @@ Deux tables distinctes, deux écrans distincts, deux vocabulaires distincts.
|
|||||||
|
|
||||||
## 2. Modèle de domaine
|
## 2. Modèle de domaine
|
||||||
|
|
||||||
- **`Edition`** : année, `start_date`, `end_date`, `retention_days`
|
Défini dans `backend/app/models/` (`enums.py`, `base.py`, `domain.py`).
|
||||||
(durée de rétention des données nominatives, paramétrable), liste de
|
Clés primaires `UUID`. Champs d'audit `created_at` / `updated_at` sur toutes
|
||||||
`DayPart`.
|
les tables sauf les tables d'association.
|
||||||
- **`DayPart`** : découpage d'une journée propre à l'édition (matin,
|
|
||||||
après-midi, soirée… nombre et bornes libres). Une `Availability` référence
|
|
||||||
un `DayPart` + une date.
|
|
||||||
- **`Volunteer`** : `email` (identifiant fonctionnel unique **par édition**,
|
|
||||||
nullable pour les bénévoles créés à la main), `first_name`, `last_name`,
|
|
||||||
`phone`, `tshirt_size`, `diet`, `comment`. Pas de date de naissance.
|
|
||||||
- **`Skill`** : référentiel par édition (SST, permis B, régie son, langues…).
|
|
||||||
N-N avec `Volunteer`. N-N avec `Shift` (compétences requises).
|
|
||||||
- **`Position`** : `name`, `location`, `category`, `lead_account_id`.
|
|
||||||
- **`Shift`** : `position_id`, `start_at`, `end_at` (peut finir le lendemain),
|
|
||||||
`min_staff`, `ideal_staff`, compétences requises (N-N `Skill`).
|
|
||||||
- **`Assignment`** : `volunteer_id`, `shift_id`, `status`, `source`, `locked`.
|
|
||||||
- **`Account`** : `email`, `role`, `edition_id`, `volunteer_id` (nullable).
|
|
||||||
- **`MagicLinkToken`** : `account_id`, `token_hash`, `expires_at`, `used_at`.
|
|
||||||
|
|
||||||
Multi-édition : toute entité métier porte un `edition_id`.
|
- **`Edition`** : `year`, `name` (opt.), `start_date`, `end_date`,
|
||||||
|
`retention_days` (durée de rétention des données nominatives en jours,
|
||||||
|
`NULL` = pas de purge).
|
||||||
|
- **`DayPart`** : découpage d'une journée propre à l'édition (nombre et bornes
|
||||||
|
libres, paramétrable par la coordination). `code`, `label`, `start_time`,
|
||||||
|
`end_time` (`end_time < start_time` ⇒ passe minuit), `order`. Unique
|
||||||
|
(`edition_id`, `code`).
|
||||||
|
- **`Volunteer`** : `email` (unique **par édition**, nullable pour les
|
||||||
|
bénévoles créés à la main — plusieurs `NULL` autorisés), `first_name`,
|
||||||
|
`last_name`, `phone`, `tshirt_size`, `diet`, `comment`. Pas de date de
|
||||||
|
naissance.
|
||||||
|
- **`Skill`** : référentiel par édition (`code`, `label`). N-N `Volunteer`
|
||||||
|
(`volunteer_skill`). N-N `Shift` (`shift_required_skill`).
|
||||||
|
- **`Position`** : `name`, `location`, `category` (`PositionCategory`),
|
||||||
|
`lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte).
|
||||||
|
- **`Shift`** : `position_id`, `starts_at`, `ends_at` (peut finir le
|
||||||
|
lendemain), `min_staff`, `ideal_staff`, compétences requises (N-N `Skill`).
|
||||||
|
- **`Availability`** : `volunteer_id`, `date`, `day_part_id`, `status`
|
||||||
|
(`AvailabilityStatus`). Unique (`volunteer_id`, `date`, `day_part_id`).
|
||||||
|
- **`Assignment`** : `volunteer_id`, `shift_id`, `status`
|
||||||
|
(`AssignmentStatus`, défaut `PROPOSED`), `source` (`AssignmentSource`,
|
||||||
|
défaut `MANUAL`), `locked` (défaut `false`). Unique (`volunteer_id`,
|
||||||
|
`shift_id`).
|
||||||
|
- **`Account`** : `email` (unique par édition), `role` (`Role`), `edition_id`,
|
||||||
|
`volunteer_id` (nullable, unique).
|
||||||
|
- **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`,
|
||||||
|
`used_at`.
|
||||||
|
|
||||||
|
Conventions techniques :
|
||||||
|
|
||||||
|
- Multi-édition : toute entité métier porte un `edition_id`.
|
||||||
|
- Énumérations stockées en `VARCHAR` + `CHECK` (`native_enum=False`), pas de
|
||||||
|
type ENUM natif PostgreSQL.
|
||||||
|
- Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les
|
||||||
|
`edition_id` et sur les liens vers `volunteer`, `shift`, `position`,
|
||||||
|
`skill`, `account`. La suppression complète d'un bénévole (RGPD) efface donc
|
||||||
|
ses `Availability`, `Assignment`, liens de compétence et son `Account`.
|
||||||
|
- Dates/heures **naïves**, heure locale Europe/Paris (`TZ` du conteneur +
|
||||||
|
`tzdata`). `app.models.local_now()` pour les horodatages.
|
||||||
|
|
||||||
### Rôles et accès
|
### Rôles et accès
|
||||||
|
|
||||||
@@ -192,6 +216,7 @@ Aucune dépendance locale requise hors Docker.
|
|||||||
| Format | `docker compose run --rm backend uv run ruff format .` |
|
| Format | `docker compose run --rm backend uv run ruff format .` |
|
||||||
| Créer une migration | `docker compose run --rm backend uv run alembic revision --autogenerate -m "..."` |
|
| Créer une migration | `docker compose run --rm backend uv run alembic revision --autogenerate -m "..."` |
|
||||||
| Appliquer les migrations | `docker compose run --rm backend uv run alembic upgrade head` |
|
| Appliquer les migrations | `docker compose run --rm backend uv run alembic upgrade head` |
|
||||||
|
| Vérifier migration ↔ modèle | `docker compose run --rm backend uv run alembic check` (après `upgrade head`) |
|
||||||
| Charger le jeu de démo | *(étape ultérieure)* `docker compose run --rm backend uv run python -m app.seed` |
|
| Charger le jeu de démo | *(étape ultérieure)* `docker compose run --rm backend uv run python -m app.seed` |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -234,4 +259,5 @@ Aucune dépendance locale requise hors Docker.
|
|||||||
|
|
||||||
| Étape | Description | État |
|
| Étape | Description | État |
|
||||||
|-------|-------------------------------------------------------|----------|
|
|-------|-------------------------------------------------------|----------|
|
||||||
| 1 | Squelette : monorepo, Docker, CI/CD, backend qui démarre | à valider |
|
| 1 | Squelette : monorepo, Docker, CI/CD, backend qui démarre | validée |
|
||||||
|
| 2 | Modèle de données complet + migration Alembic initiale | à valider |
|
||||||
|
|||||||
+8
-1
@@ -2,7 +2,14 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /bin/uv
|
COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /bin/uv
|
||||||
|
|
||||||
ENV UV_LINK_MODE=copy \
|
# Fuseau horaire : toutes les dates/heures du domaine sont naïves et
|
||||||
|
# interprétées en heure locale Europe/Paris (règles « 22h », « journée »…).
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV TZ=Europe/Paris \
|
||||||
|
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"
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"""Schéma initial du domaine
|
||||||
|
|
||||||
|
Revision ID: 0001_initial
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-09-09
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0001_initial"
|
||||||
|
down_revision: str | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
ROLE = sa.Enum("COORDINATOR", "LEAD", "VOLUNTEER", name="role", native_enum=False)
|
||||||
|
AVAILABILITY_STATUS = sa.Enum(
|
||||||
|
"AVAILABLE", "IF_NEEDED", "UNAVAILABLE",
|
||||||
|
name="availabilitystatus", native_enum=False,
|
||||||
|
)
|
||||||
|
ASSIGNMENT_STATUS = sa.Enum(
|
||||||
|
"PROPOSED", "CONFIRMED", "DECLINED", "CANCELLED",
|
||||||
|
name="assignmentstatus", native_enum=False,
|
||||||
|
)
|
||||||
|
ASSIGNMENT_SOURCE = sa.Enum(
|
||||||
|
"SOLVER", "MANUAL", name="assignmentsource", native_enum=False
|
||||||
|
)
|
||||||
|
POSITION_CATEGORY = sa.Enum(
|
||||||
|
"BAR", "TICKETING", "WELCOME", "PARKING", "CLEANING", "CATERING",
|
||||||
|
"SETUP", "STAGE", "OTHER",
|
||||||
|
name="positioncategory", native_enum=False,
|
||||||
|
)
|
||||||
|
TSHIRT_SIZE = sa.Enum(
|
||||||
|
"XS", "S", "M", "L", "XL", "XXL", "XXXL",
|
||||||
|
name="tshirtsize", native_enum=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamps() -> tuple[sa.Column, sa.Column]:
|
||||||
|
return (
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"edition",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("year", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(), nullable=True),
|
||||||
|
sa.Column("start_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("end_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("retention_days", sa.Integer(), nullable=True),
|
||||||
|
*_timestamps(),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_edition_year", "edition", ["year"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"day_part",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("code", sa.String(), nullable=False),
|
||||||
|
sa.Column("label", sa.String(), nullable=False),
|
||||||
|
sa.Column("start_time", sa.Time(), nullable=False),
|
||||||
|
sa.Column("end_time", sa.Time(), nullable=False),
|
||||||
|
sa.Column("order", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
*_timestamps(),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["edition_id"], ["edition.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"edition_id", "code", name="uq_day_part_edition_code"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_day_part_edition_id", "day_part", ["edition_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"skill",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("code", sa.String(), nullable=False),
|
||||||
|
sa.Column("label", sa.String(), nullable=False),
|
||||||
|
*_timestamps(),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["edition_id"], ["edition.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("edition_id", "code", name="uq_skill_edition_code"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_skill_edition_id", "skill", ["edition_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"volunteer",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("email", sa.String(), nullable=True),
|
||||||
|
sa.Column("first_name", sa.String(), nullable=False),
|
||||||
|
sa.Column("last_name", sa.String(), nullable=False),
|
||||||
|
sa.Column("phone", sa.String(), nullable=True),
|
||||||
|
sa.Column("tshirt_size", TSHIRT_SIZE, nullable=True),
|
||||||
|
sa.Column("diet", sa.String(), nullable=True),
|
||||||
|
sa.Column("comment", sa.String(), nullable=True),
|
||||||
|
*_timestamps(),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["edition_id"], ["edition.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"edition_id", "email", name="uq_volunteer_edition_email"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_volunteer_edition_id", "volunteer", ["edition_id"])
|
||||||
|
op.create_index("ix_volunteer_email", "volunteer", ["email"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"volunteer_skill",
|
||||||
|
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("skill_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["skill_id"], ["skill.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("volunteer_id", "skill_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"account",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("email", sa.String(), nullable=False),
|
||||||
|
sa.Column("role", ROLE, nullable=False),
|
||||||
|
sa.Column("volunteer_id", sa.Uuid(), nullable=True),
|
||||||
|
*_timestamps(),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["edition_id"], ["edition.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"edition_id", "email", name="uq_account_edition_email"
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("volunteer_id", name="uq_account_volunteer"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_account_edition_id", "account", ["edition_id"])
|
||||||
|
op.create_index("ix_account_email", "account", ["email"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"magic_link_token",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("account_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("token_hash", sa.String(), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("used_at", sa.DateTime(), nullable=True),
|
||||||
|
*_timestamps(),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["account_id"], ["account.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_magic_link_token_account_id", "magic_link_token", ["account_id"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_magic_link_token_token_hash",
|
||||||
|
"magic_link_token",
|
||||||
|
["token_hash"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"position",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(), nullable=False),
|
||||||
|
sa.Column("location", sa.String(), nullable=True),
|
||||||
|
sa.Column("category", POSITION_CATEGORY, nullable=False),
|
||||||
|
sa.Column("lead_account_id", sa.Uuid(), nullable=True),
|
||||||
|
*_timestamps(),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["edition_id"], ["edition.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["lead_account_id"], ["account.id"], ondelete="SET NULL"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_position_edition_id", "position", ["edition_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"shift",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("position_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("starts_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("ends_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("min_staff", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"ideal_staff", sa.Integer(), server_default="0", nullable=False
|
||||||
|
),
|
||||||
|
*_timestamps(),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["edition_id"], ["edition.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["position_id"], ["position.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_shift_edition_id", "shift", ["edition_id"])
|
||||||
|
op.create_index("ix_shift_position_id", "shift", ["position_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"shift_required_skill",
|
||||||
|
sa.Column("shift_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("skill_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["shift_id"], ["shift.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["skill_id"], ["skill.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("shift_id", "skill_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"availability",
|
||||||
|
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("day_part_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("status", AVAILABILITY_STATUS, nullable=False),
|
||||||
|
*_timestamps(),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["edition_id"], ["edition.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["day_part_id"], ["day_part.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"volunteer_id", "date", "day_part_id", name="uq_availability_slot"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_availability_edition_id", "availability", ["edition_id"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_availability_volunteer_id", "availability", ["volunteer_id"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_availability_day_part_id", "availability", ["day_part_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"assignment",
|
||||||
|
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("shift_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"status", ASSIGNMENT_STATUS, server_default="PROPOSED",
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source", ASSIGNMENT_SOURCE, server_default="MANUAL",
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"locked", sa.Boolean(), server_default="false", nullable=False
|
||||||
|
),
|
||||||
|
*_timestamps(),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["edition_id"], ["edition.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["shift_id"], ["shift.id"], ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"volunteer_id", "shift_id", name="uq_assignment_volunteer_shift"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_assignment_edition_id", "assignment", ["edition_id"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_assignment_volunteer_id", "assignment", ["volunteer_id"]
|
||||||
|
)
|
||||||
|
op.create_index("ix_assignment_shift_id", "assignment", ["shift_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("assignment")
|
||||||
|
op.drop_table("availability")
|
||||||
|
op.drop_table("shift_required_skill")
|
||||||
|
op.drop_table("shift")
|
||||||
|
op.drop_table("position")
|
||||||
|
op.drop_table("magic_link_token")
|
||||||
|
op.drop_table("account")
|
||||||
|
op.drop_table("volunteer_skill")
|
||||||
|
op.drop_table("volunteer")
|
||||||
|
op.drop_table("skill")
|
||||||
|
op.drop_table("day_part")
|
||||||
|
op.drop_table("edition")
|
||||||
@@ -1,6 +1,54 @@
|
|||||||
"""Tables SQLModel du domaine.
|
"""Tables SQLModel du domaine et énumérations associées.
|
||||||
|
|
||||||
Vide à ce stade : le modèle de données est ajouté à l'étape suivante. Ce module
|
Importé par Alembic (`app/migrations/env.py`) et par les tests pour enregistrer
|
||||||
est importé par Alembic (`app/migrations/env.py`) et par les tests pour
|
les métadonnées sur `SQLModel.metadata`.
|
||||||
enregistrer les métadonnées.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from app.models.base import TimestampMixin, local_now
|
||||||
|
from app.models.domain import (
|
||||||
|
Account,
|
||||||
|
Assignment,
|
||||||
|
Availability,
|
||||||
|
DayPart,
|
||||||
|
Edition,
|
||||||
|
MagicLinkToken,
|
||||||
|
Position,
|
||||||
|
Shift,
|
||||||
|
ShiftRequiredSkill,
|
||||||
|
Skill,
|
||||||
|
Volunteer,
|
||||||
|
VolunteerSkill,
|
||||||
|
)
|
||||||
|
from app.models.enums import (
|
||||||
|
ACTIVE_ASSIGNMENT_STATUSES,
|
||||||
|
AssignmentSource,
|
||||||
|
AssignmentStatus,
|
||||||
|
AvailabilityStatus,
|
||||||
|
PositionCategory,
|
||||||
|
Role,
|
||||||
|
TshirtSize,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ACTIVE_ASSIGNMENT_STATUSES",
|
||||||
|
"Account",
|
||||||
|
"Assignment",
|
||||||
|
"AssignmentSource",
|
||||||
|
"AssignmentStatus",
|
||||||
|
"Availability",
|
||||||
|
"AvailabilityStatus",
|
||||||
|
"DayPart",
|
||||||
|
"Edition",
|
||||||
|
"MagicLinkToken",
|
||||||
|
"Position",
|
||||||
|
"PositionCategory",
|
||||||
|
"Role",
|
||||||
|
"Shift",
|
||||||
|
"ShiftRequiredSkill",
|
||||||
|
"Skill",
|
||||||
|
"TimestampMixin",
|
||||||
|
"TshirtSize",
|
||||||
|
"Volunteer",
|
||||||
|
"VolunteerSkill",
|
||||||
|
"local_now",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
def local_now() -> dt.datetime:
|
||||||
|
"""Horodatage naïf en heure locale (Europe/Paris via le TZ du conteneur).
|
||||||
|
|
||||||
|
Toutes les dates/heures du domaine sont naïves et interprétées en heure
|
||||||
|
locale : créneaux, disponibilités, et champs d'audit.
|
||||||
|
"""
|
||||||
|
return dt.datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin(SQLModel):
|
||||||
|
"""Champs d'audit communs. Hérité par les tables, pas une table.
|
||||||
|
|
||||||
|
Pas de `sa_column=` ici : une instance `Column` ne peut pas être partagée
|
||||||
|
entre plusieurs tables. On laisse SQLModel générer une colonne par table
|
||||||
|
et on passe seulement des `sa_column_kwargs`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
created_at: dt.datetime = Field(default_factory=local_now, nullable=False)
|
||||||
|
updated_at: dt.datetime = Field(
|
||||||
|
default_factory=local_now,
|
||||||
|
nullable=False,
|
||||||
|
sa_column_kwargs={"onupdate": local_now},
|
||||||
|
)
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
"""Tables SQLModel du domaine.
|
||||||
|
|
||||||
|
Vocabulaire : voir CLAUDE.md § 1. Toute entité métier porte un `edition_id`
|
||||||
|
(multi-édition dans une seule base). Les suppressions en cascade sont assurées
|
||||||
|
au niveau base (`ondelete="CASCADE"`) ; les relations ORM utilisent
|
||||||
|
`passive_deletes=True` pour laisser la base faire le travail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Column, Integer
|
||||||
|
from sqlalchemy import Enum as SAEnum
|
||||||
|
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
|
||||||
|
|
||||||
|
from app.models.base import TimestampMixin
|
||||||
|
from app.models.enums import (
|
||||||
|
AssignmentSource,
|
||||||
|
AssignmentStatus,
|
||||||
|
AvailabilityStatus,
|
||||||
|
PositionCategory,
|
||||||
|
Role,
|
||||||
|
TshirtSize,
|
||||||
|
)
|
||||||
|
|
||||||
|
_PASSIVE = {"passive_deletes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Tables d'association (sans champs d'audit) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class VolunteerSkill(SQLModel, table=True):
|
||||||
|
__tablename__ = "volunteer_skill"
|
||||||
|
|
||||||
|
volunteer_id: uuid.UUID = Field(
|
||||||
|
foreign_key="volunteer.id", primary_key=True, ondelete="CASCADE"
|
||||||
|
)
|
||||||
|
skill_id: uuid.UUID = Field(
|
||||||
|
foreign_key="skill.id", primary_key=True, ondelete="CASCADE"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftRequiredSkill(SQLModel, table=True):
|
||||||
|
__tablename__ = "shift_required_skill"
|
||||||
|
|
||||||
|
shift_id: uuid.UUID = Field(
|
||||||
|
foreign_key="shift.id", primary_key=True, ondelete="CASCADE"
|
||||||
|
)
|
||||||
|
skill_id: uuid.UUID = Field(
|
||||||
|
foreign_key="skill.id", primary_key=True, ondelete="CASCADE"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Édition et découpage des journées #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class Edition(TimestampMixin, table=True):
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
year: int = Field(index=True)
|
||||||
|
name: str | None = None
|
||||||
|
start_date: dt.date
|
||||||
|
end_date: dt.date
|
||||||
|
# Durée de rétention des données nominatives, en jours. None = pas de purge.
|
||||||
|
retention_days: int | None = None
|
||||||
|
|
||||||
|
day_parts: list["DayPart"] = Relationship(
|
||||||
|
back_populates="edition", sa_relationship_kwargs=_PASSIVE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DayPart(TimestampMixin, table=True):
|
||||||
|
__tablename__ = "day_part"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("edition_id", "code", name="uq_day_part_edition_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
edition_id: uuid.UUID = Field(
|
||||||
|
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||||
|
)
|
||||||
|
code: str
|
||||||
|
label: str
|
||||||
|
start_time: dt.time
|
||||||
|
# end_time < start_time signifie que la demi-journée passe minuit
|
||||||
|
# (ex. soirée 19:00 -> 06:00).
|
||||||
|
end_time: dt.time
|
||||||
|
order: int = Field(
|
||||||
|
default=0,
|
||||||
|
sa_column=Column(Integer, nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
edition: Edition = Relationship(back_populates="day_parts")
|
||||||
|
availabilities: list["Availability"] = Relationship(
|
||||||
|
back_populates="day_part", sa_relationship_kwargs=_PASSIVE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Personnes : bénévoles, compétences, comptes #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class Skill(TimestampMixin, table=True):
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("edition_id", "code", name="uq_skill_edition_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
edition_id: uuid.UUID = Field(
|
||||||
|
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||||
|
)
|
||||||
|
code: str
|
||||||
|
label: str
|
||||||
|
|
||||||
|
volunteers: list["Volunteer"] = Relationship(
|
||||||
|
back_populates="skills",
|
||||||
|
link_model=VolunteerSkill,
|
||||||
|
sa_relationship_kwargs=_PASSIVE,
|
||||||
|
)
|
||||||
|
shifts_requiring: list["Shift"] = Relationship(
|
||||||
|
back_populates="required_skills",
|
||||||
|
link_model=ShiftRequiredSkill,
|
||||||
|
sa_relationship_kwargs=_PASSIVE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Volunteer(TimestampMixin, table=True):
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"edition_id", "email", name="uq_volunteer_edition_email"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
edition_id: uuid.UUID = Field(
|
||||||
|
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||||
|
)
|
||||||
|
# Identifiant fonctionnel unique par édition. Nullable : bénévole créé à la
|
||||||
|
# main par la coordination, sans email.
|
||||||
|
email: str | None = Field(default=None, index=True)
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
phone: str | None = None
|
||||||
|
tshirt_size: TshirtSize | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(SAEnum(TshirtSize, native_enum=False), nullable=True),
|
||||||
|
)
|
||||||
|
diet: str | None = None
|
||||||
|
comment: str | None = None
|
||||||
|
|
||||||
|
skills: list[Skill] = Relationship(
|
||||||
|
back_populates="volunteers",
|
||||||
|
link_model=VolunteerSkill,
|
||||||
|
sa_relationship_kwargs=_PASSIVE,
|
||||||
|
)
|
||||||
|
availabilities: list["Availability"] = Relationship(
|
||||||
|
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
|
||||||
|
)
|
||||||
|
assignments: list["Assignment"] = Relationship(
|
||||||
|
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Account(TimestampMixin, table=True):
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("edition_id", "email", name="uq_account_edition_email"),
|
||||||
|
UniqueConstraint("volunteer_id", name="uq_account_volunteer"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
edition_id: uuid.UUID = Field(
|
||||||
|
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||||
|
)
|
||||||
|
email: str = Field(index=True)
|
||||||
|
role: Role = Field(
|
||||||
|
sa_column=Column(SAEnum(Role, native_enum=False), nullable=False)
|
||||||
|
)
|
||||||
|
# Lien optionnel vers le bénévole. Supprimé avec lui (droit à l'effacement).
|
||||||
|
volunteer_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
foreign_key="volunteer.id",
|
||||||
|
ondelete="CASCADE",
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MagicLinkToken(TimestampMixin, table=True):
|
||||||
|
__tablename__ = "magic_link_token"
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
account_id: uuid.UUID = Field(
|
||||||
|
foreign_key="account.id", index=True, ondelete="CASCADE"
|
||||||
|
)
|
||||||
|
token_hash: str = Field(unique=True, index=True)
|
||||||
|
expires_at: dt.datetime
|
||||||
|
used_at: dt.datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Planning : postes, créneaux, disponibilités, affectations #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class Position(TimestampMixin, table=True):
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
edition_id: uuid.UUID = Field(
|
||||||
|
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||||
|
)
|
||||||
|
name: str
|
||||||
|
location: str | None = None
|
||||||
|
category: PositionCategory = Field(
|
||||||
|
sa_column=Column(
|
||||||
|
SAEnum(PositionCategory, native_enum=False), nullable=False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lead_account_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
foreign_key="account.id",
|
||||||
|
ondelete="SET NULL",
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
shifts: list["Shift"] = Relationship(
|
||||||
|
back_populates="position", sa_relationship_kwargs=_PASSIVE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Shift(TimestampMixin, table=True):
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
edition_id: uuid.UUID = Field(
|
||||||
|
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||||
|
)
|
||||||
|
position_id: uuid.UUID = Field(
|
||||||
|
foreign_key="position.id", index=True, ondelete="CASCADE"
|
||||||
|
)
|
||||||
|
starts_at: dt.datetime
|
||||||
|
# Peut être le lendemain de starts_at (créneau à cheval sur minuit).
|
||||||
|
ends_at: dt.datetime
|
||||||
|
min_staff: int = Field(
|
||||||
|
default=0, sa_column=Column(Integer, nullable=False, server_default="0")
|
||||||
|
)
|
||||||
|
ideal_staff: int = Field(
|
||||||
|
default=0, sa_column=Column(Integer, nullable=False, server_default="0")
|
||||||
|
)
|
||||||
|
|
||||||
|
position: Position = Relationship(back_populates="shifts")
|
||||||
|
required_skills: list[Skill] = Relationship(
|
||||||
|
back_populates="shifts_requiring",
|
||||||
|
link_model=ShiftRequiredSkill,
|
||||||
|
sa_relationship_kwargs=_PASSIVE,
|
||||||
|
)
|
||||||
|
assignments: list["Assignment"] = Relationship(
|
||||||
|
back_populates="shift", sa_relationship_kwargs=_PASSIVE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Availability(TimestampMixin, table=True):
|
||||||
|
"""Déclaration du bénévole : « je peux venir tel jour, telle demi-journée ».
|
||||||
|
|
||||||
|
À ne pas confondre avec Assignment (décision de l'organisation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"volunteer_id", "date", "day_part_id", name="uq_availability_slot"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
day_part_id: uuid.UUID = Field(
|
||||||
|
foreign_key="day_part.id", index=True, ondelete="CASCADE"
|
||||||
|
)
|
||||||
|
status: AvailabilityStatus = Field(
|
||||||
|
sa_column=Column(
|
||||||
|
SAEnum(AvailabilityStatus, native_enum=False), nullable=False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
volunteer: Volunteer = Relationship(back_populates="availabilities")
|
||||||
|
day_part: DayPart = Relationship(back_populates="availabilities")
|
||||||
|
|
||||||
|
|
||||||
|
class Assignment(TimestampMixin, table=True):
|
||||||
|
"""Décision de l'organisation : « tu tiens tel créneau ».
|
||||||
|
|
||||||
|
À ne pas confondre avec Availability (déclaration du bénévole).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"volunteer_id", "shift_id", name="uq_assignment_volunteer_shift"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
shift_id: uuid.UUID = Field(
|
||||||
|
foreign_key="shift.id", index=True, ondelete="CASCADE"
|
||||||
|
)
|
||||||
|
status: AssignmentStatus = Field(
|
||||||
|
default=AssignmentStatus.PROPOSED,
|
||||||
|
sa_column=Column(
|
||||||
|
SAEnum(AssignmentStatus, native_enum=False),
|
||||||
|
nullable=False,
|
||||||
|
server_default=AssignmentStatus.PROPOSED.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
source: AssignmentSource = Field(
|
||||||
|
default=AssignmentSource.MANUAL,
|
||||||
|
sa_column=Column(
|
||||||
|
SAEnum(AssignmentSource, native_enum=False),
|
||||||
|
nullable=False,
|
||||||
|
server_default=AssignmentSource.MANUAL.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
locked: bool = Field(
|
||||||
|
default=False,
|
||||||
|
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
|
||||||
|
volunteer: Volunteer = Relationship(back_populates="assignments")
|
||||||
|
shift: Shift = Relationship(back_populates="assignments")
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Énumérations du domaine.
|
||||||
|
|
||||||
|
Stockées en base comme VARCHAR + contrainte CHECK (`native_enum=False`) : pas de
|
||||||
|
type ENUM natif PostgreSQL, donc pas de migration de type à gérer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class Role(enum.StrEnum):
|
||||||
|
COORDINATOR = "COORDINATOR"
|
||||||
|
LEAD = "LEAD"
|
||||||
|
VOLUNTEER = "VOLUNTEER"
|
||||||
|
|
||||||
|
|
||||||
|
class AvailabilityStatus(enum.StrEnum):
|
||||||
|
AVAILABLE = "AVAILABLE"
|
||||||
|
IF_NEEDED = "IF_NEEDED"
|
||||||
|
UNAVAILABLE = "UNAVAILABLE"
|
||||||
|
|
||||||
|
|
||||||
|
class AssignmentStatus(enum.StrEnum):
|
||||||
|
PROPOSED = "PROPOSED"
|
||||||
|
CONFIRMED = "CONFIRMED"
|
||||||
|
DECLINED = "DECLINED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
class AssignmentSource(enum.StrEnum):
|
||||||
|
SOLVER = "SOLVER"
|
||||||
|
MANUAL = "MANUAL"
|
||||||
|
|
||||||
|
|
||||||
|
class PositionCategory(enum.StrEnum):
|
||||||
|
BAR = "BAR"
|
||||||
|
TICKETING = "TICKETING"
|
||||||
|
WELCOME = "WELCOME"
|
||||||
|
PARKING = "PARKING"
|
||||||
|
CLEANING = "CLEANING"
|
||||||
|
CATERING = "CATERING"
|
||||||
|
SETUP = "SETUP"
|
||||||
|
STAGE = "STAGE"
|
||||||
|
OTHER = "OTHER"
|
||||||
|
|
||||||
|
|
||||||
|
class TshirtSize(enum.StrEnum):
|
||||||
|
XS = "XS"
|
||||||
|
S = "S"
|
||||||
|
M = "M"
|
||||||
|
L = "L"
|
||||||
|
XL = "XL"
|
||||||
|
XXL = "XXL"
|
||||||
|
XXXL = "XXXL"
|
||||||
|
|
||||||
|
|
||||||
|
# Statuts d'affectation « actifs » : seuls ceux-ci comptent pour les règles de
|
||||||
|
# chevauchement, de cumul horaire et de repos (voir app/validation).
|
||||||
|
ACTIVE_ASSIGNMENT_STATUSES: frozenset[AssignmentStatus] = frozenset(
|
||||||
|
{AssignmentStatus.PROPOSED, AssignmentStatus.CONFIRMED}
|
||||||
|
)
|
||||||
@@ -12,6 +12,7 @@ from app.main import app
|
|||||||
def engine():
|
def engine():
|
||||||
"""Vraie base PostgreSQL de test (voir docker/postgres-init)."""
|
"""Vraie base PostgreSQL de test (voir docker/postgres-init)."""
|
||||||
eng = create_engine(get_settings().test_database_url, pool_pre_ping=True)
|
eng = create_engine(get_settings().test_database_url, pool_pre_ping=True)
|
||||||
|
SQLModel.metadata.drop_all(eng)
|
||||||
SQLModel.metadata.create_all(eng)
|
SQLModel.metadata.create_all(eng)
|
||||||
yield eng
|
yield eng
|
||||||
SQLModel.metadata.drop_all(eng)
|
SQLModel.metadata.drop_all(eng)
|
||||||
@@ -19,13 +20,17 @@ def engine():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def session(engine):
|
def session(engine):
|
||||||
"""Session isolée : tout est annulé à la fin du test."""
|
"""Session isolée : chaque test s'exécute dans une transaction annulée
|
||||||
|
à la fin, y compris ses `commit()` (savepoints)."""
|
||||||
connection = engine.connect()
|
connection = engine.connect()
|
||||||
transaction = connection.begin()
|
transaction = connection.begin()
|
||||||
with Session(bind=connection) as s:
|
s = Session(bind=connection, join_transaction_mode="create_savepoint")
|
||||||
|
try:
|
||||||
yield s
|
yield s
|
||||||
transaction.rollback()
|
finally:
|
||||||
connection.close()
|
s.close()
|
||||||
|
transaction.rollback()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from app.models import (
|
||||||
|
Assignment,
|
||||||
|
AssignmentSource,
|
||||||
|
AssignmentStatus,
|
||||||
|
Availability,
|
||||||
|
AvailabilityStatus,
|
||||||
|
DayPart,
|
||||||
|
Edition,
|
||||||
|
Position,
|
||||||
|
PositionCategory,
|
||||||
|
Shift,
|
||||||
|
Skill,
|
||||||
|
Volunteer,
|
||||||
|
)
|
||||||
|
|
||||||
|
EXPECTED_TABLES = {
|
||||||
|
"edition",
|
||||||
|
"day_part",
|
||||||
|
"skill",
|
||||||
|
"volunteer",
|
||||||
|
"volunteer_skill",
|
||||||
|
"account",
|
||||||
|
"magic_link_token",
|
||||||
|
"position",
|
||||||
|
"shift",
|
||||||
|
"shift_required_skill",
|
||||||
|
"availability",
|
||||||
|
"assignment",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _edition(session) -> Edition:
|
||||||
|
e = Edition(
|
||||||
|
year=2026,
|
||||||
|
start_date=dt.date(2026, 7, 8),
|
||||||
|
end_date=dt.date(2026, 7, 11),
|
||||||
|
retention_days=365,
|
||||||
|
)
|
||||||
|
session.add(e)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(e)
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_has_expected_tables():
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
assert set(SQLModel.metadata.tables) == EXPECTED_TABLES
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_core_graph(session):
|
||||||
|
edition = _edition(session)
|
||||||
|
|
||||||
|
volunteer = Volunteer(
|
||||||
|
edition_id=edition.id,
|
||||||
|
email="alex@example.org",
|
||||||
|
first_name="Alex",
|
||||||
|
last_name="Martin",
|
||||||
|
)
|
||||||
|
skill = Skill(edition_id=edition.id, code="SST", label="Secourisme")
|
||||||
|
volunteer.skills.append(skill)
|
||||||
|
position = Position(
|
||||||
|
edition_id=edition.id, name="Bar principal", category=PositionCategory.BAR
|
||||||
|
)
|
||||||
|
session.add(volunteer)
|
||||||
|
session.add(position)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(volunteer)
|
||||||
|
session.refresh(position)
|
||||||
|
|
||||||
|
# Lien N-N bénévole <-> compétence.
|
||||||
|
assert [s.code for s in volunteer.skills] == ["SST"]
|
||||||
|
|
||||||
|
shift = Shift(
|
||||||
|
edition_id=edition.id,
|
||||||
|
position_id=position.id,
|
||||||
|
starts_at=dt.datetime(2026, 7, 9, 18, 0),
|
||||||
|
ends_at=dt.datetime(2026, 7, 9, 23, 0),
|
||||||
|
min_staff=2,
|
||||||
|
ideal_staff=4,
|
||||||
|
)
|
||||||
|
session.add(shift)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(volunteer)
|
||||||
|
session.refresh(shift)
|
||||||
|
|
||||||
|
assignment = Assignment(
|
||||||
|
edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id
|
||||||
|
)
|
||||||
|
session.add(assignment)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(assignment)
|
||||||
|
|
||||||
|
# Valeurs par défaut appliquées.
|
||||||
|
assert assignment.status == AssignmentStatus.PROPOSED
|
||||||
|
assert assignment.source == AssignmentSource.MANUAL
|
||||||
|
assert assignment.locked is False
|
||||||
|
# Relations.
|
||||||
|
assert shift.position.id == position.id
|
||||||
|
assert volunteer.assignments[0].id == assignment.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_enum_round_trip(session):
|
||||||
|
edition = _edition(session)
|
||||||
|
day_part = DayPart(
|
||||||
|
edition_id=edition.id,
|
||||||
|
code="EVENING",
|
||||||
|
label="Soirée",
|
||||||
|
start_time=dt.time(19, 0),
|
||||||
|
end_time=dt.time(6, 0),
|
||||||
|
order=2,
|
||||||
|
)
|
||||||
|
volunteer = Volunteer(
|
||||||
|
edition_id=edition.id, first_name="Sam", last_name="Nuit"
|
||||||
|
)
|
||||||
|
session.add_all([day_part, volunteer])
|
||||||
|
session.commit()
|
||||||
|
session.refresh(day_part)
|
||||||
|
session.refresh(volunteer)
|
||||||
|
|
||||||
|
avail = Availability(
|
||||||
|
edition_id=edition.id,
|
||||||
|
volunteer_id=volunteer.id,
|
||||||
|
date=dt.date(2026, 7, 9),
|
||||||
|
day_part_id=day_part.id,
|
||||||
|
status=AvailabilityStatus.IF_NEEDED,
|
||||||
|
)
|
||||||
|
session.add(avail)
|
||||||
|
session.commit()
|
||||||
|
session.expire_all()
|
||||||
|
|
||||||
|
reloaded = session.get(Availability, avail.id)
|
||||||
|
assert reloaded.status is AvailabilityStatus.IF_NEEDED
|
||||||
|
|
||||||
|
|
||||||
|
def test_volunteer_email_unique_per_edition(session):
|
||||||
|
edition = _edition(session)
|
||||||
|
session.add(
|
||||||
|
Volunteer(
|
||||||
|
edition_id=edition.id,
|
||||||
|
email="dup@example.org",
|
||||||
|
first_name="A",
|
||||||
|
last_name="A",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.add(
|
||||||
|
Volunteer(
|
||||||
|
edition_id=edition.id,
|
||||||
|
email="dup@example.org",
|
||||||
|
first_name="B",
|
||||||
|
last_name="B",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_volunteers_without_email_allowed(session):
|
||||||
|
edition = _edition(session)
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
Volunteer(edition_id=edition.id, first_name="A", last_name="A"),
|
||||||
|
Volunteer(edition_id=edition.id, first_name="B", last_name="B"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.commit() # NULL n'entre pas dans la contrainte d'unicité
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_volunteer_cascades_to_assignments(session):
|
||||||
|
edition = _edition(session)
|
||||||
|
volunteer = Volunteer(
|
||||||
|
edition_id=edition.id, first_name="Cascade", last_name="Test"
|
||||||
|
)
|
||||||
|
position = Position(
|
||||||
|
edition_id=edition.id, name="Accueil", category=PositionCategory.WELCOME
|
||||||
|
)
|
||||||
|
session.add_all([volunteer, position])
|
||||||
|
session.commit()
|
||||||
|
session.refresh(volunteer)
|
||||||
|
session.refresh(position)
|
||||||
|
|
||||||
|
shift = Shift(
|
||||||
|
edition_id=edition.id,
|
||||||
|
position_id=position.id,
|
||||||
|
starts_at=dt.datetime(2026, 7, 9, 9, 0),
|
||||||
|
ends_at=dt.datetime(2026, 7, 9, 12, 0),
|
||||||
|
)
|
||||||
|
session.add(shift)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(shift)
|
||||||
|
|
||||||
|
assignment = Assignment(
|
||||||
|
edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id
|
||||||
|
)
|
||||||
|
session.add(assignment)
|
||||||
|
session.commit()
|
||||||
|
assignment_id = assignment.id
|
||||||
|
volunteer_id = volunteer.id
|
||||||
|
|
||||||
|
# Suppression au niveau base : la cascade ON DELETE doit s'appliquer.
|
||||||
|
session.execute(
|
||||||
|
text("DELETE FROM volunteer WHERE id = :id"), {"id": volunteer_id}
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.expire_all()
|
||||||
|
|
||||||
|
assert session.get(Assignment, assignment_id) is None
|
||||||
|
# Le créneau, lui, subsiste.
|
||||||
|
assert session.get(Shift, shift.id) is not None
|
||||||
@@ -26,6 +26,7 @@ services:
|
|||||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
ENVIRONMENT: production
|
ENVIRONMENT: production
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
TZ: Europe/Paris
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ services:
|
|||||||
TEST_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-benevoles}:${POSTGRES_PASSWORD:-benevoles}@db:5432/benevoles_test
|
TEST_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-benevoles}:${POSTGRES_PASSWORD:-benevoles}@db:5432/benevoles_test
|
||||||
ENVIRONMENT: development
|
ENVIRONMENT: development
|
||||||
JWT_SECRET: ${JWT_SECRET:-dev-only-change-me}
|
JWT_SECRET: ${JWT_SECRET:-dev-only-change-me}
|
||||||
|
TZ: Europe/Paris
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
Reference in New Issue
Block a user