Files
benevoles/backend/app/models/domain.py
T
AdamThomasandClaude Sonnet 5 24caece7df Bénévole : vœux d'artistes (entité Artist) et vœux de postes
Nouvelle entité `Artist` (nom + jour de passage), CRUD
`/editions/{id}/artists` (lecture ouverte à tout compte de l'édition,
écriture COORDINATION), unicité (édition, nom, date).

La fiche bénévole porte deux listes de vœux, sur le modèle de
`skill_codes` (None = inchangé, [] = vider) :
- `artist_ids` : artistes que le bénévole veut voir (`volunteer_artist`) ;
- `wished_position_ids` : postes qu'il voudrait tenir
  (`volunteer_position_wish`), distinct de l'affectation.

Un id étranger à l'édition renvoie 422 (`artist.unknown` /
`position.unknown`). Suppression d'un artiste : cascade sur les vœux.
Migration 0006. Jeu de démonstration et frontend inchangés (le front
suivra).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-10 22:12:55 +02:00

404 lines
13 KiB
Python

"""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,
AvailabilityStatus,
PositionCategory,
Role,
)
_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"
)
class VolunteerArtist(SQLModel, table=True):
"""Vœu d'un bénévole : « je veux voir cet artiste »."""
__tablename__ = "volunteer_artist"
volunteer_id: uuid.UUID = Field(
foreign_key="volunteer.id", primary_key=True, ondelete="CASCADE"
)
artist_id: uuid.UUID = Field(
foreign_key="artist.id", primary_key=True, ondelete="CASCADE"
)
class VolunteerPositionWish(SQLModel, table=True):
"""Vœu d'un bénévole : « je voudrais tenir ce poste »."""
__tablename__ = "volunteer_position_wish"
volunteer_id: uuid.UUID = Field(
foreign_key="volunteer.id", primary_key=True, ondelete="CASCADE"
)
position_id: uuid.UUID = Field(
foreign_key="position.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 Artist(TimestampMixin, table=True):
"""Artiste programmé sur l'édition.
La coordination constitue la liste (présentée par jour de passage). Les
bénévoles y choisissent les artistes qu'ils veulent voir (`VolunteerArtist`).
"""
__table_args__ = (
UniqueConstraint(
"edition_id", "name", "performance_date",
name="uq_artist_edition_name_date",
),
)
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
# Jour de passage, dans les dates de l'édition.
performance_date: dt.date = Field(index=True)
fans: list["Volunteer"] = Relationship(
back_populates="wished_artists",
link_model=VolunteerArtist,
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
comment: str | None = None
skills: list[Skill] = Relationship(
back_populates="volunteers",
link_model=VolunteerSkill,
sa_relationship_kwargs=_PASSIVE,
)
wished_artists: list[Artist] = Relationship(
back_populates="fans",
link_model=VolunteerArtist,
sa_relationship_kwargs=_PASSIVE,
)
wished_positions: list["Position"] = Relationship(
back_populates="wishing_volunteers",
link_model=VolunteerPositionWish,
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)
)
# Défini ⇒ connexion par mot de passe (admins). None ⇒ connexion par lien
# magique (référents, bénévoles).
password_hash: str | None = None
# 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,
)
@property
def auth_method(self) -> str:
return "password" if self.password_hash else "magic_link"
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
)
wishing_volunteers: list["Volunteer"] = Relationship(
back_populates="wished_positions",
link_model=VolunteerPositionWish,
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
# Créneau type dont ce créneau est issu (génération à la création d'un
# poste). NULL = créneau ad hoc. SET NULL si le type est supprimé.
day_part_id: uuid.UUID | None = Field(
default=None,
foreign_key="day_part.id",
ondelete="SET NULL",
nullable=True,
index=True,
)
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 ».
Existe ⇒ le bénévole est affecté (pas d'étape de confirmation). Retirer une
affectation, c'est supprimer la ligne. À 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"
)
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")