É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:
AdamThomas
2026-09-09 22:25:29 +02:00
co-authored by Claude Sonnet 5
parent 09d7d70076
commit cb2e84adf0
11 changed files with 1068 additions and 28 deletions
+45 -19
View File
@@ -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
View File
@@ -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")
+52 -4
View File
@@ -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",
]
+28
View File
@@ -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},
)
+330
View File
@@ -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")
+60
View File
@@ -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}
)
+7 -2
View File
@@ -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,11 +20,15 @@ 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
finally:
s.close()
transaction.rollback() transaction.rollback()
connection.close() connection.close()
+216
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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: