Étape 55 : écrans /mobile-benevole et /mobile-referent d'après la maquette (design/Planning bénévole mobile) — créneau en cours ou prochain mis en avant, planning par journée de festival avec frise horaire, compteurs et pointage du référent par créneau. Étape 56 : message de l'organisation (Edition.volunteer_message, onglet « Message aux bénévoles ») et message de chaque poste (Position.message, saisi par le référent), migration 0029, affichés sur l'écran bénévole. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
718 lines
29 KiB
Python
718 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
|
|
# Message de l'organisation, le même pour tous les bénévoles (écran
|
|
# bénévole) — saisi par la coordination. `_updated_at` : date affichée
|
|
# sous le message, posée par l'API à chaque changement de texte.
|
|
volunteer_message: str | None = None
|
|
volunteer_message_updated_at: dt.datetime | 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,
|
|
)
|
|
# Message du poste (consignes…), saisi par son référent ou la
|
|
# coordination, montré aux bénévoles affectés au poste.
|
|
message: str | None = None
|
|
message_updated_at: dt.datetime | None = None
|
|
|
|
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,
|
|
),
|
|
)
|
|
# Ordre d'empilement sur sa face : les champs sont dessinés du plus petit
|
|
# au plus grand, un champ recouvre donc ceux d'un `z_order` inférieur.
|
|
z_order: int = Field(
|
|
default=0, sa_column=Column(Integer, nullable=False, server_default="0")
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 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)
|
|
)
|