From cb2e84adf0c0d90d25f20c2be89d12e6514e6113 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Wed, 9 Sep 2026 22:25:29 +0200 Subject: [PATCH] =?UTF-8?q?=C3=89tape=202=20:=20mod=C3=A8le=20de=20donn?= =?UTF-8?q?=C3=A9es=20complet=20et=20migration=20initiale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM --- CLAUDE.md | 64 +++- backend/Dockerfile | 9 +- .../versions/0001_initial_schema.py | 318 +++++++++++++++++ backend/app/models/__init__.py | 56 ++- backend/app/models/base.py | 28 ++ backend/app/models/domain.py | 330 ++++++++++++++++++ backend/app/models/enums.py | 60 ++++ backend/tests/conftest.py | 13 +- backend/tests/test_models.py | 216 ++++++++++++ deploy/docker-compose.prod.yml | 1 + docker-compose.yml | 1 + 11 files changed, 1068 insertions(+), 28 deletions(-) create mode 100644 backend/app/migrations/versions/0001_initial_schema.py create mode 100644 backend/app/models/base.py create mode 100644 backend/app/models/domain.py create mode 100644 backend/app/models/enums.py create mode 100644 backend/tests/test_models.py diff --git a/CLAUDE.md b/CLAUDE.md index f99fb5d..0c5004b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,25 +51,49 @@ Deux tables distinctes, deux écrans distincts, deux vocabulaires distincts. ## 2. Modèle de domaine -- **`Edition`** : année, `start_date`, `end_date`, `retention_days` - (durée de rétention des données nominatives, paramétrable), liste de - `DayPart`. -- **`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`. +Défini dans `backend/app/models/` (`enums.py`, `base.py`, `domain.py`). +Clés primaires `UUID`. Champs d'audit `created_at` / `updated_at` sur toutes +les tables sauf les tables d'association. -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 @@ -192,6 +216,7 @@ Aucune dépendance locale requise hors Docker. | 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 "..."` | | 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` | --- @@ -234,4 +259,5 @@ Aucune dépendance locale requise hors Docker. | É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 | diff --git a/backend/Dockerfile b/backend/Dockerfile index 4cc4246..533915e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,7 +2,14 @@ FROM python:3.12-slim 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_PROJECT_ENVIRONMENT=/app/.venv \ PATH="/app/.venv/bin:$PATH" diff --git a/backend/app/migrations/versions/0001_initial_schema.py b/backend/app/migrations/versions/0001_initial_schema.py new file mode 100644 index 0000000..428ee6b --- /dev/null +++ b/backend/app/migrations/versions/0001_initial_schema.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 6ada675..256bedf 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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 -est importé par Alembic (`app/migrations/env.py`) et par les tests pour -enregistrer les métadonnées. +Importé par Alembic (`app/migrations/env.py`) et par les tests pour enregistrer +les métadonnées sur `SQLModel.metadata`. """ + +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", +] diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..1eef26c --- /dev/null +++ b/backend/app/models/base.py @@ -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}, + ) diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py new file mode 100644 index 0000000..e83eeca --- /dev/null +++ b/backend/app/models/domain.py @@ -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") diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py new file mode 100644 index 0000000..2ecc551 --- /dev/null +++ b/backend/app/models/enums.py @@ -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} +) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 10eb6a6..a3dad66 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -12,6 +12,7 @@ from app.main import app def engine(): """Vraie base PostgreSQL de test (voir docker/postgres-init).""" eng = create_engine(get_settings().test_database_url, pool_pre_ping=True) + SQLModel.metadata.drop_all(eng) SQLModel.metadata.create_all(eng) yield eng SQLModel.metadata.drop_all(eng) @@ -19,13 +20,17 @@ def engine(): @pytest.fixture 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() transaction = connection.begin() - with Session(bind=connection) as s: + s = Session(bind=connection, join_transaction_mode="create_savepoint") + try: yield s - transaction.rollback() - connection.close() + finally: + s.close() + transaction.rollback() + connection.close() @pytest.fixture diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py new file mode 100644 index 0000000..33c90a2 --- /dev/null +++ b/backend/tests/test_models.py @@ -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 diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 55464f0..7a58494 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -26,6 +26,7 @@ services: DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} ENVIRONMENT: production JWT_SECRET: ${JWT_SECRET} + TZ: Europe/Paris depends_on: db: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index 16e3feb..3d0b7f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,7 @@ services: TEST_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-benevoles}:${POSTGRES_PASSWORD:-benevoles}@db:5432/benevoles_test ENVIRONMENT: development JWT_SECRET: ${JWT_SECRET:-dev-only-change-me} + TZ: Europe/Paris ports: - "8000:8000" depends_on: