Recto personnalisable (fond image + champs positionnés : nom, poste(s)
affectés, catégorie, rôle) via un éditeur visuel glisser-déposer ; verso
fixe, la même image pour tous les bénévoles. Nouvel écran « Badges »
(COORDINATION uniquement, /badges).
Backend : modèle BadgeTemplate/BadgeField (migration 0011), génération
PDF isolée (app/badges/pdf.py, ReportLab — planches recto suivies des
planches verso correspondantes pour une impression recto-verso), route
POST /editions/{id}/badges/generate, tests (module pur + API).
Frontend : client HTTP (upload multipart, réponses binaires images/PDF),
client mock (mode démonstration), éditeur de positionnement en
millimètres (coordonnées partagées avec le backend), écran de génération
avec sélection des bénévoles et téléchargement.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
447 lines
13 KiB
Python
447 lines
13 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,
|
||
BadgeFieldType,
|
||
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
|
||
|
||
|
||
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]
|
||
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
|
||
fields: list[BadgeFieldRead]
|
||
|
||
|
||
class BadgeGenerateRequest(ApiModel):
|
||
# None/absent = tous les bénévoles de l'édition.
|
||
volunteer_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)
|