- app/seed.py, exécutable via python -m app.seed, idempotent et déterministe (graine fixe), sans donnée nominative réelle - Édition « Festival des Lucioles » 2026 : 120 bénévoles, 15 postes, 4 jours (montage/festival/démontage), 3 demi-journées, 8 compétences, ~100 créneaux, ~280 affectations actives, disponibilités tri-état - ~12 créneaux volontairement sous l'effectif minimal (trous d'effectif) - 3 affectations à avertissement (compétence absente, hors disponibilité, repos insuffisant), 0 violation bloquante - Passe de vérification finale via le moteur de validation : le résumé imprime le décompte des avertissements par règle et le nombre de bloquantes - covered_slots rendue publique dans validation.engine (réutilisée par le seed) - 2 tests (dataset réaliste, déterminisme/idempotence) ; 106 tests au total Fin du premier incrément backend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
193 lines
6.8 KiB
Python
193 lines
6.8 KiB
Python
"""Moteur de validation des affectations — cœur du produit.
|
|
|
|
Fonction pure : une `ValidationInput` en entrée, une liste de `Violation` en
|
|
sortie. Règles et sémantique figées : voir CLAUDE.md § 3.
|
|
"""
|
|
|
|
import datetime as dt
|
|
|
|
from app.models import AvailabilityStatus
|
|
from app.validation.models import (
|
|
Severity,
|
|
ShiftInfo,
|
|
ValidationInput,
|
|
Violation,
|
|
ViolationCode,
|
|
)
|
|
|
|
_EIGHT_HOURS = dt.timedelta(hours=8)
|
|
|
|
|
|
def validate(data: ValidationInput) -> list[Violation]:
|
|
violations: list[Violation] = []
|
|
violations.extend(_check_overlap(data))
|
|
violations.extend(_check_outside_availability(data))
|
|
violations.extend(_check_daily_hours(data))
|
|
violations.extend(_check_insufficient_rest(data))
|
|
violations.extend(_check_missing_skills(data))
|
|
return violations
|
|
|
|
|
|
def has_blocking(violations: list[Violation]) -> bool:
|
|
return any(v.severity is Severity.BLOCKING for v in violations)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# SHIFT_OVERLAP (bloquante) #
|
|
# --------------------------------------------------------------------------- #
|
|
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
|
|
# Bornes strictes : deux créneaux jointifs (fin == début) ne se chevauchent
|
|
# pas.
|
|
return a.starts_at < b.ends_at and b.starts_at < a.ends_at
|
|
|
|
|
|
def _check_overlap(data: ValidationInput) -> list[Violation]:
|
|
candidate = data.candidate_shift
|
|
return [
|
|
Violation.of(
|
|
ViolationCode.SHIFT_OVERLAP,
|
|
other_shift_id=other.id,
|
|
other_starts_at=other.starts_at.isoformat(),
|
|
other_ends_at=other.ends_at.isoformat(),
|
|
)
|
|
for other in data.existing_shifts
|
|
if _overlaps(candidate, other)
|
|
]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# OUTSIDE_AVAILABILITY (avertissement) #
|
|
# --------------------------------------------------------------------------- #
|
|
def covered_slots(
|
|
shift: ShiftInfo, day_parts: tuple
|
|
) -> list[tuple[dt.date, str]]:
|
|
"""Demi-journées (date, code) dont la fenêtre horaire recoupe le créneau."""
|
|
if not day_parts:
|
|
return []
|
|
slots: list[tuple[dt.date, str]] = []
|
|
day = shift.starts_at.date() - dt.timedelta(days=1)
|
|
last = shift.ends_at.date()
|
|
while day <= last:
|
|
for part in day_parts:
|
|
window_start = dt.datetime.combine(day, part.start_time)
|
|
if part.end_time > part.start_time:
|
|
window_end = dt.datetime.combine(day, part.end_time)
|
|
else:
|
|
window_end = dt.datetime.combine(
|
|
day + dt.timedelta(days=1), part.end_time
|
|
)
|
|
if shift.starts_at < window_end and window_start < shift.ends_at:
|
|
slots.append((day, part.code))
|
|
day += dt.timedelta(days=1)
|
|
return slots
|
|
|
|
|
|
def _check_outside_availability(data: ValidationInput) -> list[Violation]:
|
|
slots = covered_slots(data.candidate_shift, data.day_parts)
|
|
if not slots:
|
|
return []
|
|
declared = {
|
|
(a.date, a.day_part_code): a.status for a in data.availabilities
|
|
}
|
|
out: list[Violation] = []
|
|
for day, code in slots:
|
|
status = declared.get((day, code))
|
|
if status is None:
|
|
out.append(
|
|
Violation.of(
|
|
ViolationCode.OUTSIDE_AVAILABILITY,
|
|
date=day.isoformat(),
|
|
day_part_code=code,
|
|
status="UNDECLARED",
|
|
)
|
|
)
|
|
elif status is AvailabilityStatus.UNAVAILABLE:
|
|
out.append(
|
|
Violation.of(
|
|
ViolationCode.OUTSIDE_AVAILABILITY,
|
|
date=day.isoformat(),
|
|
day_part_code=code,
|
|
status="UNAVAILABLE",
|
|
)
|
|
)
|
|
return out
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# DAILY_HOURS_EXCEEDED (avertissement) #
|
|
# --------------------------------------------------------------------------- #
|
|
def _day_boundary(day_parts: tuple) -> dt.time:
|
|
if not day_parts:
|
|
return dt.time(0, 0)
|
|
return min(day_parts, key=lambda part: part.order).start_time
|
|
|
|
|
|
def _day_key(moment: dt.datetime, boundary: dt.time) -> dt.date:
|
|
if moment.time() >= boundary:
|
|
return moment.date()
|
|
return moment.date() - dt.timedelta(days=1)
|
|
|
|
|
|
def _check_daily_hours(data: ValidationInput) -> list[Violation]:
|
|
boundary = _day_boundary(data.day_parts)
|
|
candidate = data.candidate_shift
|
|
key = _day_key(candidate.starts_at, boundary)
|
|
|
|
total = candidate.ends_at - candidate.starts_at
|
|
for other in data.existing_shifts:
|
|
if _day_key(other.starts_at, boundary) == key:
|
|
total += other.ends_at - other.starts_at
|
|
|
|
if total > _EIGHT_HOURS:
|
|
return [
|
|
Violation.of(
|
|
ViolationCode.DAILY_HOURS_EXCEEDED,
|
|
date=key.isoformat(),
|
|
total_minutes=int(total.total_seconds() // 60),
|
|
)
|
|
]
|
|
return []
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# INSUFFICIENT_REST (avertissement) #
|
|
# --------------------------------------------------------------------------- #
|
|
def _check_insufficient_rest(data: ValidationInput) -> list[Violation]:
|
|
candidate = data.candidate_shift
|
|
out: list[Violation] = []
|
|
for other in data.existing_shifts:
|
|
if _overlaps(candidate, other):
|
|
continue
|
|
if other.ends_at <= candidate.starts_at:
|
|
gap = candidate.starts_at - other.ends_at
|
|
direction = "BEFORE"
|
|
elif candidate.ends_at <= other.starts_at:
|
|
gap = other.starts_at - candidate.ends_at
|
|
direction = "AFTER"
|
|
else: # pragma: no cover - inatteignable : non-chevauchement ⇒ avant ou après
|
|
continue
|
|
if gap < _EIGHT_HOURS:
|
|
out.append(
|
|
Violation.of(
|
|
ViolationCode.INSUFFICIENT_REST,
|
|
other_shift_id=other.id,
|
|
direction=direction,
|
|
gap_minutes=int(gap.total_seconds() // 60),
|
|
)
|
|
)
|
|
return out
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# MISSING_REQUIRED_SKILL (avertissement) #
|
|
# --------------------------------------------------------------------------- #
|
|
def _check_missing_skills(data: ValidationInput) -> list[Violation]:
|
|
missing = sorted(
|
|
data.candidate_shift.required_skill_codes
|
|
- data.volunteer_skill_codes
|
|
)
|
|
return [
|
|
Violation.of(ViolationCode.MISSING_REQUIRED_SKILL, skill_code=code)
|
|
for code in missing
|
|
]
|