La photo recouvrait le nom et le texte libre (champs dessinés dans l'ordre alphabétique de leur type). BadgeField.z_order (migration 0028, champs existants renumérotés photo dessous) fixe l'ordre de dessin du PDF et de l'aperçu ; boutons « Premier plan / Avancer / Reculer / Arrière-plan » dans l'inspecteur. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
751 lines
23 KiB
Python
751 lines
23 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,
|
||
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
|
||
created_at: dt.datetime
|
||
updated_at: dt.datetime
|
||
|
||
|
||
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
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Volunteer #
|
||
# --------------------------------------------------------------------------- #
|
||
class VolunteerCreate(ApiModel):
|
||
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
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 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
|
||
|
||
|
||
class PositionRead(ApiModel):
|
||
id: uuid.UUID
|
||
edition_id: uuid.UUID
|
||
name: str
|
||
location: str | None
|
||
category: PositionCategory
|
||
lead_account_id: uuid.UUID | 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
|
||
created_at: dt.datetime
|
||
updated_at: dt.datetime
|
||
|
||
|
||
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)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 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 EditionStats(ApiModel):
|
||
registrations: StatsRegistrations
|
||
answers: StatsAnswers
|
||
payments: StatsPayments
|
||
artist_wishes: list[StatsArtistWish]
|
||
needs: StatsNeeds
|