Files
benevoles/backend/app/api/schemas.py
T
AdamThomasandClaude Sonnet 5 40127a2252 Étape 22 : feuilles de poste (PDF), complément des badges
Une page par poste et par jour, listant les créneaux avec les bénévoles
affectés et leur téléphone — à imprimer et afficher sur place. Aucune
nouvelle entité : dérivé de Position/Shift/Assignment/Volunteer
existants (contrairement aux badges, pas de personnalisation à
configurer). Un créneau à besoin 0/0 sans affectation n'apparaît pas.

Backend : app/sheets/pdf.py (ReportLab, Platypus — SimpleDocTemplate/
Table plutôt que le canvas bas niveau des badges, plus adapté à un
rapport texte multi-pages), POST /editions/{id}/position-sheets/generate
(COORDINATION, {position_ids?} — absent = tous les postes).

Frontend : bouton « Feuilles de poste » dans la grille des besoins,
respecte le filtre de catégorie déjà actif à l'écran.

Vérifié : pytest (284 passés), ruff, alembic check, tsc --noEmit,
eslint, vite build, smoke (flux de bout en bout en mode démonstration)
— tous verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 21:27:01 +02:00

524 lines
15 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,
BadgeAlign,
BadgeFieldType,
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
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
self_declared_available: bool
willing_to_pay_fee: bool
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
# --------------------------------------------------------------------------- #
# Availability (disponibilités déclarées par le bénévole) #
# --------------------------------------------------------------------------- #
class AvailabilityWindowIn(ApiModel):
date: dt.date
start_time: dt.time
# end_time <= start_time ⇒ la fenêtre se termine le lendemain.
end_time: dt.time
class AvailabilitySet(ApiModel):
"""Remplace l'intégralité des disponibilités d'un bénévole."""
windows: list[AvailabilityWindowIn] = Field(default_factory=list)
class AvailabilityRead(ApiModel):
id: uuid.UUID
edition_id: uuid.UUID
volunteer_id: uuid.UUID
date: dt.date
start_time: dt.time
end_time: dt.time
created_at: dt.datetime
updated_at: dt.datetime
# --------------------------------------------------------------------------- #
# 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):
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"
class BadgeFieldsSet(ApiModel):
"""Remplace l'intégralité des champs du recto — au plus un par
`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]
class FormTemplateUpdate(ApiModel):
is_open: bool
class PublicPositionOption(ApiModel):
id: uuid.UUID
name: str
class PublicArtistOption(ApiModel):
id: uuid.UUID
name: str
performance_date: dt.date
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]
class FormSubmissionResult(ApiModel):
volunteer_id: uuid.UUID