"""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, LargeBinary, 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, BadgeAlign, BadgeFieldType, FormFieldType, 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 # Renseigné par le formulaire public d'inscription (`app/api/forms.py`) — # voir aussi ce module pour `photo`/`photo_content_type`. photo: bytes | None = Field(default=None, sa_column=Column(LargeBinary)) photo_content_type: str | None = None # « Collègue que j'aime bien » saisi en texte libre à l'inscription : la # personne visée n'existe pas forcément encore en base à ce moment-là, # donc pas de lien direct vers un autre `Volunteer`. La coordination lit # cette note sur la fiche et la transforme, si besoin, en un vrai # `VolunteerCompanionWish` une fois les deux bénévoles inscrits. companion_wish_note: str | None = None # Cases à cocher du formulaire public — purement informatives : aucune # logique n'y est attachée (pas de lien avec `Availability`, pas de # workflow de paiement). self_declared_available: bool = Field( default=False, sa_column=Column(Boolean, nullable=False, server_default="false"), ) willing_to_pay_fee: bool = Field( default=False, sa_column=Column(Boolean, nullable=False, server_default="false"), ) 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") # --------------------------------------------------------------------------- # # Badges (planches recto/verso, voir app/badges/) # # --------------------------------------------------------------------------- # class BadgeTemplate(TimestampMixin, table=True): """Recto/verso du badge d'une édition. Une ligne par édition (créée à la demande). Le verso (`back_image`) est le même pour tous les bénévoles ; le recto combine `front_image` (fond) et les `BadgeField` positionnés dessus. Images stockées en base (pas de volume de fichiers à gérer). Le format du badge (`front_image_width_mm`/`height_mm`) est **dérivé de l'image du recto** à l'import (voir `app/badges/pdf.py:image_size_mm`, résolution supposée 300 dpi) plutôt qu'un format fixe imposé — `None` tant qu'aucune image n'a été importée (l'éditeur retombe alors sur le format CR80 par défaut, voir `DEFAULT_BADGE_WIDTH_MM`).""" __tablename__ = "badge_template" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) edition_id: uuid.UUID = Field( foreign_key="edition.id", unique=True, ondelete="CASCADE" ) front_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary)) front_image_content_type: str | None = None front_image_width_mm: float | None = None front_image_height_mm: float | None = None back_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary)) back_image_content_type: str | None = None class BadgeField(TimestampMixin, table=True): """Un champ positionné sur le recto du badge (nom, poste(s), catégorie, rôle — voir `BadgeFieldType`). Coordonnées en millimètres depuis le coin haut-gauche du badge (format CR80, 85,6 × 54 mm — voir `app/badges/pdf.py`). Au plus un champ par `field_type` et par édition : décocher un champ dans l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`).""" __tablename__ = "badge_field" __table_args__ = ( UniqueConstraint( "edition_id", "field_type", name="uq_badge_field_edition_type" ), ) id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) edition_id: uuid.UUID = Field( foreign_key="edition.id", index=True, ondelete="CASCADE" ) field_type: BadgeFieldType = Field( sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False) ) x_mm: float y_mm: float width_mm: float height_mm: float font_size: float = Field(default=10.0) align: BadgeAlign = Field( default=BadgeAlign.LEFT, sa_column=Column( SAEnum(BadgeAlign, native_enum=False), nullable=False, server_default=BadgeAlign.LEFT.value, ), ) bold: bool = Field( default=False, sa_column=Column(Boolean, nullable=False, server_default="false"), ) color: str = Field(default="#000000") # --------------------------------------------------------------------------- # # Formulaire public d'inscription (voir app/api/forms.py) # # --------------------------------------------------------------------------- # class FormTemplate(TimestampMixin, table=True): """Configuration du formulaire public d'une édition. Une ligne par édition (créée à la demande). `is_open` coupe les nouvelles soumissions sans effacer la configuration (les champs activés restent enregistrés). `logo`/`logo_content_type`, `description` et `yapla_url` habillent la page publique (`/inscription`) — aucun rapport avec les champs collectés par `FormField` ; stockage en base comme les autres images de l'appli (badges, photo de bénévole), pas de volume de fichiers à gérer.""" __tablename__ = "form_template" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) edition_id: uuid.UUID = Field( foreign_key="edition.id", unique=True, ondelete="CASCADE" ) is_open: bool = Field( default=True, sa_column=Column(Boolean, nullable=False, server_default="true"), ) logo: bytes | None = Field(default=None, sa_column=Column(LargeBinary)) logo_content_type: str | None = None description: str | None = None # Lien vers la page de cotisation Yapla (yapla.com, plateforme externe de # paiement pour associations) — encart affiché sur `/inscription` près de # la case « je participe à la cotisation » (`FormFieldType. # WILLING_TO_PAY_FEE`). Rien n'est vérifié côté appli : la case reste # une déclaration, le paiement se passe entièrement chez Yapla. yapla_url: str | None = None class FormField(SQLModel, table=True): """Un champ activé sur le formulaire public (voir `FormFieldType`) — la présence d'une ligne vaut activation. Pas de champs d'audit : juste une case à cocher dans l'écran de configuration, remplacée en bloc (`PUT .../form/fields`), rien d'autre à suivre dans le temps.""" __tablename__ = "form_field" __table_args__ = ( UniqueConstraint( "edition_id", "field_type", name="uq_form_field_edition_type" ), ) id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) edition_id: uuid.UUID = Field( foreign_key="edition.id", index=True, ondelete="CASCADE" ) field_type: FormFieldType = Field( sa_column=Column(SAEnum(FormFieldType, native_enum=False), nullable=False) )