Files
benevoles/backend/app/validation/engine.py
T
AdamThomasandClaude Opus 5.5 d722a81a87
ci-cd / build-backend (push) Successful in 31s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s
Retrait des disponibilités et des limites de temps dans la journée
Disponibilités déclarées supprimées (table `availability`, migration 0022,
API, éditeur de la fiche bénévole, badge de couverture du panneau
d'affectation, colonnes des statistiques) ainsi que les règles
OUTSIDE_AVAILABILITY, DAILY_HOURS_EXCEEDED et INSUFFICIENT_REST, les malus
de disponibilité et de repos du solveur et le filtre « Surchargés » de la
vue bénévoles. Seul le chevauchement reste contrôlé. La case « je suis
disponible » du formulaire public est conservée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:29:58 +02:00

109 lines
4.4 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.
"""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.validation.models import (
Severity,
ShiftInfo,
ValidationInput,
Violation,
ViolationCode,
)
def validate(data: ValidationInput) -> list[Violation]:
violations: list[Violation] = []
violations.extend(_check_overlap(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)
]
# --------------------------------------------------------------------------- #
# « Journée de festival » — plus utilisée par la validation elle-même, mais #
# partagée par le solveur, les feuilles de poste et les statistiques. #
# --------------------------------------------------------------------------- #
_MINUTES_PER_DAY = 24 * 60
def day_boundary(day_parts) -> dt.time:
"""Début d'une « journée de festival » : début du plus long creux que
laissent les créneaux types (`DayPart`) sur le cadran de 24 h — l'heure à
laquelle le dernier de la journée se termine —, minuit à défaut — voir
CLAUDE.md § 3. Avec Matin 08 h–13 h, Après-midi 13 h–19 h, Soirée
19 h–02 h, le creux 02 h–08 h est le plus long : la journée
commence à 02 h, la soirée qui passe minuit reste sur le jour où elle a
commencé. Indépendant de `order`, qui n'est qu'un ordre d'affichage et
ne suit pas les heures quand on les modifie (un créneau type d'ordre 0
déplacé à 21 h faisait commencer chaque journée à 21 h). Sans creux (les
créneaux types couvrent 24 h), début du premier par `order`. Accepte
aussi bien les `DayPartInfo` de ce module que les lignes `DayPart`.
Miroir front : `lib/planning.ts:dayBoundaryFromDayParts`."""
day_parts = list(day_parts)
if not day_parts:
return dt.time(0, 0)
covered = [False] * _MINUTES_PER_DAY
for part in day_parts:
start = part.start_time.hour * 60 + part.start_time.minute
end = part.end_time.hour * 60 + part.end_time.minute
length = (end - start) % _MINUTES_PER_DAY or _MINUTES_PER_DAY
for offset in range(length):
covered[(start + offset) % _MINUTES_PER_DAY] = True
if all(covered):
return min(day_parts, key=lambda part: part.order).start_time
# Parcours circulaire à partir d'une minute couverte : chaque creux est
# vu d'un seul tenant, même s'il enjambe minuit.
origin = covered.index(True)
best_start, best_length = 0, 0
run_start, run_length = 0, 0
for offset in range(1, _MINUTES_PER_DAY + 1):
minute = (origin + offset) % _MINUTES_PER_DAY
if covered[minute]:
run_length = 0
continue
if run_length == 0:
run_start = minute
run_length += 1
if run_length > best_length:
best_start, best_length = run_start, run_length
return dt.time(best_start // 60, best_start % 60)
def festival_day(moment: dt.datetime, boundary: dt.time) -> dt.date:
"""Journée de festival d'un instant : avant `boundary`, c'est encore la
nuit de la veille (ex. 01:00 le dimanche ⇒ samedi). Partagé avec le
solveur et les feuilles de poste, pour qu'une soirée qui passe minuit
reste d'un seul tenant partout."""
if moment.time() >= boundary:
return moment.date()
return moment.date() - dt.timedelta(days=1)