Files
benevoles/backend/app/api/schemas.py
T
AdamThomasandClaude Sonnet 5 8fcc22115f Gestion des référents depuis la fiche bénévole
Un poste avait bien un `lead_account_id`, lu par l'écran référent
(`/mobile-referent`) et par les droits d'écriture (`ensure_can_write_position`),
mais rien ne permettait de le renseigner : aucun écran ne l'exposait, et
l'API ne validait même pas que la valeur envoyée désignait un compte référent
existant de la bonne édition.

Backend :
- `PATCH /editions/{id}/accounts/{account_id}` (nouveau) : change le `role`
  d'un compte, seulement entre LEAD et VOLUNTEER (lien magique dans les deux
  cas) — 422 `account.role_change_not_allowed` dès que COORDINATOR est
  impliqué (mot de passe en jeu, jamais touché ici). C'est ce qui permet de
  faire passer le compte d'un bénévole en référent.
- `POST`/`PATCH /positions` : `lead_account_id` vérifié — doit désigner un
  compte `Role.LEAD` de la même édition, sinon 422
  `position.lead_must_be_referent_account` (silencieusement accepté
  jusqu'ici, y compris un compte d'une autre édition ou du mauvais rôle).

Frontend (`ConfigVolunteers.tsx`) : nouvelle section « Référent des postes »
sur la fiche du bénévole, juste après l'accès de connexion — mêmes badges à
cocher que les vœux de poste/artiste. Cocher un poste crée ou met à niveau
(LEAD) l'accès de connexion du bénévole automatiquement ; décocher retire
seulement le poste, sans jamais rétrograder l'accès. Un bénévole dont le
compte est déjà COORDINATOR ne peut pas être désigné référent (il a déjà
accès à tout). Miroir complet côté mock (`accounts.update`,
`checkLeadAccount`) pour que la démonstration se comporte pareil.

Vérifié : 204 tests backend pertinents (14 nouveaux : 6 sur `PATCH accounts`,
8 sur la validation `lead_account_id`), ruff, alembic check ; typecheck/
lint/build/smoke frontend ; parcours complet testé en direct sur le jeu de
démo (création de compte, promotion LEAD, rejet COORDINATOR, affectation
référent) avant nettoyage des données de test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-15 23:19:56 +02:00

388 lines
11 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,
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)