Retrait des compétences ; disponibilités en fenêtres horaires libres
Compétences supprimées de bout en bout :
- tables `skill`, `volunteer_skill`, `shift_required_skill` ;
- `Volunteer.skill_codes`, `Shift.required_skill_codes`, colonne `skills`
de l'import, règle de validation `MISSING_REQUIRED_SKILL`, jeu de démo.
Disponibilités repensées : `Availability` passe de (`day_part_id`,
`status` tri-état) à une **fenêtre horaire libre** (`date`, `start_time`,
`end_time` ; `end <= start` ⇒ passe minuit), plusieurs par jour.
- Nouveaux endpoints `GET`/`PUT /editions/{id}/volunteers/{vid}/availability`
(PUT = remplacement complet, COORDINATION) et
`GET /editions/{id}/availability` (toute l'édition, pour la grille).
- `OUTSIDE_AVAILABILITY` (toujours WARNING) : compare le créneau à l'union
des fenêtres du bénévole ce jour-là ; renvoie `date` + `uncovered_minutes`.
`DayPart` ne sert plus qu'à la borne de « journée » du cumul 8 h.
- L'import ne gère plus les disponibilités (fenêtres saisies dans l'app) :
`values.py` supprimé, mapping réduit à nom/email/téléphone/commentaire.
Migration 0007 (recrée `availability`). `AvailabilityStatus` supprimé.
158 tests, `alembic check` et `ruff` au vert. Front à suivre.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
81609eecc5
commit
be51770b51
@@ -3,9 +3,8 @@ règle, plus les cas limites de la sémantique figée (CLAUDE.md § 3)."""
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from app.models import AvailabilityStatus
|
||||
from app.validation import (
|
||||
AvailabilityDeclaration,
|
||||
AvailabilityWindow,
|
||||
DayPartWindow,
|
||||
Severity,
|
||||
ShiftInfo,
|
||||
@@ -26,18 +25,20 @@ def _dt(day: int, hour: int, minute: int = 0) -> dt.datetime:
|
||||
return dt.datetime(2026, 7, day, hour, minute)
|
||||
|
||||
|
||||
def _shift(sid: str, start: dt.datetime, end: dt.datetime, skills=()) -> ShiftInfo:
|
||||
return ShiftInfo(
|
||||
id=sid, starts_at=start, ends_at=end, required_skill_codes=frozenset(skills)
|
||||
)
|
||||
def _shift(sid: str, start: dt.datetime, end: dt.datetime) -> ShiftInfo:
|
||||
return ShiftInfo(id=sid, starts_at=start, ends_at=end)
|
||||
|
||||
|
||||
def _codes(violations) -> list[ViolationCode]:
|
||||
return [v.code for v in violations]
|
||||
|
||||
|
||||
def _avail(day: int, code: str, status: AvailabilityStatus) -> AvailabilityDeclaration:
|
||||
return AvailabilityDeclaration(dt.date(2026, 7, day), code, status)
|
||||
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)
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -47,10 +48,7 @@ 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)),),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(
|
||||
_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),
|
||||
),
|
||||
availabilities=(_win(10, 8, 23),),
|
||||
)
|
||||
assert ViolationCode.SHIFT_OVERLAP not in _codes(validate(data))
|
||||
|
||||
@@ -59,6 +57,7 @@ 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]
|
||||
@@ -71,73 +70,68 @@ def test_overlap_triggers_and_is_blocking():
|
||||
# --------------------------------------------------------------------------- #
|
||||
# OUTSIDE_AVAILABILITY #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_availability_pass_when_declared_available():
|
||||
def test_availability_pass_when_shift_within_declared_window():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
|
||||
availabilities=(_win(10, 13, 19),),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_availability_pass_when_declared_if_needed():
|
||||
def test_availability_pass_when_window_exactly_matches_shift():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.IF_NEEDED),),
|
||||
availabilities=(_win(10, 14, 18),),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_availability_triggers_when_slot_unavailable():
|
||||
def test_availability_triggers_when_nothing_declared():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.UNAVAILABLE),),
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert violations[0].severity is Severity.WARNING
|
||||
assert violations[0].params["status"] == "UNAVAILABLE"
|
||||
assert violations[0].params["day_part_code"] == "AFTERNOON"
|
||||
assert violations[0].params["date"] == "2026-07-10"
|
||||
assert violations[0].params["uncovered_minutes"] == 240
|
||||
|
||||
|
||||
def test_availability_triggers_when_slot_undeclared():
|
||||
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, 14), _dt(10, 18)),
|
||||
day_parts=DAY_PARTS,
|
||||
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["status"] == "UNDECLARED"
|
||||
assert violations[0].params["uncovered_minutes"] == 60
|
||||
|
||||
|
||||
def test_availability_covers_two_slots_and_flags_only_the_missing_one():
|
||||
# 18:00 -> 22:00 recoupe AFTERNOON (jusqu'à 19:00) et EVENING (dès 19:00).
|
||||
def test_availability_union_of_several_windows_covers_the_shift():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 18), _dt(10, 22)),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
|
||||
)
|
||||
violations = validate(data)
|
||||
assert len(violations) == 1
|
||||
assert violations[0].params["day_part_code"] == "EVENING"
|
||||
|
||||
|
||||
def test_availability_midnight_crossing_shift_uses_evening_slot_only():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 22), _dt(11, 2)),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_avail(10, "EVENING", AvailabilityStatus.AVAILABLE),),
|
||||
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_rule_skipped_without_day_parts():
|
||||
def test_availability_midnight_crossing_window_covers_night_shift():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
candidate_shift=_shift("c", _dt(10, 22), _dt(11, 2)),
|
||||
availabilities=(_win(10, 19, 4),), # 19:00 -> 04:00 le lendemain
|
||||
)
|
||||
assert ViolationCode.OUTSIDE_AVAILABILITY not in _codes(validate(data))
|
||||
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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -147,10 +141,7 @@ 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=(
|
||||
_avail(10, "MORNING", AvailabilityStatus.AVAILABLE),
|
||||
_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),
|
||||
),
|
||||
availabilities=(_win(10, 8, 18),),
|
||||
)
|
||||
assert ViolationCode.DAILY_HOURS_EXCEEDED not in _codes(validate(data))
|
||||
|
||||
@@ -200,8 +191,7 @@ 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
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_avail(10, "MORNING", AvailabilityStatus.AVAILABLE),),
|
||||
availabilities=(_win(10, 8, 12),),
|
||||
)
|
||||
assert ViolationCode.INSUFFICIENT_REST not in _codes(validate(data))
|
||||
|
||||
@@ -243,81 +233,45 @@ 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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# MISSING_REQUIRED_SKILL #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_skill_pass_when_volunteer_has_all_required():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18), skills=["SST"]),
|
||||
volunteer_skill_codes=frozenset({"SST", "PERMIS_B"}),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_skill_triggers_for_each_missing_code_sorted():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift(
|
||||
"c", _dt(10, 14), _dt(10, 18), skills=["SOUND", "SST", "PERMIS_B"]
|
||||
),
|
||||
volunteer_skill_codes=frozenset({"SST"}),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
|
||||
)
|
||||
violations = validate(data)
|
||||
assert [v.params["skill_code"] for v in violations] == ["PERMIS_B", "SOUND"]
|
||||
assert all(v.severity is Severity.WARNING for v in violations)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Combinaisons #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_clean_assignment_has_no_violation():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18), skills=["SST"]),
|
||||
volunteer_skill_codes=frozenset({"SST"}),
|
||||
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=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
|
||||
availabilities=(_win(10, 13, 19),),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_multiple_rules_fire_together():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift(
|
||||
"c", _dt(10, 14), _dt(10, 18), skills=["SOUND"]
|
||||
),
|
||||
volunteer_skill_codes=frozenset(),
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),), # chevauchement
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(
|
||||
_avail(10, "AFTERNOON", AvailabilityStatus.UNAVAILABLE),
|
||||
),
|
||||
# aucune dispo déclarée ⇒ OUTSIDE_AVAILABILITY
|
||||
)
|
||||
codes = set(_codes(validate(data)))
|
||||
assert {
|
||||
ViolationCode.SHIFT_OVERLAP,
|
||||
ViolationCode.OUTSIDE_AVAILABILITY,
|
||||
ViolationCode.MISSING_REQUIRED_SKILL,
|
||||
} <= 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), skills=["SOUND"]),
|
||||
volunteer_skill_codes=frozenset(),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
|
||||
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.MISSING_REQUIRED_SKILL]
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert has_blocking(violations) is False
|
||||
|
||||
Reference in New Issue
Block a user