Files
benevoles/backend/app/models/domain.py
T
AdamThomasandClaude Sonnet 5 0bd38a6465
ci-cd / build-backend (push) Failing after 4m12s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped
Formulaire public : paiement de la cotisation via l'API SumUp (remplace Yapla)
Remplace le simple lien Yapla statique par une vraie intégration API SumUp :
checkout hébergé créé à la soumission du formulaire, statut de paiement
(PENDING/PAID/FAILED/EXPIRED) tenu à jour par un webhook SumUp et visible sur
la fiche du bénévole. La cotisation n'est plus un montant unique : la
coordination configure autant de types de paiement qu'elle veut (libellé +
montant, ex. « Cotisation classique », « Mécénat »), et le bénévole en
choisit un à l'inscription — le libellé et le montant choisis sont figés sur
le bénévole, indépendamment d'une modification ultérieure de la config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 19:56:04 +02:00

570 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
FeePaymentStatus,
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"),
)
# Paiement de la cotisation via l'API SumUp (`app/sumup/`, voir
# `app/api/forms.py:submit_public_form`/`sumup_webhook`) — créé si le
# bénévole choisit un type de paiement (`FormPaymentOption`) et que
# l'édition a configuré SumUp (`FormTemplate.sumup_*`). `fee_payment_label`/
# `fee_amount` sont une copie figée du libellé/montant de l'option
# choisie au moment du choix (jamais recalculés si l'option est éditée
# ou supprimée ensuite). `fee_checkout_id` (identifiant SumUp du
# checkout) sert seulement à faire le lien depuis le webhook ; jamais
# exposé (voir `app/api/volunteers.py:_read`). `fee_payment_status` reste
# `NULL` tant qu'aucun checkout n'a été créé.
fee_payment_label: str | None = None
fee_amount: float | None = None
fee_checkout_id: str | None = Field(default=None, index=True)
fee_payment_status: FeePaymentStatus | None = Field(
default=None,
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
)
fee_paid_at: dt.datetime | 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")
# --------------------------------------------------------------------------- #
# 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` et `description` 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.
`sumup_*` configurent le paiement de la cotisation via l'API SumUp (voir
`app/sumup/`)."""
__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
# Paiement de la cotisation via l'API SumUp (`app/sumup/`) : un checkout
# est créé à la soumission si le bénévole choisit un des types de
# paiement configurés (`FormPaymentOption`, voir plus bas) et que ces
# deux identifiants sont renseignés (voir `app/api/forms.py`,
# `sumup_enabled` sur `PublicFormConfig`). `sumup_api_key` n'est jamais
# renvoyé par l'API (voir `FormTemplateRead.sumup_api_key_set`) — c'est
# un secret, au même titre qu'un mot de passe SMTP.
sumup_merchant_code: str | None = None
sumup_api_key: str | None = None
class FormPaymentOption(TimestampMixin, table=True):
"""Un type de paiement proposé sur le formulaire public (ex. « Cotisation
classique », « Mécénat ») : libellé + montant fixe, configurés librement
par la coordination (autant qu'elle veut, comme les créneaux types
`DayPart`) — voir `app/api/forms.py`. Le bénévole en choisit au plus un
à l'inscription ; le libellé et le montant réellement proposés sont
recopiés sur `Volunteer` au moment de la création du checkout
(`fee_payment_label`/`fee_amount`), pour ne jamais changer
rétroactivement si cette configuration est éditée ou supprimée plus
tard."""
__tablename__ = "form_payment_option"
__table_args__ = (
UniqueConstraint(
"edition_id", "label", name="uq_form_payment_option_edition_label"
),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE"
)
label: str
amount: float
order: int = Field(
default=0,
sa_column=Column(Integer, nullable=False, server_default="0"),
)
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)
)