"""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 # --------------------------------------------------------------------------- # # 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