É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
@@ -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
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",
]
+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}
)