Compétences supprimées de bout en bout :
- tables `skill`, `volunteer_skill`, `shift_required_skill` ;
- `Volunteer.skill_codes`, `Shift.required_skill_codes`, colonne `skills`
de l'import, règle de validation `MISSING_REQUIRED_SKILL`, jeu de démo.
Disponibilités repensées : `Availability` passe de (`day_part_id`,
`status` tri-état) à une **fenêtre horaire libre** (`date`, `start_time`,
`end_time` ; `end <= start` ⇒ passe minuit), plusieurs par jour.
- Nouveaux endpoints `GET`/`PUT /editions/{id}/volunteers/{vid}/availability`
(PUT = remplacement complet, COORDINATION) et
`GET /editions/{id}/availability` (toute l'édition, pour la grille).
- `OUTSIDE_AVAILABILITY` (toujours WARNING) : compare le créneau à l'union
des fenêtres du bénévole ce jour-là ; renvoie `date` + `uncovered_minutes`.
`DayPart` ne sert plus qu'à la borne de « journée » du cumul 8 h.
- L'import ne gère plus les disponibilités (fenêtres saisies dans l'app) :
`values.py` supprimé, mapping réduit à nom/email/téléphone/commentaire.
Migration 0007 (recrée `availability`). `AvailabilityStatus` supprimé.
158 tests, `alembic check` et `ruff` au vert. Front à suivre.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
90 lines
2.5 KiB
Python
90 lines
2.5 KiB
Python
"""Types d'entrée et de sortie du moteur de validation.
|
|
|
|
Découplé de la base : le moteur ne connaît que ces structures simples, jamais
|
|
les modèles SQLModel. La couche appelante construit l'entrée depuis la base.
|
|
"""
|
|
|
|
import datetime as dt
|
|
from dataclasses import dataclass
|
|
from enum import StrEnum
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class Severity(StrEnum):
|
|
BLOCKING = "BLOCKING"
|
|
WARNING = "WARNING"
|
|
|
|
|
|
class ViolationCode(StrEnum):
|
|
SHIFT_OVERLAP = "SHIFT_OVERLAP"
|
|
OUTSIDE_AVAILABILITY = "OUTSIDE_AVAILABILITY"
|
|
DAILY_HOURS_EXCEEDED = "DAILY_HOURS_EXCEEDED"
|
|
INSUFFICIENT_REST = "INSUFFICIENT_REST"
|
|
|
|
|
|
SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
|
|
ViolationCode.SHIFT_OVERLAP: Severity.BLOCKING,
|
|
ViolationCode.OUTSIDE_AVAILABILITY: Severity.WARNING,
|
|
ViolationCode.DAILY_HOURS_EXCEEDED: Severity.WARNING,
|
|
ViolationCode.INSUFFICIENT_REST: Severity.WARNING,
|
|
}
|
|
|
|
|
|
class Violation(BaseModel):
|
|
"""Une violation : un **code** + des paramètres structurés, jamais de phrase
|
|
(traduction via `locales/fr.json`, clés `validation.<CODE>`)."""
|
|
|
|
code: ViolationCode
|
|
severity: Severity
|
|
params: dict[str, str | int] = Field(default_factory=dict)
|
|
|
|
@classmethod
|
|
def of(cls, code: ViolationCode, **params: str | int) -> "Violation":
|
|
return cls(code=code, severity=SEVERITY_BY_CODE[code], params=params)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ShiftInfo:
|
|
id: str
|
|
starts_at: dt.datetime
|
|
ends_at: dt.datetime
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DayPartWindow:
|
|
"""Créneau type. Ne sert plus qu'à fixer la borne de « journée » pour le
|
|
cumul des 8 h (début du premier créneau type)."""
|
|
|
|
code: str
|
|
start_time: dt.time
|
|
# end_time <= start_time ⇒ la demi-journée passe minuit.
|
|
end_time: dt.time
|
|
order: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AvailabilityWindow:
|
|
"""« Le <date>, je peux de <start_time> à <end_time>. »
|
|
|
|
`end_time <= start_time` ⇒ la fenêtre se termine le lendemain.
|
|
"""
|
|
|
|
date: dt.date
|
|
start_time: dt.time
|
|
end_time: dt.time
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ValidationInput:
|
|
"""Tout ce qu'il faut pour valider une affectation candidate.
|
|
|
|
`existing_shifts` = les créneaux des autres affectations du même bénévole
|
|
(une affectation = une décision, il n'y a pas d'étape de confirmation).
|
|
"""
|
|
|
|
candidate_shift: ShiftInfo
|
|
existing_shifts: tuple[ShiftInfo, ...] = ()
|
|
availabilities: tuple[AvailabilityWindow, ...] = ()
|
|
day_parts: tuple[DayPartWindow, ...] = ()
|