Retrait des disponibilités et des limites de temps dans la journée
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:
co-authored by
Claude Opus 5.5
parent
f671bb85bf
commit
d722a81a87
+3
-163
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user