Files
benevoles/backend/app/migrations/versions/0001_initial_schema.py
T
AdamThomasandClaude Sonnet 5 cb2e84adf0 É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
2026-09-09 22:25:29 +02:00

319 lines
11 KiB
Python

"""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")