Files
benevoles/backend/app/api/schemas.py
T
AdamThomasandClaude Opus 5.5 461742eb78
ci-cd / build-backend (push) Successful in 1m25s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 13s
Email de test et ajout manuel sans champ obligatoire
Étape 67 : adresse + bouton dans l'onglet « Messages » pour envoyer tout
de suite un email de test, résultat réel affiché (envoyé, désactivé,
connexion ou message refusé, réponse du serveur SMTP) —
POST .../notifications/test.

Étape 66 : prénom et nom facultatifs à l'ajout manuel d'un bénévole ;
un bénévole sans nom s'affiche « Sans nom » / « ? » partout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:34:24 +01:00

824 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
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
# --------------------------------------------------------------------------- #
# 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