"""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, 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 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 # 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 # 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 # --------------------------------------------------------------------------- # # 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] 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/`, 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 de disponibilité déclarées, heures déjà affectées.""" date: dt.date shifts: int min_hours: float ideal_hours: float available_hours: float available_volunteers: int 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_availability: int volunteers_with_assignment: int class EditionStats(ApiModel): registrations: StatsRegistrations answers: StatsAnswers payments: StatsPayments artist_wishes: list[StatsArtistWish] needs: StatsNeeds