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
+15 -215
View File
@@ -4,7 +4,6 @@ règle, plus les cas limites de la sémantique figée (CLAUDE.md § 3)."""
import datetime as dt
from app.validation import (
AvailabilityWindow,
DayPartWindow,
Severity,
ShiftInfo,
@@ -33,14 +32,6 @@ def _codes(violations) -> list[ViolationCode]:
return [v.code for v in violations]
def _win(day: int, start_h: int, end_h: int) -> AvailabilityWindow:
"""Fenêtre de dispo le <day> juillet, de start_h:00 à end_h:00.
`end_h <= start_h` ⇒ se termine le lendemain."""
return AvailabilityWindow(
dt.date(2026, 7, day), dt.time(start_h % 24, 0), dt.time(end_h % 24, 0)
)
# --------------------------------------------------------------------------- #
# SHIFT_OVERLAP #
# --------------------------------------------------------------------------- #
@@ -48,233 +39,42 @@ def test_overlap_pass_when_shifts_are_adjacent():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),),
availabilities=(_win(10, 8, 23),),
)
assert ViolationCode.SHIFT_OVERLAP not in _codes(validate(data))
assert validate(data) == []
def test_overlap_triggers_and_is_blocking():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),),
availabilities=(_win(10, 8, 23),),
)
violations = validate(data)
overlap = [v for v in violations if v.code is ViolationCode.SHIFT_OVERLAP]
assert len(overlap) == 1
assert overlap[0].severity is Severity.BLOCKING
assert overlap[0].params["other_shift_id"] == "e"
assert _codes(violations) == [ViolationCode.SHIFT_OVERLAP]
assert violations[0].severity is Severity.BLOCKING
assert violations[0].params["other_shift_id"] == "e"
assert has_blocking(violations) is True
# --------------------------------------------------------------------------- #
# OUTSIDE_AVAILABILITY #
# Plus de limite de temps dans la journée #
# --------------------------------------------------------------------------- #
def test_availability_pass_when_shift_within_declared_window():
def test_long_day_and_short_rest_raise_nothing():
"""Ni cumul journalier (> 8 h) ni repos minimal (< 8 h) : seuls les
chevauchements sont signalés."""
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
availabilities=(_win(10, 13, 19),),
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 22)),
existing_shifts=(
_shift("matin", _dt(10, 6), _dt(10, 14)),
_shift("nuit", _dt(10, 23), _dt(11, 3)),
),
)
assert validate(data) == []
def test_availability_pass_when_window_exactly_matches_shift():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
availabilities=(_win(10, 14, 18),),
)
assert validate(data) == []
def test_availability_triggers_when_nothing_declared():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
)
violations = validate(data)
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
assert violations[0].severity is Severity.WARNING
assert violations[0].params["date"] == "2026-07-10"
assert violations[0].params["uncovered_minutes"] == 240
def test_availability_triggers_on_partial_coverage_and_counts_the_gap():
# Créneau 16:00 -> 20:00 ; dispo seulement 13:00 -> 19:00 ⇒ 60 min à découvert.
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 16), _dt(10, 20)),
availabilities=(_win(10, 13, 19),),
)
violations = validate(data)
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
assert violations[0].params["uncovered_minutes"] == 60
def test_availability_union_of_several_windows_covers_the_shift():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 12), _dt(10, 20)),
availabilities=(_win(10, 12, 16), _win(10, 15, 20)),
)
assert validate(data) == []
def test_availability_midnight_crossing_window_covers_night_shift():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 22), _dt(11, 2)),
availabilities=(_win(10, 19, 4),), # 19:00 -> 04:00 le lendemain
)
assert validate(data) == []
def test_availability_gap_between_two_windows_is_flagged():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 18)),
availabilities=(_win(10, 10, 12), _win(10, 14, 18)), # trou 12:00-14:00
)
violations = validate(data)
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
assert violations[0].params["uncovered_minutes"] == 120
# --------------------------------------------------------------------------- #
# DAILY_HOURS_EXCEEDED #
# --------------------------------------------------------------------------- #
def test_daily_hours_pass_at_exactly_eight():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h pile
day_parts=DAY_PARTS,
availabilities=(_win(10, 8, 18),),
)
assert ViolationCode.DAILY_HOURS_EXCEEDED not in _codes(validate(data))
def test_daily_hours_triggers_above_eight():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h
existing_shifts=(_shift("e", _dt(10, 20), _dt(10, 20, 30)),), # +30 min
day_parts=DAY_PARTS,
)
violations = [
v for v in validate(data) if v.code is ViolationCode.DAILY_HOURS_EXCEEDED
]
assert len(violations) == 1
assert violations[0].params["total_minutes"] == 510
assert violations[0].params["date"] == "2026-07-10"
def test_daily_hours_day_starts_at_first_day_part_boundary():
# Frontière de journée = 06:00 (début de MORNING). Un créneau de nuit qui
# commence à 03:00 le 11 appartient à la journée du 10.
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 8), _dt(10, 15)), # 7 h, journée 10
existing_shifts=(_shift("e", _dt(11, 3), _dt(11, 5)),), # 2 h, journée 10
day_parts=DAY_PARTS,
)
violations = [
v for v in validate(data) if v.code is ViolationCode.DAILY_HOURS_EXCEEDED
]
assert len(violations) == 1
assert violations[0].params["date"] == "2026-07-10"
def test_daily_hours_ignores_other_days():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h journée 10
existing_shifts=(_shift("e", _dt(9, 9), _dt(9, 17)),), # 8 h journée 9
day_parts=DAY_PARTS,
)
assert ViolationCode.DAILY_HOURS_EXCEEDED not in _codes(validate(data))
# --------------------------------------------------------------------------- #
# INSUFFICIENT_REST #
# --------------------------------------------------------------------------- #
def test_rest_pass_with_more_than_eight_hours_gap():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 8), _dt(10, 12)),
existing_shifts=(_shift("e", _dt(9, 18), _dt(9, 23)),), # écart 9 h
availabilities=(_win(10, 8, 12),),
)
assert ViolationCode.INSUFFICIENT_REST not in _codes(validate(data))
def test_rest_pass_at_exactly_eight_hours():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 4), _dt(10, 12)),
existing_shifts=(_shift("e", _dt(10, 20), _dt(10, 22)),), # écart 8 h pile
)
assert ViolationCode.INSUFFICIENT_REST not in _codes(validate(data))
def test_rest_triggers_for_shift_before():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 14)),
existing_shifts=(_shift("e", _dt(10, 2), _dt(10, 6)),), # écart 4 h
)
violations = [
v for v in validate(data) if v.code is ViolationCode.INSUFFICIENT_REST
]
assert len(violations) == 1
assert violations[0].params["direction"] == "BEFORE"
assert violations[0].params["gap_minutes"] == 240
def test_rest_triggers_for_shift_after():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 14)),
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 20)),), # écart 4 h
)
violations = [
v for v in validate(data) if v.code is ViolationCode.INSUFFICIENT_REST
]
assert len(violations) == 1
assert violations[0].params["direction"] == "AFTER"
def test_back_to_back_shifts_are_rest_violation_not_overlap():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),),
availabilities=(_win(10, 8, 23),),
)
codes = _codes(validate(data))
assert ViolationCode.SHIFT_OVERLAP not in codes
assert ViolationCode.INSUFFICIENT_REST in codes
# --------------------------------------------------------------------------- #
# Combinaisons #
# --------------------------------------------------------------------------- #
def test_clean_assignment_has_no_violation():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(9, 8), _dt(9, 12)),),
day_parts=DAY_PARTS,
availabilities=(_win(10, 13, 19),),
)
data = ValidationInput(candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)))
assert validate(data) == []
def test_multiple_rules_fire_together():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),), # chevauchement
# aucune dispo déclarée ⇒ OUTSIDE_AVAILABILITY
)
codes = set(_codes(validate(data)))
assert {
ViolationCode.SHIFT_OVERLAP,
ViolationCode.OUTSIDE_AVAILABILITY,
} <= codes
assert has_blocking(validate(data)) is True
def test_only_warnings_is_not_blocking():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
# pas de dispo ⇒ un seul avertissement OUTSIDE_AVAILABILITY
)
violations = validate(data)
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
assert has_blocking(violations) is False
assert has_blocking([]) is False
# --- Début de « journée de festival » (day_boundary) ------------------------