Étape 2 : modèle de données complet et migration initiale
- Tables SQLModel : Edition, DayPart, Volunteer, Skill, Position, Shift, Availability, Assignment, Account, MagicLinkToken + tables d'association - Multi-édition (edition_id partout), énumérations en VARCHAR + CHECK, suppressions ON DELETE CASCADE (droit à l'effacement) - Dates/heures naïves en heure locale Europe/Paris (tzdata + TZ conteneur) - Migration Alembic 0001 écrite à la main, vérifiée par `alembic check` (aucun écart avec les modèles) et par un aller-retour downgrade/upgrade - Tests : graphe complet, unicité email par édition, N-N compétences, aller-retour d'énumération, cascade de suppression d'un bénévole Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
09d7d70076
commit
cb2e84adf0
@@ -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",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import datetime as dt
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
def local_now() -> dt.datetime:
|
||||
"""Horodatage naïf en heure locale (Europe/Paris via le TZ du conteneur).
|
||||
|
||||
Toutes les dates/heures du domaine sont naïves et interprétées en heure
|
||||
locale : créneaux, disponibilités, et champs d'audit.
|
||||
"""
|
||||
return dt.datetime.now()
|
||||
|
||||
|
||||
class TimestampMixin(SQLModel):
|
||||
"""Champs d'audit communs. Hérité par les tables, pas une table.
|
||||
|
||||
Pas de `sa_column=` ici : une instance `Column` ne peut pas être partagée
|
||||
entre plusieurs tables. On laisse SQLModel générer une colonne par table
|
||||
et on passe seulement des `sa_column_kwargs`.
|
||||
"""
|
||||
|
||||
created_at: dt.datetime = Field(default_factory=local_now, nullable=False)
|
||||
updated_at: dt.datetime = Field(
|
||||
default_factory=local_now,
|
||||
nullable=False,
|
||||
sa_column_kwargs={"onupdate": local_now},
|
||||
)
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Tables SQLModel du domaine.
|
||||
|
||||
Vocabulaire : voir CLAUDE.md § 1. Toute entité métier porte un `edition_id`
|
||||
(multi-édition dans une seule base). Les suppressions en cascade sont assurées
|
||||
au niveau base (`ondelete="CASCADE"`) ; les relations ORM utilisent
|
||||
`passive_deletes=True` pour laisser la base faire le travail.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, Column, Integer
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
|
||||
|
||||
from app.models.base import TimestampMixin
|
||||
from app.models.enums import (
|
||||
AssignmentSource,
|
||||
AssignmentStatus,
|
||||
AvailabilityStatus,
|
||||
PositionCategory,
|
||||
Role,
|
||||
TshirtSize,
|
||||
)
|
||||
|
||||
_PASSIVE = {"passive_deletes": True}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tables d'association (sans champs d'audit) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class VolunteerSkill(SQLModel, table=True):
|
||||
__tablename__ = "volunteer_skill"
|
||||
|
||||
volunteer_id: uuid.UUID = Field(
|
||||
foreign_key="volunteer.id", primary_key=True, ondelete="CASCADE"
|
||||
)
|
||||
skill_id: uuid.UUID = Field(
|
||||
foreign_key="skill.id", primary_key=True, ondelete="CASCADE"
|
||||
)
|
||||
|
||||
|
||||
class ShiftRequiredSkill(SQLModel, table=True):
|
||||
__tablename__ = "shift_required_skill"
|
||||
|
||||
shift_id: uuid.UUID = Field(
|
||||
foreign_key="shift.id", primary_key=True, ondelete="CASCADE"
|
||||
)
|
||||
skill_id: uuid.UUID = Field(
|
||||
foreign_key="skill.id", primary_key=True, ondelete="CASCADE"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Édition et découpage des journées #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class Edition(TimestampMixin, table=True):
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
year: int = Field(index=True)
|
||||
name: str | None = None
|
||||
start_date: dt.date
|
||||
end_date: dt.date
|
||||
# Durée de rétention des données nominatives, en jours. None = pas de purge.
|
||||
retention_days: int | None = None
|
||||
|
||||
day_parts: list["DayPart"] = Relationship(
|
||||
back_populates="edition", sa_relationship_kwargs=_PASSIVE
|
||||
)
|
||||
|
||||
|
||||
class DayPart(TimestampMixin, table=True):
|
||||
__tablename__ = "day_part"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("edition_id", "code", name="uq_day_part_edition_code"),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
code: str
|
||||
label: str
|
||||
start_time: dt.time
|
||||
# end_time < start_time signifie que la demi-journée passe minuit
|
||||
# (ex. soirée 19:00 -> 06:00).
|
||||
end_time: dt.time
|
||||
order: int = Field(
|
||||
default=0,
|
||||
sa_column=Column(Integer, nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
edition: Edition = Relationship(back_populates="day_parts")
|
||||
availabilities: list["Availability"] = Relationship(
|
||||
back_populates="day_part", sa_relationship_kwargs=_PASSIVE
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Personnes : bénévoles, compétences, comptes #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class Skill(TimestampMixin, table=True):
|
||||
__table_args__ = (
|
||||
UniqueConstraint("edition_id", "code", name="uq_skill_edition_code"),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
code: str
|
||||
label: str
|
||||
|
||||
volunteers: list["Volunteer"] = Relationship(
|
||||
back_populates="skills",
|
||||
link_model=VolunteerSkill,
|
||||
sa_relationship_kwargs=_PASSIVE,
|
||||
)
|
||||
shifts_requiring: list["Shift"] = Relationship(
|
||||
back_populates="required_skills",
|
||||
link_model=ShiftRequiredSkill,
|
||||
sa_relationship_kwargs=_PASSIVE,
|
||||
)
|
||||
|
||||
|
||||
class Volunteer(TimestampMixin, table=True):
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"edition_id", "email", name="uq_volunteer_edition_email"
|
||||
),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
# Identifiant fonctionnel unique par édition. Nullable : bénévole créé à la
|
||||
# main par la coordination, sans email.
|
||||
email: str | None = Field(default=None, index=True)
|
||||
first_name: str
|
||||
last_name: str
|
||||
phone: str | None = None
|
||||
tshirt_size: TshirtSize | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(SAEnum(TshirtSize, native_enum=False), nullable=True),
|
||||
)
|
||||
diet: str | None = None
|
||||
comment: str | None = None
|
||||
|
||||
skills: list[Skill] = Relationship(
|
||||
back_populates="volunteers",
|
||||
link_model=VolunteerSkill,
|
||||
sa_relationship_kwargs=_PASSIVE,
|
||||
)
|
||||
availabilities: list["Availability"] = Relationship(
|
||||
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
|
||||
)
|
||||
assignments: list["Assignment"] = Relationship(
|
||||
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
|
||||
)
|
||||
|
||||
|
||||
class Account(TimestampMixin, table=True):
|
||||
__table_args__ = (
|
||||
UniqueConstraint("edition_id", "email", name="uq_account_edition_email"),
|
||||
UniqueConstraint("volunteer_id", name="uq_account_volunteer"),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
email: str = Field(index=True)
|
||||
role: Role = Field(
|
||||
sa_column=Column(SAEnum(Role, native_enum=False), nullable=False)
|
||||
)
|
||||
# Lien optionnel vers le bénévole. Supprimé avec lui (droit à l'effacement).
|
||||
volunteer_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
foreign_key="volunteer.id",
|
||||
ondelete="CASCADE",
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
||||
class MagicLinkToken(TimestampMixin, table=True):
|
||||
__tablename__ = "magic_link_token"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
account_id: uuid.UUID = Field(
|
||||
foreign_key="account.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
token_hash: str = Field(unique=True, index=True)
|
||||
expires_at: dt.datetime
|
||||
used_at: dt.datetime | None = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Planning : postes, créneaux, disponibilités, affectations #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class Position(TimestampMixin, table=True):
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
name: str
|
||||
location: str | None = None
|
||||
category: PositionCategory = Field(
|
||||
sa_column=Column(
|
||||
SAEnum(PositionCategory, native_enum=False), nullable=False
|
||||
)
|
||||
)
|
||||
lead_account_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
foreign_key="account.id",
|
||||
ondelete="SET NULL",
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
shifts: list["Shift"] = Relationship(
|
||||
back_populates="position", sa_relationship_kwargs=_PASSIVE
|
||||
)
|
||||
|
||||
|
||||
class Shift(TimestampMixin, table=True):
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
position_id: uuid.UUID = Field(
|
||||
foreign_key="position.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
starts_at: dt.datetime
|
||||
# Peut être le lendemain de starts_at (créneau à cheval sur minuit).
|
||||
ends_at: dt.datetime
|
||||
min_staff: int = Field(
|
||||
default=0, sa_column=Column(Integer, nullable=False, server_default="0")
|
||||
)
|
||||
ideal_staff: int = Field(
|
||||
default=0, sa_column=Column(Integer, nullable=False, server_default="0")
|
||||
)
|
||||
|
||||
position: Position = Relationship(back_populates="shifts")
|
||||
required_skills: list[Skill] = Relationship(
|
||||
back_populates="shifts_requiring",
|
||||
link_model=ShiftRequiredSkill,
|
||||
sa_relationship_kwargs=_PASSIVE,
|
||||
)
|
||||
assignments: list["Assignment"] = Relationship(
|
||||
back_populates="shift", sa_relationship_kwargs=_PASSIVE
|
||||
)
|
||||
|
||||
|
||||
class Availability(TimestampMixin, table=True):
|
||||
"""Déclaration du bénévole : « je peux venir tel jour, telle demi-journée ».
|
||||
|
||||
À ne pas confondre avec Assignment (décision de l'organisation).
|
||||
"""
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"volunteer_id", "date", "day_part_id", name="uq_availability_slot"
|
||||
),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
volunteer_id: uuid.UUID = Field(
|
||||
foreign_key="volunteer.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
date: dt.date
|
||||
day_part_id: uuid.UUID = Field(
|
||||
foreign_key="day_part.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
status: AvailabilityStatus = Field(
|
||||
sa_column=Column(
|
||||
SAEnum(AvailabilityStatus, native_enum=False), nullable=False
|
||||
)
|
||||
)
|
||||
|
||||
volunteer: Volunteer = Relationship(back_populates="availabilities")
|
||||
day_part: DayPart = Relationship(back_populates="availabilities")
|
||||
|
||||
|
||||
class Assignment(TimestampMixin, table=True):
|
||||
"""Décision de l'organisation : « tu tiens tel créneau ».
|
||||
|
||||
À ne pas confondre avec Availability (déclaration du bénévole).
|
||||
"""
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"volunteer_id", "shift_id", name="uq_assignment_volunteer_shift"
|
||||
),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
volunteer_id: uuid.UUID = Field(
|
||||
foreign_key="volunteer.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
shift_id: uuid.UUID = Field(
|
||||
foreign_key="shift.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
status: AssignmentStatus = Field(
|
||||
default=AssignmentStatus.PROPOSED,
|
||||
sa_column=Column(
|
||||
SAEnum(AssignmentStatus, native_enum=False),
|
||||
nullable=False,
|
||||
server_default=AssignmentStatus.PROPOSED.value,
|
||||
),
|
||||
)
|
||||
source: AssignmentSource = Field(
|
||||
default=AssignmentSource.MANUAL,
|
||||
sa_column=Column(
|
||||
SAEnum(AssignmentSource, native_enum=False),
|
||||
nullable=False,
|
||||
server_default=AssignmentSource.MANUAL.value,
|
||||
),
|
||||
)
|
||||
locked: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
|
||||
volunteer: Volunteer = Relationship(back_populates="assignments")
|
||||
shift: Shift = Relationship(back_populates="assignments")
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Énumérations du domaine.
|
||||
|
||||
Stockées en base comme VARCHAR + contrainte CHECK (`native_enum=False`) : pas de
|
||||
type ENUM natif PostgreSQL, donc pas de migration de type à gérer.
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class Role(enum.StrEnum):
|
||||
COORDINATOR = "COORDINATOR"
|
||||
LEAD = "LEAD"
|
||||
VOLUNTEER = "VOLUNTEER"
|
||||
|
||||
|
||||
class AvailabilityStatus(enum.StrEnum):
|
||||
AVAILABLE = "AVAILABLE"
|
||||
IF_NEEDED = "IF_NEEDED"
|
||||
UNAVAILABLE = "UNAVAILABLE"
|
||||
|
||||
|
||||
class AssignmentStatus(enum.StrEnum):
|
||||
PROPOSED = "PROPOSED"
|
||||
CONFIRMED = "CONFIRMED"
|
||||
DECLINED = "DECLINED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
class AssignmentSource(enum.StrEnum):
|
||||
SOLVER = "SOLVER"
|
||||
MANUAL = "MANUAL"
|
||||
|
||||
|
||||
class PositionCategory(enum.StrEnum):
|
||||
BAR = "BAR"
|
||||
TICKETING = "TICKETING"
|
||||
WELCOME = "WELCOME"
|
||||
PARKING = "PARKING"
|
||||
CLEANING = "CLEANING"
|
||||
CATERING = "CATERING"
|
||||
SETUP = "SETUP"
|
||||
STAGE = "STAGE"
|
||||
OTHER = "OTHER"
|
||||
|
||||
|
||||
class TshirtSize(enum.StrEnum):
|
||||
XS = "XS"
|
||||
S = "S"
|
||||
M = "M"
|
||||
L = "L"
|
||||
XL = "XL"
|
||||
XXL = "XXL"
|
||||
XXXL = "XXXL"
|
||||
|
||||
|
||||
# Statuts d'affectation « actifs » : seuls ceux-ci comptent pour les règles de
|
||||
# chevauchement, de cumul horaire et de repos (voir app/validation).
|
||||
ACTIVE_ASSIGNMENT_STATUSES: frozenset[AssignmentStatus] = frozenset(
|
||||
{AssignmentStatus.PROPOSED, AssignmentStatus.CONFIRMED}
|
||||
)
|
||||
Reference in New Issue
Block a user