"""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, 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 # --------------------------------------------------------------------------- # # 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] # --------------------------------------------------------------------------- # # 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)