Files
benevoles/backend/app/models/domain.py
T
AdamThomasandClaude Sonnet 5 f58ec00e30 Créneaux types : paramétrage + génération auto des créneaux
Le paramétrage des créneaux se fait dans « Règles & édition » : la coordination
définit des créneaux types (DayPart). À la création d'un poste, un créneau à
besoin 0/0 est généré pour chaque jour de l'édition et chaque créneau type ;
les coordinateurs ajustent ensuite min/idéal.

Back-end :
- DayPart CRUD (GET/POST/PATCH/DELETE /editions/{id}/day-parts). Code figé
  après création (slugifié depuis le libellé si absent), unique par édition.
- Shift.day_part_id (nullable, SET NULL) + migration 0004.
- create_position génère les créneaux ; POST .../positions/{pid}/generate-shifts
  recrée les manquants (idempotent, ne touche pas aux besoins déjà réglés).
- 13 tests (day-parts, génération, extension d'édition, ajout de type).

Front-end :
- api/hooks/mock : dayParts CRUD + positions.generateShifts.
- ConfigRules : panneau « Créneaux types » (libellé + horaires éditables,
  ajout, suppression).
- ConfigGrid : bouton « Générer les créneaux » sur l'en-tête du poste.
- GrillePage : case « à définir » (gris, glyphe ·) quand min=idéal=0, avec
  entrée de légende.

155 tests back OK ; typecheck + lint + build + smoke front OK.

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

340 lines
11 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,
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)
)
# 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
)
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")