Créateur de badges refait d'après la maquette : champs sur le recto et le verso (BadgeField.face, verso placé en miroir dans le PDF pour le recto-verso), fond de zone, réduction automatique du texte, photo ovale, initiales sans photo, nom sur deux lignes (migration 0026) ; éditeur en trois colonnes (champs, aperçu recto/verso avec de vrais bénévoles, inspecteur), formats prédéfinis, annuler, flèches, zoom, repères de découpe, dépôt d'image sur l'aperçu, contrôle de tous les badges avant génération. Nombre de badges par bénévole (Volunteer.badge_count, 1 par défaut, migration 0027) : champ sur la fiche, autant d'exemplaires sur les planches, total affiché à la génération, colonne dans l'export Excel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
704 lines
29 KiB
Python
704 lines
29 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 secrets
|
|
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,
|
|
BadgeFace,
|
|
BadgeFieldType,
|
|
BadgePhotoShape,
|
|
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):
|
|
__table_args__ = (
|
|
CheckConstraint("badge_count >= 1", name="ck_volunteer_badge_count_positive"),
|
|
)
|
|
|
|
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
|
|
# Texte du badge propre à ce bénévole (champ `BadgeFieldType.FREE_TEXT`),
|
|
# saisi par la coordination sur la fiche — ex. « Accès backstage ». Vide :
|
|
# le texte commun de l'éditeur (`BadgeField.text`) s'applique.
|
|
badge_text: str | None = None
|
|
# Nombre d'exemplaires de son badge à imprimer sur les planches (badge
|
|
# perdu, plusieurs accréditations…) — 1 par défaut.
|
|
badge_count: int = Field(
|
|
default=1, sa_column=Column(Integer, nullable=False, server_default="1")
|
|
)
|
|
# Cases à cocher du formulaire public — purement informatives : aucune
|
|
# logique n'y est attachée (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
|
|
# Transaction SumUp qui a réglé la cotisation (`app/sumup/client.py:
|
|
# SumUpCheckout`), posée à la confirmation du paiement : le code court
|
|
# (`TEENSK4W2K`) est celui du tableau de bord SumUp et de ses reçus —
|
|
# rapprochement comptable paiement ↔ bénévole. Exposés (fiche, export
|
|
# Excel), contrairement à `fee_checkout_id`.
|
|
fee_transaction_code: str | None = Field(default=None, index=True)
|
|
fee_transaction_id: str | None = None
|
|
# Coordonnées collectées uniquement pour un type de paiement « mécénat »
|
|
# (`FormPaymentOption.requires_mecenat_info`), avant le paiement (voir
|
|
# `PendingFormSubmission` et `app/api/forms.py:submit_mecenat_info`) —
|
|
# conservées pour générer un reçu fiscal (cerfa) ultérieurement. `None`
|
|
# pour un bénévole n'ayant pas payé via une option de mécénat.
|
|
mecenat_address: str | None = None
|
|
mecenat_city: str | None = None
|
|
mecenat_postal_code: str | None = None
|
|
mecenat_country: str | None = None
|
|
# Numéro d'ordre du reçu fiscal (cerfa) : année du paiement + rang sur 5
|
|
# chiffres (`202600001`), attribué une seule fois — à la confirmation du
|
|
# paiement ou, à défaut, au premier export — puis figé, pour qu'un reçu
|
|
# regénéré garde son numéro. Voir `app/mecenat/receipts.py`.
|
|
mecenat_receipt_number: str | None = Field(default=None, unique=True)
|
|
|
|
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,
|
|
)
|
|
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 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.
|
|
"""
|
|
|
|
__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 une face du badge (`face` : recto ou verso —
|
|
nom, poste(s), catégorie, rôle… voir `BadgeFieldType`). Coordonnées en
|
|
millimètres depuis le coin haut-gauche du badge (voir `app/badges/pdf.py`).
|
|
Au plus un champ par `field_type` et par face pour une édition (le même
|
|
champ peut figurer au recto et au verso) : décocher un champ dans
|
|
l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`)."""
|
|
|
|
__tablename__ = "badge_field"
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"edition_id", "face", "field_type", name="uq_badge_field_edition_face_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"
|
|
)
|
|
face: BadgeFace = Field(
|
|
default=BadgeFace.FRONT,
|
|
sa_column=Column(
|
|
SAEnum(BadgeFace, native_enum=False),
|
|
nullable=False,
|
|
server_default=BadgeFace.FRONT.value,
|
|
),
|
|
)
|
|
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")
|
|
# Champ `FREE_TEXT` seulement : texte saisi dans l'éditeur, imprimé sur
|
|
# tous les badges — sauf ceux dont la fiche porte un « Texte du badge »
|
|
# (`Volunteer.badge_text`), qui le remplace pour ce bénévole.
|
|
text: str | None = None
|
|
# Fond de la zone du champ (`#rrggbb`) ; `None` = transparent. Sans
|
|
# effet sur le champ `PHOTO`.
|
|
bg_color: str | None = None
|
|
# Réduit la taille du texte (par pas de 0,5 pt) jusqu'à ce que tout
|
|
# tienne dans le cadre, au lieu de tronquer les lignes en trop.
|
|
auto_fit: bool = Field(
|
|
default=False,
|
|
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
|
)
|
|
# Champ `PHOTO` seulement : rectangle ou ellipse inscrite dans le cadre.
|
|
photo_shape: BadgePhotoShape = Field(
|
|
default=BadgePhotoShape.RECT,
|
|
sa_column=Column(
|
|
SAEnum(BadgePhotoShape, native_enum=False),
|
|
nullable=False,
|
|
server_default=BadgePhotoShape.RECT.value,
|
|
),
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 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
|
|
# Secret partagé nécessaire pour atteindre le formulaire public
|
|
# (`GET .../form/public`, `GET .../form/logo`, `POST .../form/public/submit`
|
|
# — voir `app/api/forms.py:_require_access_token`) : `/inscription` seul,
|
|
# sans ce jeton dans l'URL, ne mène nulle part. Généré aléatoirement à la
|
|
# création de la ligne (`secrets.token_urlsafe`) ; régénérable par la
|
|
# coordination (`POST .../form/regenerate-token`) pour révoquer un lien
|
|
# qui aurait fuité — l'ancien cesse alors immédiatement de fonctionner.
|
|
access_token: str = Field(default_factory=lambda: secrets.token_urlsafe(16))
|
|
|
|
|
|
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"),
|
|
)
|
|
# Type « mécénat » : avant le paiement, le bénévole passe par une étape
|
|
# supplémentaire (adresse postale) pour permettre l'édition d'un reçu
|
|
# fiscal (cerfa) — voir `Volunteer.mecenat_*` et
|
|
# `app/api/forms.py:submit_mecenat_info`.
|
|
requires_mecenat_info: bool = Field(
|
|
default=False,
|
|
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
|
)
|
|
# Montant libre : le bénévole saisit lui-même son montant sur le
|
|
# formulaire public ; `amount` en est alors le minimum (voir
|
|
# `app/api/forms.py:_free_amount`).
|
|
free_amount: bool = Field(
|
|
default=False,
|
|
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
|
)
|
|
|
|
|
|
class PendingFormSubmission(TimestampMixin, table=True):
|
|
"""Inscription en attente de paiement en ligne (voir CLAUDE.md § 2,
|
|
`Paiement de la cotisation`). Créée à la place d'un `Volunteer` quand le
|
|
bénévole choisit un type de paiement en ligne (`FormPaymentOption`) sur
|
|
le formulaire public — le `Volunteer` n'est créé qu'à la confirmation du
|
|
paiement (`app/api/forms.py:_promote_pending`), pour ne jamais compter en
|
|
base quelqu'un qui a rempli le formulaire sans aller au bout du
|
|
paiement.
|
|
|
|
Reprend les champs du formulaire (identiques à `Volunteer`, en plus
|
|
volatil) ; `position_wish_ids_json`/`artist_wish_ids_json` : listes
|
|
JSON stringifiées (pas d'association SQL pour une ligne éphémère), leurs
|
|
UUID sont résolus (silencieusement filtrés si un poste/artiste a
|
|
entretemps disparu — le paiement ne doit jamais échouer pour ça) au
|
|
moment de la promotion en `Volunteer`.
|
|
|
|
`id` sert aussi de jeton public (`GET .../form/public/pending/{id}`,
|
|
aucune authentification) : un UUID v4 est déjà pratiquement impossible
|
|
à deviner, comme un identifiant de session de paiement chez la plupart
|
|
des prestataires.
|
|
|
|
`payment_label`/`payment_amount`/`requires_mecenat_info` sont une copie
|
|
figée de l'option choisie (même principe que `Volunteer.fee_payment_label`)
|
|
— indépendante d'une modification de `FormPaymentOption` pendant que le
|
|
paiement est en cours. `checkout_id`/`checkout_status` suivent le
|
|
checkout SumUp *courant* : relancés (nouvel identifiant) à chaque
|
|
nouvelle tentative après un échec/une expiration
|
|
(`app/api/forms.py:retry_pending_payment`), sans jamais perdre les
|
|
informations déjà saisies.
|
|
|
|
`promoted_volunteer_id` : posé au moment du paiement confirmé (`PAID`) —
|
|
le `Volunteer` créé. Les champs nominatifs sont alors vidés (RGPD :
|
|
la même information vit désormais uniquement sur `Volunteer`), seuls
|
|
les champs de suivi du paiement restent. Une ligne jamais promue est
|
|
purgée après un délai (voir `_prune_stale_pending`)."""
|
|
|
|
__tablename__ = "pending_form_submission"
|
|
|
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
edition_id: uuid.UUID = Field(
|
|
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
|
)
|
|
|
|
first_name: str | None = None
|
|
last_name: str | None = None
|
|
email: str | None = None
|
|
phone: str | None = None
|
|
comment: str | None = None
|
|
companion_wish_note: str | None = None
|
|
available: bool = Field(
|
|
default=False,
|
|
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
|
)
|
|
position_wish_ids_json: str | None = None
|
|
artist_wish_ids_json: str | None = None
|
|
photo: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
|
photo_content_type: str | None = None
|
|
|
|
payment_label: str
|
|
payment_amount: float
|
|
requires_mecenat_info: bool = Field(
|
|
default=False,
|
|
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
|
)
|
|
mecenat_address: str | None = None
|
|
mecenat_city: str | None = None
|
|
mecenat_postal_code: str | None = None
|
|
mecenat_country: str | None = None
|
|
|
|
checkout_id: str | None = Field(default=None, index=True)
|
|
checkout_status: FeePaymentStatus | None = Field(
|
|
default=None,
|
|
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
|
|
)
|
|
promoted_volunteer_id: uuid.UUID | None = Field(
|
|
default=None, foreign_key="volunteer.id", ondelete="SET NULL"
|
|
)
|
|
|
|
|
|
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)
|
|
)
|