"""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")