Retrait des disponibilités et des limites de temps dans la journée
ci-cd / build-backend (push) Successful in 31s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s

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>
This commit is contained in:
AdamThomas
2026-10-05 21:29:58 +02:00
co-authored by Claude Opus 5.5
parent f671bb85bf
commit d722a81a87
40 changed files with 213 additions and 1845 deletions
+2 -107
View File
@@ -7,7 +7,6 @@ sortie. Règles et sémantique figées : voir CLAUDE.md § 3.
import datetime as dt
from app.validation.models import (
AvailabilityWindow,
Severity,
ShiftInfo,
ValidationInput,
@@ -15,15 +14,10 @@ from app.validation.models import (
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))
return violations
@@ -55,57 +49,8 @@ def _check_overlap(data: ValidationInput) -> list[Violation]:
# --------------------------------------------------------------------------- #
# OUTSIDE_AVAILABILITY (avertissement) #
# --------------------------------------------------------------------------- #
def _window_interval(w: AvailabilityWindow) -> tuple[dt.datetime, dt.datetime]:
start = dt.datetime.combine(w.date, w.start_time)
end_day = w.date if w.end_time > w.start_time else w.date + dt.timedelta(days=1)
return start, dt.datetime.combine(end_day, w.end_time)
def uncovered_minutes(
shift: ShiftInfo, windows: tuple[AvailabilityWindow, ...]
) -> int:
"""Minutes du créneau qui ne tombent dans aucune fenêtre de disponibilité.
Le bénévole peut déclarer plusieurs fenêtres ; on regarde leur union.
"""
cursor = shift.starts_at
intervals = sorted(
(iv for iv in map(_window_interval, windows) if iv[1] > iv[0]),
key=lambda iv: iv[0],
)
gap = dt.timedelta()
for start, end in intervals:
if end <= cursor:
continue
if start > shift.ends_at:
break
if start > cursor:
gap += min(start, shift.ends_at) - cursor
cursor = max(cursor, end)
if cursor >= shift.ends_at:
return int(gap.total_seconds() // 60)
if cursor < shift.ends_at:
gap += shift.ends_at - cursor
return int(gap.total_seconds() // 60)
def _check_outside_availability(data: ValidationInput) -> list[Violation]:
missing = uncovered_minutes(data.candidate_shift, data.availabilities)
if missing == 0:
return []
return [
Violation.of(
ViolationCode.OUTSIDE_AVAILABILITY,
date=data.candidate_shift.starts_at.date().isoformat(),
uncovered_minutes=missing,
)
]
# --------------------------------------------------------------------------- #
# DAILY_HOURS_EXCEEDED (avertissement) #
# « 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
@@ -161,53 +106,3 @@ def festival_day(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 = festival_day(candidate.starts_at, boundary)
total = candidate.ends_at - candidate.starts_at
for other in data.existing_shifts:
if festival_day(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