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
+3 -163
View File
@@ -9,9 +9,8 @@ Déterministe (graine fixe). Aucune donnée nominative réelle.
import datetime as dt
import random
from collections import Counter
from dataclasses import dataclass, field
from dataclasses import dataclass
from sqlalchemy import delete
from sqlmodel import Session, select
from app.auth.passwords import hash_password
@@ -22,7 +21,6 @@ from app.models import (
Artist,
Assignment,
AssignmentSource,
Availability,
DayPart,
Edition,
FeePaymentStatus,
@@ -35,14 +33,7 @@ from app.models import (
VolunteerCompanionWish,
VolunteerPositionWish,
)
from app.validation import (
AvailabilityWindow,
DayPartWindow,
Severity,
ShiftInfo,
ValidationInput,
validate,
)
from app.validation import Severity, validate
from app.validation.loader import build_validation_input
SEED = 20260709
@@ -227,7 +218,6 @@ class Summary:
companion_wishes: int = 0
fee_paid_classique: int = 0
fee_paid_mecenat: int = 0
warnings: Counter = field(default_factory=Counter)
blocking: int = 0
def __str__(self) -> str:
@@ -245,10 +235,7 @@ class Summary:
f" Vœux de compagnon déclarés : {self.companion_wishes}",
f" Cotisation payée (classique) : {self.fee_paid_classique}",
f" Cotisation payée (mécénat) : {self.fee_paid_mecenat}",
f" Avertissements actifs : {sum(self.warnings.values())}",
]
for code, count in sorted(self.warnings.items()):
lines.append(f" {code} : {count}")
lines.append(f" Violations bloquantes : {self.blocking}")
lines.append("")
lines.append(
@@ -309,9 +296,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
]
session.add_all(day_parts)
session.flush()
dp_windows = tuple(
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order) for dp in day_parts
)
coordination = Account(
edition_id=edition.id,
@@ -428,27 +412,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
)
)
n_companion_wishes += 1
# 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:
windows: list[AvailabilityWindow] = []
for day in dates:
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,
)
)
windows.append(AvailabilityWindow(day, start, end))
availabilities[volunteer.id] = windows
shifts: list[Shift] = []
for position, (_n, category, _l, _r) in zip(positions, POSITIONS, strict=True):
for day in dates:
@@ -470,28 +433,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
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 = volunteers[:3]
reserved_ids = {v.id for v in reserved}
assignable = [v for v in volunteers if v.id not in reserved_ids]
def _clean(volunteer: Volunteer, shift: Shift) -> bool:
data = ValidationInput(
candidate_shift=ShiftInfo(
id=str(shift.id),
starts_at=shift.starts_at,
ends_at=shift.ends_at,
),
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(availabilities[volunteer.id]),
day_parts=dp_windows,
)
return not validate(data)
n_assignments = 0
order = shifts[:]
rng.shuffle(order)
@@ -500,7 +441,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
target = max(0, shift.min_staff - 1)
else:
target = rng.randint(shift.min_staff, shift.ideal_staff)
pool = assignable[:]
pool = volunteers[:]
rng.shuffle(pool)
placed = 0
for volunteer in pool:
@@ -508,8 +449,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
break
if any(_overlap(shift, s) for s in assigned[volunteer.id]):
continue
if not _clean(volunteer, shift):
continue
session.add(
Assignment(
edition_id=edition.id,
@@ -522,9 +461,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
placed += 1
n_assignments += 1
n_assignments += _add_demo_warnings(
session, edition, shifts, reserved, assigned, availabilities
)
n_fee_classique, n_fee_mecenat = _add_demo_payments(session, volunteers, rng)
session.flush()
@@ -558,74 +494,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
return summary
def _clear_day(session, volunteer, day, availabilities) -> None:
session.execute(
delete(Availability)
.where(Availability.volunteer_id == volunteer.id)
.where(Availability.date == day)
)
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].append(AvailabilityWindow(day, start, end))
def _add_demo_warnings(
session, edition, shifts, reserved, assigned, availabilities
) -> int:
"""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.
"""
a, b, tired = reserved
added = 0
# 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
# 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):
_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
return added
def _add_demo_payments(
session: Session, volunteers: list[Volunteer], rng: random.Random
) -> tuple[int, int]:
@@ -664,32 +532,6 @@ def _add_demo_payments(
return len(classique), len(mecenat)
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
if (
first.ends_at <= second.starts_at
and dt.timedelta(0) < gap < dt.timedelta(hours=8)
and first.starts_at.date() != second.starts_at.date()
):
return first, second
return None
def _add_active(session, edition, volunteer, shift, assigned) -> None:
session.add(
Assignment(
edition_id=edition.id,
volunteer_id=volunteer.id,
shift_id=shift.id,
source=AssignmentSource.MANUAL,
)
)
assigned[volunteer.id].append(shift)
def _verify(session: Session, edition: Edition, summary: Summary) -> None:
session.flush()
assignments = session.exec(
@@ -709,8 +551,6 @@ def _verify(session: Session, edition: Edition, summary: Summary) -> None:
):
if violation.severity is Severity.BLOCKING:
summary.blocking += 1
else:
summary.warnings[violation.code] += 1
def main() -> None: