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:
AdamThomas
2026-09-10 22:46:45 +02:00
co-authored by Claude Sonnet 5
parent 81609eecc5
commit be51770b51
36 changed files with 800 additions and 1199 deletions
+73 -166
View File
@@ -11,6 +11,7 @@ import random
from collections import Counter
from dataclasses import dataclass, field
from sqlalchemy import delete
from sqlmodel import Session, select
from app.auth.passwords import hash_password
@@ -21,27 +22,22 @@ from app.models import (
Assignment,
AssignmentSource,
Availability,
AvailabilityStatus,
DayPart,
Edition,
Position,
PositionCategory,
Role,
Shift,
ShiftRequiredSkill,
Skill,
Volunteer,
VolunteerSkill,
)
from app.validation import (
AvailabilityDeclaration,
AvailabilityWindow,
DayPartWindow,
Severity,
ShiftInfo,
ValidationInput,
validate,
)
from app.validation.engine import covered_slots
from app.validation.loader import build_validation_input
SEED = 20260709
@@ -136,19 +132,8 @@ LAST_NAMES = [
"Nicolas",
]
SKILLS: list[tuple[str, str]] = [
("SST", "Sauveteur secouriste du travail"),
("PERMIS_B", "Permis B"),
("REGIE_SON", "Régie son"),
("REGIE_LUM", "Régie lumière"),
("CACES", "CACES nacelle"),
("ELEC", "Habilitation électrique"),
("EN", "Anglais courant"),
("ES", "Espagnol courant"),
]
_C = PositionCategory
# (nom, catégorie, lieu, compétences requises sur ses créneaux)
# (nom, catégorie, lieu, _ inutilisé)
POSITIONS: list[tuple[str, PositionCategory, str, list[str]]] = [
("Bar central", _C.BAR, "Chapiteau", []),
("Bar scène", _C.BAR, "Grande scène", []),
@@ -291,11 +276,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
dp_windows = tuple(
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order) for dp in day_parts
)
dp_by_code = {dp.code: dp for dp in day_parts}
skills = {code: Skill(edition_id=edition.id, code=code, label=label) for code, label in SKILLS}
session.add_all(skills.values())
session.flush()
coordination = Account(
edition_id=edition.id,
@@ -337,7 +317,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
}
volunteers: list[Volunteer] = []
vol_skill_codes: dict[object, set[str]] = {}
used_names: set[tuple[str, str]] = set()
for i in range(120):
while True:
@@ -366,45 +345,29 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
volunteer_id=volunteer.id,
)
)
count = rng.choices([0, 1, 2, 3], weights=[0.3, 0.34, 0.24, 0.12])[0]
codes = set(rng.sample(list(skills), k=count))
# Le permis B est courant : on en garantit chez ~40 % des bénévoles
# pour que le parking soit gréable.
if i % 5 in (0, 1):
codes.add("PERMIS_B")
for code in codes:
session.add(VolunteerSkill(volunteer_id=volunteer.id, skill_id=skills[code].id))
vol_skill_codes[volunteer.id] = codes
availabilities: dict[object, dict[tuple[dt.date, str], AvailabilityStatus]] = {}
# Disponibilités : chaque bénévole déclare, pour ~80 % des jours, une
# large fenêtre couvrant la journée et la nuit (06:00 → 04:00 le lendemain).
_FULL_DAY = (dt.time(6, 0), dt.time(4, 0))
availabilities: dict[object, list[AvailabilityWindow]] = {}
for volunteer in volunteers:
declared: dict[tuple[dt.date, str], AvailabilityStatus] = {}
windows: list[AvailabilityWindow] = []
for day in dates:
for dp in day_parts:
if rng.random() < 0.72:
status = rng.choices(
[
AvailabilityStatus.AVAILABLE,
AvailabilityStatus.IF_NEEDED,
AvailabilityStatus.UNAVAILABLE,
],
weights=[0.78, 0.14, 0.08],
)[0]
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=day,
day_part_id=dp.id,
status=status,
)
if rng.random() < 0.8:
start, end = _FULL_DAY
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=day,
start_time=start,
end_time=end,
)
declared[(day, dp.code)] = status
availabilities[volunteer.id] = declared
)
windows.append(AvailabilityWindow(day, start, end))
availabilities[volunteer.id] = windows
shifts: list[Shift] = []
shift_required: dict[object, set[str]] = {}
for position, (_n, category, _l, required) in zip(positions, POSITIONS, strict=True):
for position, (_n, category, _l, _r) in zip(positions, POSITIONS, strict=True):
for day in dates:
for start_hour, end_hour in _WINDOWS[category][phase_of[day]]:
min_staff, ideal_staff = _STAFF[category]
@@ -420,21 +383,13 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
)
session.add(shift)
session.flush()
for code in required:
session.add(ShiftRequiredSkill(shift_id=shift.id, skill_id=skills[code].id))
shifts.append(shift)
shift_required[shift.id] = set(required)
assigned: dict[object, list[Shift]] = {v.id: [] for v in volunteers}
# Trois bénévoles réservés aux scénarios d'avertissement : exclus de
# l'affectation générale pour rester libres.
reserved_unskilled = next(
v for v in volunteers if not vol_skill_codes[v.id]
)
reserved = [reserved_unskilled] + [
v for v in volunteers if v is not reserved_unskilled
][:2]
reserved = volunteers[:3]
reserved_ids = {v.id for v in reserved}
assignable = [v for v in volunteers if v.id not in reserved_ids]
@@ -444,17 +399,12 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
id=str(shift.id),
starts_at=shift.starts_at,
ends_at=shift.ends_at,
required_skill_codes=frozenset(shift_required[shift.id]),
),
volunteer_skill_codes=frozenset(vol_skill_codes[volunteer.id]),
existing_shifts=tuple(
ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at)
for s in assigned[volunteer.id]
),
availabilities=tuple(
AvailabilityDeclaration(day, code, status)
for (day, code), status in availabilities[volunteer.id].items()
),
availabilities=tuple(availabilities[volunteer.id]),
day_parts=dp_windows,
)
return not validate(data)
@@ -490,15 +440,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
n_assignments += 1
n_assignments += _add_demo_warnings(
session,
edition,
shifts,
reserved,
assigned,
availabilities,
dp_by_code,
day_parts,
shift_required,
session, edition, shifts, reserved, assigned, availabilities
)
session.flush()
@@ -526,99 +468,67 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
return summary
def _set_availability(session, edition, volunteer, day, day_part, status, availabilities) -> None:
row = session.exec(
select(Availability)
def _clear_day(session, volunteer, day, availabilities) -> None:
session.execute(
delete(Availability)
.where(Availability.volunteer_id == volunteer.id)
.where(Availability.date == day)
.where(Availability.day_part_id == day_part.id)
).first()
if row is not None:
row.status = status
else:
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=day,
day_part_id=day_part.id,
status=status,
)
)
availabilities[volunteer.id] = [
w for w in availabilities[volunteer.id] if w.date != day
]
def _declare_window(
session, edition, volunteer, day, start, end, availabilities
) -> None:
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=day,
start_time=start,
end_time=end,
)
availabilities[volunteer.id][(day, day_part.code)] = status
)
availabilities[volunteer.id].append(AvailabilityWindow(day, start, end))
def _add_demo_warnings(
session,
edition,
shifts,
reserved,
assigned,
availabilities,
dp_by_code,
day_parts,
shift_required,
session, edition, shifts, reserved, assigned, availabilities
) -> int:
"""Trois affectations qui déclenchent chacune un avertissement, et rien
d'autre : compétence absente, hors disponibilité, repos insuffisant.
`reserved` = [bénévole sans compétence, deux autres], tenus à l'écart de
l'affectation générale.
"""Quelques affectations qui déclenchent des avertissements (et rien
d'autre) : hors disponibilité, repos insuffisant. `reserved` = trois
bénévoles tenus à l'écart de l'affectation générale.
"""
unskilled, reluctant, tired = reserved
a, b, tired = reserved
added = 0
# 1. MISSING_REQUIRED_SKILL : sur la régie, un bénévole sans compétence.
regie = next(s for s in shifts if "REGIE_SON" in shift_required[s.id])
for day, code in covered_slots(regie, day_parts):
_set_availability(
session,
edition,
unskilled,
day,
dp_by_code[code],
AvailabilityStatus.AVAILABLE,
availabilities,
)
_add_active(session, edition, unskilled, regie, assigned)
added += 1
# OUTSIDE_AVAILABILITY : deux bénévoles affectés un jour où ils n'ont
# déclaré aucune disponibilité.
plain = sorted(shifts, key=lambda s: s.starts_at)
for volunteer, shift in ((a, plain[3]), (b, plain[7])):
_clear_day(session, volunteer, shift.starts_at.date(), availabilities)
_add_active(session, edition, volunteer, shift, assigned)
added += 1
# 2. OUTSIDE_AVAILABILITY : un créneau de bar sur une demi-journée déclarée
# indisponible.
bar = next(
s
for s in shifts
if not shift_required[s.id] and (s.ends_at - s.starts_at) <= dt.timedelta(hours=8)
)
for offset, (day, code) in enumerate(covered_slots(bar, day_parts)):
_set_availability(
session,
edition,
reluctant,
day,
dp_by_code[code],
AvailabilityStatus.UNAVAILABLE if offset == 0 else AvailabilityStatus.AVAILABLE,
availabilities,
)
_add_active(session, edition, reluctant, bar, assigned)
added += 1
# 3. INSUFFICIENT_REST : deux créneaux sans compétence espacés de moins de
# 8 h, sur deux journées (pour ne pas déclencher le cumul).
pair = _find_rest_pair(shifts, shift_required)
# INSUFFICIENT_REST : deux créneaux espacés de moins de 8 h, sur deux
# journées (pour ne pas déclencher le cumul), tous deux couverts par une
# disponibilité.
pair = _find_rest_pair(shifts)
if pair is not None:
first, second = pair
for shift in (first, second):
for day, code in covered_slots(shift, day_parts):
_set_availability(
session,
edition,
tired,
day,
dp_by_code[code],
AvailabilityStatus.AVAILABLE,
availabilities,
)
_clear_day(session, tired, shift.starts_at.date(), availabilities)
_declare_window(
session,
edition,
tired,
shift.starts_at.date(),
dt.time(6, 0),
dt.time(4, 0),
availabilities,
)
_add_active(session, edition, tired, first, assigned)
_add_active(session, edition, tired, second, assigned)
added += 2
@@ -626,11 +536,8 @@ def _add_demo_warnings(
return added
def _find_rest_pair(shifts: list[Shift], shift_required: dict) -> tuple[Shift, Shift] | None:
plain = sorted(
(s for s in shifts if not shift_required[s.id]),
key=lambda s: s.starts_at,
)
def _find_rest_pair(shifts: list[Shift]) -> tuple[Shift, Shift] | None:
plain = sorted(shifts, key=lambda s: s.starts_at)
for i, first in enumerate(plain):
for second in plain[i + 1 :]:
gap = second.starts_at - first.ends_at