Nouveau vœu sur la fiche du bénévole — « je voudrais être affecté avec tel(s) bénévole(s) » — pris en compte à la fois pour le rendre visible et par le solveur. Backend : - `VolunteerCompanionWish` (`volunteer_id`, `companion_id`, à sens unique, `CHECK (volunteer_id <> companion_id)`) — migration 0010. - API : `wished_companion_ids` sur `VolunteerCreate`/`Update`/`Read`, mêmes règles que les autres vœux (ids inconnus → 422 `volunteer.unknown` ; se souhaiter soi-même → 422 `volunteer.cannot_wish_self`). - Solveur (`engine.py`) : bonus (+25 par correspondance) si un des compagnons souhaités du candidat est déjà affecté (décision déjà prise) au créneau — cumulable si plusieurs compagnons sont présents. Comme les autres termes de l'objectif, un bonus/malus, jamais une contrainte. - Seed de démo : ~20 % des bénévoles ont un vœu de compagnon (couple, amis). Frontend : - Fiche bénévole (`ConfigVolunteers.tsx`) : sélecteur de compagnons (ajout et édition), avec une bulle d'aide expliquant le sens unique et l'effet sur le solveur — c'est ce qui « fait apparaître » le lien entre bénévoles. - Panneau d'affectation : badge « avec N compagnon(s) souhaité(s) » sur un candidat, et bonus correspondant dans le classement par défaut (`candidateChanceScore`). - Mock (`runSolver`) : même bonus, pour que la démonstration se comporte pareil que le vrai solveur. CLAUDE.md à jour (modèle de domaine, section 4, journal). Vérifié : 194 tests backend (dont 13 nouveaux), ruff, alembic check ; typecheck/lint/build/smoke frontend ; solveur testé en direct sur le jeu de démo reseedé (0,25 s, 47 suggestions). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
367 lines
13 KiB
Python
367 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, CheckConstraint, Column, Integer, Time
|
|
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,
|
|
PositionCategory,
|
|
Role,
|
|
)
|
|
|
|
_PASSIVE = {"passive_deletes": True}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Tables d'association (sans champs d'audit) #
|
|
# --------------------------------------------------------------------------- #
|
|
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"
|
|
)
|
|
|
|
|
|
class VolunteerCompanionWish(SQLModel, table=True):
|
|
"""Vœu d'un bénévole : « je voudrais être affecté avec ce bénévole ».
|
|
|
|
De 0 à N par bénévole, à sens unique (A peut souhaiter B sans que B ne le
|
|
souhaite en retour) — le solveur (section 4 de CLAUDE.md) le lit dans ce
|
|
seul sens."""
|
|
|
|
__tablename__ = "volunteer_companion_wish"
|
|
__table_args__ = (
|
|
CheckConstraint(
|
|
"volunteer_id <> companion_id", name="ck_companion_wish_not_self"
|
|
),
|
|
)
|
|
|
|
volunteer_id: uuid.UUID = Field(
|
|
foreign_key="volunteer.id", primary_key=True, ondelete="CASCADE"
|
|
)
|
|
companion_id: uuid.UUID = Field(
|
|
foreign_key="volunteer.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")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Personnes : bénévoles, comptes #
|
|
# --------------------------------------------------------------------------- #
|
|
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)
|
|
# Heure de passage. end_time <= start_time ⇒ le passage finit le lendemain.
|
|
start_time: dt.time = Field(
|
|
sa_column=Column(Time, nullable=False, server_default="20:00:00")
|
|
)
|
|
end_time: dt.time = Field(
|
|
sa_column=Column(Time, nullable=False, server_default="21:00:00")
|
|
)
|
|
|
|
fans: list["Volunteer"] = Relationship(
|
|
back_populates="wished_artists",
|
|
link_model=VolunteerArtist,
|
|
sa_relationship_kwargs=_PASSIVE,
|
|
)
|
|
|
|
|
|
class Volunteer(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"
|
|
)
|
|
# Pas unique : plusieurs bénévoles peuvent partager une même adresse (ex.
|
|
# un couple, une famille). L'import (app/importing/service.py) distingue
|
|
# deux bénévoles d'un même email par leur nom ; la coordination reste
|
|
# seule juge en cas d'ambiguïté réelle.
|
|
email: str | None = Field(default=None, index=True)
|
|
first_name: str
|
|
last_name: str
|
|
phone: str | None = None
|
|
comment: str | None = None
|
|
|
|
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")
|
|
assignments: list["Assignment"] = Relationship(
|
|
back_populates="shift", sa_relationship_kwargs=_PASSIVE
|
|
)
|
|
|
|
|
|
class Availability(TimestampMixin, table=True):
|
|
"""Déclaration du bénévole : « tel jour, je peux venir de telle à telle
|
|
heure ». Fenêtre horaire libre (pas liée aux créneaux types). Plusieurs
|
|
fenêtres par jour possibles.
|
|
|
|
Heures naïves, locales Europe/Paris. `end_time <= start_time` ⇒ la fenêtre
|
|
passe minuit (se termine le lendemain). À ne pas confondre avec Assignment
|
|
(décision de l'organisation).
|
|
"""
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"volunteer_id", "date", "start_time", "end_time",
|
|
name="uq_availability_window",
|
|
),
|
|
)
|
|
|
|
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
|
|
start_time: dt.time
|
|
end_time: dt.time
|
|
|
|
volunteer: Volunteer = 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")
|