L'email de test reprend le modèle en cours d'édition, balises remplies avec le planning d'un bénévole au choix (premier avec un créneau par défaut), option lien de connexion sans vrai jeton, sujet « [Test] … » — même fonction de rendu que l'envoi réel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
836 lines
26 KiB
Python
836 lines
26 KiB
Python
"""Schémas d'entrée/sortie de l'API CRUD et de la matrice."""
|
||
|
||
import datetime as dt
|
||
import uuid
|
||
|
||
from pydantic import BaseModel, ConfigDict, Field
|
||
|
||
from app.models import (
|
||
AssignmentSource,
|
||
AttendanceStatus,
|
||
BadgeAlign,
|
||
BadgeFace,
|
||
BadgeFieldType,
|
||
BadgePhotoShape,
|
||
FeePaymentStatus,
|
||
FormFieldType,
|
||
PositionCategory,
|
||
Role,
|
||
)
|
||
from app.validation import Violation
|
||
|
||
|
||
class ApiModel(BaseModel):
|
||
"""Base commune : autorise la construction depuis un objet ORM."""
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Edition #
|
||
# --------------------------------------------------------------------------- #
|
||
class EditionRead(ApiModel):
|
||
id: uuid.UUID
|
||
year: int
|
||
name: str | None
|
||
start_date: dt.date
|
||
end_date: dt.date
|
||
retention_days: int | None
|
||
volunteer_message: str | None
|
||
volunteer_message_updated_at: dt.datetime | None
|
||
created_at: dt.datetime
|
||
updated_at: dt.datetime
|
||
|
||
|
||
# Plafond d'un message (organisation, poste) : quelques paragraphes, pas un
|
||
# document.
|
||
MESSAGE_MAX_LENGTH = 2000
|
||
|
||
|
||
class EditionUpdate(ApiModel):
|
||
name: str | None = None
|
||
year: int | None = None
|
||
start_date: dt.date | None = None
|
||
end_date: dt.date | None = None
|
||
retention_days: int | None = None
|
||
# Vide ou blanc = plus de message.
|
||
volunteer_message: str | None = Field(default=None, max_length=MESSAGE_MAX_LENGTH)
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Volunteer #
|
||
# --------------------------------------------------------------------------- #
|
||
class VolunteerCreate(ApiModel):
|
||
# Facultatifs à l'ajout manuel par la coordination (fiche complétée plus
|
||
# tard) ; le formulaire public et l'import les exigent toujours.
|
||
first_name: str = ""
|
||
last_name: str = ""
|
||
email: str | None = None
|
||
phone: str | None = None
|
||
comment: str | None = None
|
||
# Vœux du bénévole : artistes à voir, postes souhaités, bénévoles avec
|
||
# qui il voudrait être affecté (à sens unique, 0 à N).
|
||
artist_ids: list[uuid.UUID] = Field(default_factory=list)
|
||
wished_position_ids: list[uuid.UUID] = Field(default_factory=list)
|
||
wished_companion_ids: list[uuid.UUID] = Field(default_factory=list)
|
||
|
||
|
||
class VolunteerUpdate(ApiModel):
|
||
first_name: str | None = None
|
||
last_name: str | None = None
|
||
email: str | None = None
|
||
phone: str | None = None
|
||
comment: str | None = None
|
||
# None = inchangé ; [] = vider. Idem pour artist_ids / wished_position_ids
|
||
# / wished_companion_ids.
|
||
artist_ids: list[uuid.UUID] | None = None
|
||
wished_position_ids: list[uuid.UUID] | None = None
|
||
wished_companion_ids: list[uuid.UUID] | None = None
|
||
# Issus du formulaire public (`app/api/forms.py`), éditables ensuite
|
||
# depuis la fiche — voir CLAUDE.md § 2 (Volunteer).
|
||
companion_wish_note: str | None = None
|
||
# Texte libre du badge (champ `FREE_TEXT`), saisi par la coordination.
|
||
badge_text: str | None = None
|
||
# Exemplaires du badge sur les planches (1 à 100 ; garde-fou contre une
|
||
# faute de frappe qui imprimerait des centaines de pages).
|
||
badge_count: int | None = Field(default=None, ge=1, le=100)
|
||
self_declared_available: bool | None = None
|
||
willing_to_pay_fee: bool | None = None
|
||
|
||
|
||
class VolunteerRead(ApiModel):
|
||
id: uuid.UUID
|
||
edition_id: uuid.UUID
|
||
first_name: str
|
||
last_name: str
|
||
email: str | None
|
||
phone: str | None
|
||
comment: str | None
|
||
artist_ids: list[uuid.UUID]
|
||
wished_position_ids: list[uuid.UUID]
|
||
wished_companion_ids: list[uuid.UUID]
|
||
has_photo: bool
|
||
companion_wish_note: str | None
|
||
badge_text: str | None
|
||
badge_count: int
|
||
self_declared_available: bool
|
||
willing_to_pay_fee: bool
|
||
# Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) —
|
||
# `fee_payment_label`/`fee_amount` : copie figée du type de paiement
|
||
# choisi (pas la config actuelle, qui peut avoir changé depuis).
|
||
# `fee_payment_status` reste `None` tant qu'aucun checkout n'a jamais été
|
||
# créé pour ce bénévole.
|
||
fee_payment_label: str | None
|
||
fee_amount: float | None
|
||
fee_payment_status: FeePaymentStatus | None
|
||
fee_paid_at: dt.datetime | None
|
||
# Transaction SumUp ayant réglé la cotisation (code du tableau de bord
|
||
# SumUp + identifiant technique) — `None` tant que non payée.
|
||
fee_transaction_code: str | None
|
||
fee_transaction_id: str | None
|
||
# Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un
|
||
# type de paiement « mécénat » (voir `FormPaymentOption.requires_mecenat_info`).
|
||
mecenat_address: str | None
|
||
mecenat_city: str | None
|
||
mecenat_postal_code: str | None
|
||
mecenat_country: str | None
|
||
created_at: dt.datetime
|
||
updated_at: dt.datetime
|
||
|
||
|
||
class VolunteerPlanningEntry(ApiModel):
|
||
"""Un créneau affecté, enrichi (poste, lieu, référent à contacter) — pour
|
||
l'écran bénévole (`GET .../volunteers/{id}/planning`), sans jamais
|
||
exposer la liste complète des postes/créneaux de l'édition à un compte
|
||
VOLUNTEER (lecture restreinte à sa propre fiche, cf. § 2 matrice)."""
|
||
|
||
assignment_id: uuid.UUID
|
||
shift_id: uuid.UUID
|
||
starts_at: dt.datetime
|
||
ends_at: dt.datetime
|
||
position_name: str
|
||
position_location: str | None
|
||
# None si le poste n'a pas (encore) de référent, ou si son compte n'est
|
||
# pas rattaché à une fiche bénévole (pas de téléphone à donner alors).
|
||
referent_name: str | None
|
||
referent_phone: str | None
|
||
# Message du poste (voir `Position.message`).
|
||
position_message: str | None
|
||
position_message_updated_at: dt.datetime | None
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Artist (programmation) #
|
||
# --------------------------------------------------------------------------- #
|
||
class ArtistCreate(ApiModel):
|
||
name: str
|
||
performance_date: dt.date
|
||
# end_time <= start_time ⇒ le passage finit le lendemain.
|
||
start_time: dt.time
|
||
end_time: dt.time
|
||
|
||
|
||
class ArtistUpdate(ApiModel):
|
||
name: str | None = None
|
||
performance_date: dt.date | None = None
|
||
start_time: dt.time | None = None
|
||
end_time: dt.time | None = None
|
||
|
||
|
||
class ArtistRead(ApiModel):
|
||
id: uuid.UUID
|
||
edition_id: uuid.UUID
|
||
name: str
|
||
performance_date: dt.date
|
||
start_time: dt.time
|
||
end_time: dt.time
|
||
created_at: dt.datetime
|
||
updated_at: dt.datetime
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Position #
|
||
# --------------------------------------------------------------------------- #
|
||
class PositionCreate(ApiModel):
|
||
name: str
|
||
location: str | None = None
|
||
category: PositionCategory
|
||
lead_account_id: uuid.UUID | None = None
|
||
|
||
|
||
class PositionUpdate(ApiModel):
|
||
name: str | None = None
|
||
location: str | None = None
|
||
category: PositionCategory | None = None
|
||
lead_account_id: uuid.UUID | None = None
|
||
# Vide ou blanc = plus de message.
|
||
message: str | None = Field(default=None, max_length=MESSAGE_MAX_LENGTH)
|
||
|
||
|
||
class PositionRead(ApiModel):
|
||
id: uuid.UUID
|
||
edition_id: uuid.UUID
|
||
name: str
|
||
location: str | None
|
||
category: PositionCategory
|
||
lead_account_id: uuid.UUID | None
|
||
message: str | None
|
||
message_updated_at: dt.datetime | None
|
||
created_at: dt.datetime
|
||
updated_at: dt.datetime
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Shift #
|
||
# --------------------------------------------------------------------------- #
|
||
class ShiftCreate(ApiModel):
|
||
position_id: uuid.UUID
|
||
starts_at: dt.datetime
|
||
ends_at: dt.datetime
|
||
min_staff: int = Field(default=0, ge=0)
|
||
ideal_staff: int = Field(default=0, ge=0)
|
||
|
||
|
||
class ShiftUpdate(ApiModel):
|
||
position_id: uuid.UUID | None = None
|
||
starts_at: dt.datetime | None = None
|
||
ends_at: dt.datetime | None = None
|
||
min_staff: int | None = Field(default=None, ge=0)
|
||
ideal_staff: int | None = Field(default=None, ge=0)
|
||
|
||
|
||
class ShiftRead(ApiModel):
|
||
id: uuid.UUID
|
||
edition_id: uuid.UUID
|
||
position_id: uuid.UUID
|
||
day_part_id: uuid.UUID | None
|
||
starts_at: dt.datetime
|
||
ends_at: dt.datetime
|
||
min_staff: int
|
||
ideal_staff: int
|
||
created_at: dt.datetime
|
||
updated_at: dt.datetime
|
||
|
||
|
||
class GenerateShiftsResult(ApiModel):
|
||
created: int
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# DayPart (créneau type) #
|
||
# --------------------------------------------------------------------------- #
|
||
class DayPartRead(ApiModel):
|
||
id: uuid.UUID
|
||
edition_id: uuid.UUID
|
||
code: str
|
||
label: str
|
||
start_time: dt.time
|
||
end_time: dt.time
|
||
order: int
|
||
created_at: dt.datetime
|
||
updated_at: dt.datetime
|
||
|
||
|
||
class DayPartCreate(ApiModel):
|
||
label: str
|
||
start_time: dt.time
|
||
end_time: dt.time
|
||
# Déduit du label si absent. Immuable ensuite (référencé par l'import).
|
||
code: str | None = None
|
||
order: int = 0
|
||
|
||
|
||
class DayPartUpdate(ApiModel):
|
||
label: str | None = None
|
||
start_time: dt.time | None = None
|
||
end_time: dt.time | None = None
|
||
order: int | None = None
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Assignment #
|
||
# --------------------------------------------------------------------------- #
|
||
class AssignmentCreate(ApiModel):
|
||
volunteer_id: uuid.UUID
|
||
shift_id: uuid.UUID
|
||
source: AssignmentSource = AssignmentSource.MANUAL
|
||
locked: bool = False
|
||
|
||
|
||
class AssignmentUpdate(ApiModel):
|
||
source: AssignmentSource | None = None
|
||
locked: bool | None = None
|
||
|
||
|
||
class AssignmentRead(ApiModel):
|
||
id: uuid.UUID
|
||
edition_id: uuid.UUID
|
||
volunteer_id: uuid.UUID
|
||
shift_id: uuid.UUID
|
||
source: AssignmentSource
|
||
locked: bool
|
||
attendance: AttendanceStatus | None
|
||
attendance_marked_at: dt.datetime | None
|
||
created_at: dt.datetime
|
||
updated_at: dt.datetime
|
||
|
||
|
||
class AttendanceUpdate(ApiModel):
|
||
# `None` = annuler le pointage.
|
||
attendance: AttendanceStatus | None
|
||
|
||
|
||
class AssignmentWriteResult(ApiModel):
|
||
assignment: AssignmentRead
|
||
warnings: list[Violation] = Field(default_factory=list)
|
||
|
||
|
||
class AssignmentValidateRequest(ApiModel):
|
||
volunteer_id: uuid.UUID
|
||
shift_id: uuid.UUID
|
||
|
||
|
||
class AssignmentValidateResult(ApiModel):
|
||
blocking: bool
|
||
violations: list[Violation] = Field(default_factory=list)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Solveur (suggestions, CP-SAT) — ne place jamais personne #
|
||
# --------------------------------------------------------------------------- #
|
||
class SolverSuggestion(ApiModel):
|
||
shift_id: uuid.UUID
|
||
# Bénévoles suggérés pour ce créneau, meilleur en tête.
|
||
volunteer_ids: list[uuid.UUID]
|
||
|
||
|
||
class SolverRunResult(ApiModel):
|
||
suggestions: list[SolverSuggestion]
|
||
shifts_considered: int
|
||
volunteers_considered: int
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Matrice poste × créneau #
|
||
# --------------------------------------------------------------------------- #
|
||
class MatrixShift(ApiModel):
|
||
shift_id: uuid.UUID
|
||
starts_at: dt.datetime
|
||
ends_at: dt.datetime
|
||
min_staff: int
|
||
ideal_staff: int
|
||
assigned: int
|
||
|
||
|
||
class MatrixPosition(ApiModel):
|
||
id: uuid.UUID
|
||
name: str
|
||
category: PositionCategory
|
||
location: str | None
|
||
shifts: list[MatrixShift]
|
||
|
||
|
||
class MatrixResponse(ApiModel):
|
||
edition_id: uuid.UUID
|
||
positions: list[MatrixPosition]
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Badges (planches PDF) #
|
||
# --------------------------------------------------------------------------- #
|
||
class BadgeFieldIn(ApiModel):
|
||
face: BadgeFace = BadgeFace.FRONT
|
||
field_type: BadgeFieldType
|
||
x_mm: float
|
||
y_mm: float
|
||
width_mm: float
|
||
height_mm: float
|
||
font_size: float = 10.0
|
||
align: BadgeAlign = BadgeAlign.LEFT
|
||
bold: bool = False
|
||
color: str = "#000000"
|
||
# `FREE_TEXT` seulement (voir `BadgeField.text`), ignoré sinon.
|
||
text: str | None = None
|
||
# Fond de la zone (`#rrggbb`), `None` = transparent — voir `BadgeField`.
|
||
bg_color: str | None = None
|
||
auto_fit: bool = False
|
||
# `PHOTO` seulement, ignoré sinon.
|
||
photo_shape: BadgePhotoShape = BadgePhotoShape.RECT
|
||
# Ordre d'empilement sur la face (voir `BadgeField.z_order`).
|
||
z_order: int = 0
|
||
|
||
|
||
class BadgeFieldsSet(ApiModel):
|
||
"""Remplace l'intégralité des champs du recto et du verso — au plus un
|
||
par (`face`, `field_type`) (décocher un champ dans l'éditeur retire sa
|
||
ligne)."""
|
||
|
||
fields: list[BadgeFieldIn] = Field(default_factory=list)
|
||
|
||
|
||
class BadgeFieldRead(BadgeFieldIn):
|
||
id: uuid.UUID
|
||
edition_id: uuid.UUID
|
||
|
||
|
||
class BadgeTemplateRead(ApiModel):
|
||
edition_id: uuid.UUID
|
||
has_front_image: bool
|
||
has_back_image: bool
|
||
# Dérivée de l'image du recto (voir `app/badges/pdf.py:image_size_mm`) ;
|
||
# format CR80 par défaut tant qu'aucune image n'est importée.
|
||
badge_width_mm: float
|
||
badge_height_mm: float
|
||
fields: list[BadgeFieldRead]
|
||
|
||
|
||
class BadgeSizeUpdate(ApiModel):
|
||
"""Ajuste la taille du badge indépendamment d'un nouvel import d'image —
|
||
ex. agrandir proportionnellement une image importée trop petite (voir
|
||
`app/api/badges.py:set_size`). Écrase la taille déduite de l'image."""
|
||
|
||
badge_width_mm: float
|
||
badge_height_mm: float
|
||
|
||
|
||
class BadgeGenerateRequest(ApiModel):
|
||
# None/absent = tous les bénévoles de l'édition.
|
||
volunteer_ids: list[uuid.UUID] | None = None
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Feuilles de poste (PDF) #
|
||
# --------------------------------------------------------------------------- #
|
||
class PositionSheetsRequest(ApiModel):
|
||
# None/absent = tous les postes de l'édition.
|
||
position_ids: list[uuid.UUID] | None = None
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Authentification et comptes #
|
||
# --------------------------------------------------------------------------- #
|
||
class MagicLinkRequest(ApiModel):
|
||
email: str
|
||
|
||
|
||
class VerifyRequest(ApiModel):
|
||
token: str
|
||
|
||
|
||
class LoginRequest(ApiModel):
|
||
email: str
|
||
password: str
|
||
|
||
|
||
class ChangePasswordRequest(ApiModel):
|
||
current_password: str
|
||
new_password: str
|
||
|
||
|
||
class AccountRead(ApiModel):
|
||
id: uuid.UUID
|
||
edition_id: uuid.UUID
|
||
email: str
|
||
role: Role
|
||
volunteer_id: uuid.UUID | None
|
||
# Mode de connexion : "password" (admins) ou "magic_link".
|
||
auth_method: str
|
||
created_at: dt.datetime
|
||
updated_at: dt.datetime
|
||
|
||
|
||
class TokenResponse(ApiModel):
|
||
access_token: str
|
||
token_type: str
|
||
account: AccountRead
|
||
|
||
|
||
class AccountCreate(ApiModel):
|
||
email: str
|
||
role: Role
|
||
volunteer_id: uuid.UUID | None = None
|
||
# Requis pour un compte COORDINATION, interdit sinon.
|
||
password: str | None = None
|
||
|
||
|
||
class AccountUpdate(ApiModel):
|
||
# Seul le rôle est modifiable, et seulement entre LEAD et VOLUNTEER (pas
|
||
# de mot de passe en jeu) — voir `api/accounts.py:update_account`.
|
||
role: Role | None = None
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Notifications par email #
|
||
# --------------------------------------------------------------------------- #
|
||
class PlanningNotificationRequest(ApiModel):
|
||
# Modèle saisi par la coordination. Variables : {prenom}, {nom},
|
||
# {nb_creneaux}, {nb_heures}, {creneaux}, {lien_connexion}.
|
||
subject: str | None = None
|
||
body: str | None = None
|
||
# None = tous les bénévoles de l'édition qui ont un email.
|
||
volunteer_ids: list[uuid.UUID] | None = None
|
||
|
||
|
||
class PlanningNotificationResult(ApiModel):
|
||
queued: int
|
||
skipped_no_email: int
|
||
recipients: list[uuid.UUID] = Field(default_factory=list)
|
||
|
||
|
||
class TestEmailRequest(ApiModel):
|
||
email: str
|
||
# Modèle du récapitulatif de planning en cours d'édition (mêmes
|
||
# variables que `PlanningNotificationRequest`) ; absent = modèle par
|
||
# défaut.
|
||
subject: str | None = None
|
||
body: str | None = None
|
||
# Bénévole dont les données remplissent les variables ; absent = le
|
||
# premier (par nom) qui a au moins un créneau.
|
||
volunteer_id: uuid.UUID | None = None
|
||
|
||
|
||
class TestEmailResult(ApiModel):
|
||
"""`status` : `sent` / `disabled` / `connection_failed` / `rejected` —
|
||
voir `app/email/sender.py:deliver_test`. `detail` : message du serveur
|
||
SMTP en cas d'échec."""
|
||
|
||
status: str
|
||
detail: str | None = None
|
||
smtp_host: str
|
||
smtp_from: str
|
||
# Bénévole dont les données ont rempli le modèle (`None` : exemple
|
||
# fictif, l'édition n'a encore aucun bénévole).
|
||
example_volunteer_id: uuid.UUID | None = None
|
||
example_volunteer_name: str
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Formulaire public d'inscription #
|
||
# --------------------------------------------------------------------------- #
|
||
class FormFieldsSet(ApiModel):
|
||
"""Remplace l'intégralité des champs activés du formulaire — nom/prénom
|
||
toujours collectés, pas dans cette liste (voir `FormFieldType`)."""
|
||
|
||
fields: list[FormFieldType] = Field(default_factory=list)
|
||
|
||
|
||
class FormTemplateRead(ApiModel):
|
||
edition_id: uuid.UUID
|
||
is_open: bool
|
||
fields: list[FormFieldType]
|
||
has_logo: bool
|
||
description: str | None
|
||
sumup_merchant_code: str | None
|
||
# `sumup_api_key` (secret) n'est jamais renvoyé — seulement s'il est
|
||
# défini, comme `has_logo` pour le logo.
|
||
sumup_api_key_set: bool
|
||
# Secret nécessaire pour atteindre le formulaire public
|
||
# (`/inscription/<access_token>`, voir CLAUDE.md § 2, `FormTemplate`) —
|
||
# exposé seulement ici (COORDINATION), jamais par `PublicFormConfig`.
|
||
access_token: str
|
||
|
||
|
||
class FormTemplateUpdate(ApiModel):
|
||
"""`None`/absent = inchangé pour chaque champ (`exclude_unset`) — permet
|
||
de changer `is_open` sans toucher `description`/`sumup_*`, etc. Chaîne
|
||
vide sur `sumup_api_key` = efface le secret enregistré."""
|
||
|
||
is_open: bool | None = None
|
||
description: str | None = None
|
||
sumup_merchant_code: str | None = None
|
||
sumup_api_key: str | None = None
|
||
|
||
|
||
class FormPaymentOptionRead(ApiModel):
|
||
id: uuid.UUID
|
||
edition_id: uuid.UUID
|
||
label: str
|
||
amount: float
|
||
order: int
|
||
requires_mecenat_info: bool
|
||
free_amount: bool
|
||
created_at: dt.datetime
|
||
updated_at: dt.datetime
|
||
|
||
|
||
class FormPaymentOptionCreate(ApiModel):
|
||
label: str
|
||
amount: float
|
||
order: int = 0
|
||
requires_mecenat_info: bool = False
|
||
free_amount: bool = False
|
||
|
||
|
||
class FormPaymentOptionUpdate(ApiModel):
|
||
label: str | None = None
|
||
amount: float | None = None
|
||
order: int | None = None
|
||
requires_mecenat_info: bool | None = None
|
||
free_amount: bool | None = None
|
||
|
||
|
||
class PublicPositionOption(ApiModel):
|
||
id: uuid.UUID
|
||
name: str
|
||
|
||
|
||
class PublicArtistOption(ApiModel):
|
||
id: uuid.UUID
|
||
name: str
|
||
performance_date: dt.date
|
||
|
||
|
||
class PublicPaymentOption(ApiModel):
|
||
"""Type de paiement proposé au bénévole sur le formulaire public — un
|
||
montant n'est pas un secret, contrairement aux identifiants SumUp."""
|
||
|
||
id: uuid.UUID
|
||
label: str
|
||
# Montant fixe, ou minimum si `free_amount`.
|
||
amount: float
|
||
requires_mecenat_info: bool
|
||
free_amount: bool
|
||
|
||
|
||
class PublicFormConfig(ApiModel):
|
||
"""Tout ce qu'il faut pour afficher le formulaire public — jamais plus :
|
||
ni les bénévoles déjà inscrits, ni les créneaux/affectations de
|
||
l'édition (voir `app/api/forms.py:get_public_form`)."""
|
||
|
||
is_open: bool
|
||
fields: list[FormFieldType]
|
||
positions: list[PublicPositionOption]
|
||
artists: list[PublicArtistOption]
|
||
has_logo: bool
|
||
description: str | None
|
||
# `True` si l'édition a configuré SumUp (identifiants marchand + au
|
||
# moins un type de paiement) — utilisé par le front-end pour afficher le
|
||
# choix de paiement près de `WILLING_TO_PAY_FEE`, sans jamais exposer les
|
||
# identifiants eux-mêmes.
|
||
sumup_enabled: bool
|
||
payment_options: list[PublicPaymentOption]
|
||
|
||
|
||
class FormSubmissionResult(ApiModel):
|
||
"""Trois formes possibles, selon le choix de paiement (voir
|
||
`app/api/forms.py:submit_public_form`) :
|
||
- aucun paiement en ligne choisi (case désactivée, ou « je paierai sur
|
||
place ») : `volunteer_id` seul, le bénévole est déjà en base ;
|
||
- paiement en ligne, type « mécénat » : `pending_token` +
|
||
`requires_mecenat_info=True`, rien en base encore — direction l'étape
|
||
d'adresse (`submit_mecenat_info`) ;
|
||
- paiement en ligne, type simple : `pending_token` + `sumup_checkout_url`,
|
||
direction la page de paiement SumUp. Toujours rien en base tant que le
|
||
paiement n'est pas confirmé (voir `PendingFormSubmission`)."""
|
||
|
||
volunteer_id: uuid.UUID | None = None
|
||
pending_token: uuid.UUID | None = None
|
||
requires_mecenat_info: bool = False
|
||
sumup_checkout_url: str | None = None
|
||
|
||
|
||
class MecenatInfoSubmit(ApiModel):
|
||
"""Étape d'adresse postale pour un type de paiement « mécénat »
|
||
(`POST .../form/public/pending/{token}/mecenat-info`) — tous obligatoires,
|
||
nécessaires à l'édition d'un reçu fiscal (cerfa)."""
|
||
|
||
address: str
|
||
city: str
|
||
postal_code: str
|
||
country: str
|
||
|
||
|
||
class PendingSubmissionStatus(ApiModel):
|
||
"""Réponse de `GET .../form/public/pending/{token}` — interrogée en
|
||
direct par la page de confirmation jusqu'à ce que le statut ne soit plus
|
||
`PENDING`. Relit toujours l'état réel auprès de SumUp (jamais seulement
|
||
la dernière valeur connue), comme le webhook."""
|
||
|
||
status: FeePaymentStatus
|
||
label: str
|
||
amount: float
|
||
volunteer_id: uuid.UUID | None = None
|
||
|
||
|
||
class SumUpWebhookPayload(ApiModel):
|
||
"""Corps envoyé par SumUp à `return_url` — voir la doc SumUp sur les
|
||
webhooks. Le seul événement existant à ce jour."""
|
||
|
||
event_type: str
|
||
id: str
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Statistiques d'une édition (`GET /editions/{id}/stats`, app/api/stats.py) #
|
||
# --------------------------------------------------------------------------- #
|
||
class StatsRegistrationDay(ApiModel):
|
||
date: dt.date
|
||
count: int
|
||
cumulative: int
|
||
|
||
|
||
class StatsRegistrations(ApiModel):
|
||
"""Bénévoles en base, par jour d'ajout (`Volunteer.created_at`) — jours
|
||
sans inscription inclus (à 0), pour une courbe sans trou."""
|
||
|
||
total: int
|
||
last_7_days: int
|
||
by_day: list[StatsRegistrationDay]
|
||
|
||
|
||
class StatsAnswers(ApiModel):
|
||
"""Nombre de bénévoles ayant renseigné chaque information (sur `total`)."""
|
||
|
||
total: int
|
||
email: int
|
||
phone: int
|
||
photo: int
|
||
comment: int
|
||
companion_note: int
|
||
available: int
|
||
position_wishes: int
|
||
artist_wishes: int
|
||
companion_wishes: int
|
||
|
||
|
||
class StatsPaymentOption(ApiModel):
|
||
label: str
|
||
mecenat: bool
|
||
count: int
|
||
amount_total: float
|
||
|
||
|
||
class StatsPayments(ApiModel):
|
||
"""Mode de paiement (même règle que `lib/fee.ts:feeMode`) : `online`
|
||
(paiement en ligne confirmé), `on_site`, `none`. `pending_unfinished` :
|
||
formulaires remplis dont le paiement en ligne n'a jamais abouti — aucun
|
||
bénévole créé pour eux (`PendingFormSubmission` non promue)."""
|
||
|
||
online: int
|
||
on_site: int
|
||
none: int
|
||
pending_unfinished: int
|
||
total_collected: float
|
||
mecenat_count: int
|
||
mecenat_total: float
|
||
by_option: list[StatsPaymentOption]
|
||
|
||
|
||
class StatsArtistWish(ApiModel):
|
||
id: uuid.UUID
|
||
name: str
|
||
date: dt.date
|
||
count: int
|
||
|
||
|
||
class StatsNeedDay(ApiModel):
|
||
"""Par journée de festival (`validation/engine.py:festival_day`) : heures
|
||
× personnes demandées (effectif minimal / idéal), heures déjà affectées."""
|
||
|
||
date: dt.date
|
||
shifts: int
|
||
min_hours: float
|
||
ideal_hours: float
|
||
assigned_hours: float
|
||
|
||
|
||
class StatsNeedPosition(ApiModel):
|
||
"""Places (personnes × créneaux) demandées et pourvues pour un poste, et
|
||
nombre de bénévoles qui l'ont mis dans leurs vœux."""
|
||
|
||
position_id: uuid.UUID
|
||
name: str
|
||
shifts: int
|
||
min_slots: int
|
||
ideal_slots: int
|
||
assigned: int
|
||
# Places pourvues, plafonnées créneau par créneau à l'effectif idéal : un
|
||
# sureffectif ne compense jamais un trou ailleurs.
|
||
filled: int
|
||
wishes: int
|
||
|
||
|
||
class StatsNeeds(ApiModel):
|
||
by_day: list[StatsNeedDay]
|
||
by_position: list[StatsNeedPosition]
|
||
volunteers_with_assignment: int
|
||
|
||
|
||
class StatsAttendanceCounts(ApiModel):
|
||
"""Pointage (`Assignment.attendance`) d'un ensemble d'affectations :
|
||
`assigned` = `present` + `absent` + `unmarked`."""
|
||
|
||
assigned: int = 0
|
||
present: int = 0
|
||
absent: int = 0
|
||
unmarked: int = 0
|
||
|
||
|
||
class StatsAttendancePosition(ApiModel):
|
||
position_id: uuid.UUID
|
||
name: str
|
||
# Créneaux terminés (`ends_at <= as_of`) et créneaux en cours.
|
||
past: StatsAttendanceCounts
|
||
live: StatsAttendanceCounts
|
||
live_shifts: int
|
||
|
||
|
||
class StatsAttendance(ApiModel):
|
||
"""Présents / absents pointés par les référents, par poste : sur les
|
||
créneaux déjà terminés et sur ceux en cours à `as_of` (heure du calcul,
|
||
locale). Les créneaux à venir ne comptent pas."""
|
||
|
||
as_of: dt.datetime
|
||
past: StatsAttendanceCounts
|
||
live: StatsAttendanceCounts
|
||
live_shifts: int
|
||
by_position: list[StatsAttendancePosition]
|
||
|
||
|
||
class EditionStats(ApiModel):
|
||
registrations: StatsRegistrations
|
||
answers: StatsAnswers
|
||
payments: StatsPayments
|
||
artist_wishes: list[StatsArtistWish]
|
||
needs: StatsNeeds
|
||
attendance: StatsAttendance
|