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>
570 lines
22 KiB
Python
570 lines
22 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, 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)
|
||
)
|