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
+32 -68
View File
@@ -4,7 +4,7 @@ import datetime as dt
from app.solver.engine import solve
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
from app.validation.models import AvailabilityWindow, ShiftInfo
from app.validation.models import ShiftInfo
def _dt(day: int, hour: int, minute: int = 0) -> dt.datetime:
@@ -39,7 +39,6 @@ def _shift(
def _volunteer(
vid: str,
*,
availability=(),
wished_position_ids=frozenset(),
wished_companion_ids=frozenset(),
wished_artist_windows=(),
@@ -48,7 +47,6 @@ def _volunteer(
) -> SolverVolunteer:
return SolverVolunteer(
id=vid,
availability=tuple(availability),
wished_position_ids=frozenset(wished_position_ids),
wished_companion_ids=frozenset(wished_companion_ids),
wished_artist_windows=tuple(wished_artist_windows),
@@ -57,10 +55,6 @@ def _volunteer(
)
def _full_day(day: int) -> AvailabilityWindow:
return AvailabilityWindow(dt.date(2026, 7, day), dt.time(6, 0), dt.time(4, 0))
def _by_shift(solution, shift_id: str) -> tuple[str, ...]:
for s in solution.suggestions:
if s.shift_id == shift_id:
@@ -75,7 +69,7 @@ def test_no_variables_returns_empty_solution():
def test_fully_staffed_shift_gets_no_suggestion():
shift = _shift(ideal_staff=1, already_assigned=1)
volunteer = _volunteer("v1", availability=[_full_day(10)])
volunteer = _volunteer("v1")
solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,)))
assert solution.suggestions == ()
@@ -84,105 +78,77 @@ def test_picks_the_only_free_volunteer_over_a_busy_one():
shift = _shift()
busy = _volunteer(
"busy",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 15), ends_at=_dt(10, 19))],
)
free = _volunteer("free", availability=[_full_day(10)])
free = _volunteer("free")
solution = solve(SolverInput(shifts=(shift,), volunteers=(busy, free)))
assert _by_shift(solution, "s1") == ("free",)
def test_does_not_exceed_ideal_staff_cap():
shift = _shift(ideal_staff=2, already_assigned=1) # il ne manque qu'une place
a = _volunteer("a", availability=[_full_day(10)])
b = _volunteer("b", availability=[_full_day(10)])
a = _volunteer("a")
b = _volunteer("b")
solution = solve(SolverInput(shifts=(shift,), volunteers=(a, b)))
assert len(_by_shift(solution, "s1")) == 1
def test_prefers_volunteer_with_matching_position_wish():
shift = _shift(position_id="regie")
plain = _volunteer("plain", availability=[_full_day(10)])
plain = _volunteer("plain")
wisher = _volunteer(
"wisher", availability=[_full_day(10)], wished_position_ids={"regie"}
"wisher", wished_position_ids={"regie"}
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(plain, wisher)))
assert _by_shift(solution, "s1") == ("wisher",)
def test_prefers_fully_available_volunteer_over_partially_available():
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
full = _volunteer("full", availability=[_full_day(10)])
partial = _volunteer(
"partial",
availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial)))
assert _by_shift(solution, "s1") == ("full",)
def test_ranks_both_candidates_when_two_slots_are_open():
shift = _shift(ideal_staff=2)
full = _volunteer("full", availability=[_full_day(10)])
partial = _volunteer(
"partial",
availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial)))
assert _by_shift(solution, "s1") == ("full", "partial")
def test_never_double_books_a_volunteer_across_two_new_suggestions():
# Deux créneaux qui se chevauchent, un seul bénévole disponible pour les
# deux : le solveur ne le propose qu'une fois.
shift_a = _shift("a", start=_dt(10, 14), end=_dt(10, 18))
shift_b = _shift("b", start=_dt(10, 16), end=_dt(10, 20))
only = _volunteer("only", availability=[_full_day(10)])
only = _volunteer("only")
solution = solve(SolverInput(shifts=(shift_a, shift_b), volunteers=(only,)))
picked_a = _by_shift(solution, "a")
picked_b = _by_shift(solution, "b")
assert len(picked_a) + len(picked_b) == 1
def test_avoids_volunteer_with_insufficient_rest_since_last_shift():
# < 8 h entre la fin d'un créneau déjà décidé et le début de celui-ci.
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
def test_short_rest_is_not_penalized():
# Plus de repos minimal (8 h) : 4 h après un créneau déjà décidé, le
# bénévole reste à égalité avec un autre.
shift = _shift(ideal_staff=2, start=_dt(10, 14), end=_dt(10, 18))
tired = _volunteer(
"tired",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 8), ends_at=_dt(10, 10))], # repos 4 h
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 8), ends_at=_dt(10, 10))],
)
fresh = _volunteer("fresh", availability=[_full_day(10)])
fresh = _volunteer("fresh")
solution = solve(SolverInput(shifts=(shift,), volunteers=(tired, fresh)))
assert _by_shift(solution, "s1") == ("fresh",)
assert set(_by_shift(solution, "s1")) == {"tired", "fresh"}
def test_daily_cumulative_hours_no_longer_penalized():
# Le solveur ne plafonne plus le cumul de 8 h/jour (retiré) : seule la
# validation, à la confirmation, le signale désormais
# (`DAILY_HOURS_EXCEEDED`). Repos respecté (8 h pile) ; cumul du jour
# (7 h + 2 h = 9 h) aurait auparavant été pénalisé.
# Aucun plafond de cumul journalier : 7 h + 2 h = 9 h sur la journée ne
# pénalise pas le bénévole.
shift = _shift(ideal_staff=2, start=_dt(10, 21), end=_dt(10, 23))
long_day = _volunteer(
"long_day",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 6), ends_at=_dt(10, 13))],
)
fresh = _volunteer("fresh", availability=[_full_day(10)])
fresh = _volunteer("fresh")
solution = solve(SolverInput(shifts=(shift,), volunteers=(long_day, fresh)))
assert set(_by_shift(solution, "s1")) == {"long_day", "fresh"}
def test_prefers_volunteer_without_the_immediately_preceding_shift():
# Repos suffisant (8 h pile) entre les deux créneaux : seul le chaînage
# doit différencier les deux bénévoles.
# Seul le chaînage doit différencier les deux bénévoles.
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"})
chained = _volunteer(
"chained",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
)
fresh = _volunteer("fresh", availability=[_full_day(10)])
fresh = _volunteer("fresh")
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained, fresh)))
assert _by_shift(solution, "s1") == ("fresh",)
@@ -196,10 +162,9 @@ def test_chain_penalty_applies_across_different_positions():
)
chained = _volunteer(
"chained",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
)
fresh = _volunteer("fresh", availability=[_full_day(10)])
fresh = _volunteer("fresh")
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained, fresh)))
assert _by_shift(solution, "s1") == ("fresh",)
@@ -210,7 +175,6 @@ def test_still_suggests_the_only_candidate_despite_chaining():
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"})
chained = _volunteer(
"chained",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained,)))
@@ -227,9 +191,9 @@ def test_an_unrelated_existing_shift_does_not_trigger_the_chain_penalty():
)
unrelated = ShiftInfo(id="not-the-predecessor", starts_at=_dt(10, 8), ends_at=_dt(10, 12))
almost_chained = _volunteer(
"almost_chained", availability=[_full_day(10)], existing_shifts=[unrelated]
"almost_chained", existing_shifts=[unrelated]
)
truly_fresh = _volunteer("truly_fresh", availability=[_full_day(10)])
truly_fresh = _volunteer("truly_fresh")
solution = solve(SolverInput(shifts=(shift,), volunteers=(almost_chained, truly_fresh)))
assert set(_by_shift(solution, "s1")) == {"almost_chained", "truly_fresh"}
@@ -241,9 +205,9 @@ def test_avoids_volunteer_whose_wished_artist_plays_during_the_shift():
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
passage = ShiftInfo(id="art1", starts_at=_dt(10, 16), ends_at=_dt(10, 17))
fan = _volunteer(
"fan", availability=[_full_day(10)], wished_artist_windows=[passage]
"fan", wished_artist_windows=[passage]
)
indiff = _volunteer("indiff", availability=[_full_day(10)])
indiff = _volunteer("indiff")
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan, indiff)))
assert _by_shift(solution, "s1") == ("indiff",)
@@ -254,7 +218,7 @@ def test_still_suggests_the_only_candidate_despite_an_artist_conflict():
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
passage = ShiftInfo(id="art1", starts_at=_dt(10, 16), ends_at=_dt(10, 17))
fan = _volunteer(
"fan", availability=[_full_day(10)], wished_artist_windows=[passage]
"fan", wished_artist_windows=[passage]
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,)))
assert _by_shift(solution, "s1") == ("fan",)
@@ -266,7 +230,7 @@ def test_artist_conflict_outside_shift_hours_has_no_effect():
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
passage = ShiftInfo(id="art1", starts_at=_dt(10, 20), ends_at=_dt(10, 21))
fan = _volunteer(
"fan", availability=[_full_day(10)], wished_artist_windows=[passage]
"fan", wished_artist_windows=[passage]
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,)))
assert _by_shift(solution, "s1") == ("fan",)
@@ -277,9 +241,9 @@ def test_prefers_volunteer_whose_wished_companion_is_already_on_the_shift():
# préféré à « stranger », qui n'a pas ce lien.
shift = _shift(ideal_staff=2, already_assigned=1, assigned_volunteer_ids={"copain"})
buddy = _volunteer(
"buddy", availability=[_full_day(10)], wished_companion_ids={"copain"}
"buddy", wished_companion_ids={"copain"}
)
stranger = _volunteer("stranger", availability=[_full_day(10)])
stranger = _volunteer("stranger")
solution = solve(SolverInput(shifts=(shift,), volunteers=(buddy, stranger)))
assert _by_shift(solution, "s1") == ("buddy",)
@@ -290,10 +254,10 @@ def test_companion_bonus_scales_with_number_of_matches():
ideal_staff=4, already_assigned=2, assigned_volunteer_ids={"a", "b"}
)
two_friends = _volunteer(
"two_friends", availability=[_full_day(10)], wished_companion_ids={"a", "b"}
"two_friends", wished_companion_ids={"a", "b"}
)
one_friend = _volunteer(
"one_friend", availability=[_full_day(10)], wished_companion_ids={"a"}
"one_friend", wished_companion_ids={"a"}
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(two_friends, one_friend)))
assert _by_shift(solution, "s1") == ("two_friends", "one_friend")
@@ -302,7 +266,7 @@ def test_companion_bonus_scales_with_number_of_matches():
def test_wished_companion_absent_from_the_shift_has_no_effect():
shift = _shift()
volunteer = _volunteer(
"v", availability=[_full_day(10)], wished_companion_ids={"not-here"}
"v", wished_companion_ids={"not-here"}
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,)))
assert _by_shift(solution, "s1") == ("v",)