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
-132
View File
@@ -1,132 +0,0 @@
"""Disponibilités déclarées par les bénévoles.
Une disponibilité = « le <date>, je peux de <start_time> à <end_time> ».
Fenêtre horaire libre (pas liée aux créneaux types) ; plusieurs par jour
possibles. `end_time <= start_time` ⇒ la fenêtre se termine le lendemain.
Ces fenêtres sont prises en compte par le moteur de validation (règle
`OUTSIDE_AVAILABILITY`) au moment d'affecter un bénévole à un créneau.
"""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import delete
from sqlmodel import Session, select
from app.api._common import owned_or_404
from app.api.deps import get_edition
from app.api.schemas import AvailabilityRead, AvailabilitySet
from app.auth.deps import (
current_account_in_edition,
require_coordinator,
require_staff,
)
from app.db import get_session
from app.models import Account, Availability, Edition, Role, Volunteer
router = APIRouter(prefix="/editions/{edition_id}", tags=["availability"])
def _ensure_can_read(account: Account, volunteer_id: uuid.UUID) -> None:
if account.role in (Role.COORDINATOR, Role.LEAD):
return
if account.volunteer_id == volunteer_id:
return
raise HTTPException(status_code=403, detail={"code": "error.forbidden"})
def _windows(session: Session, volunteer_id: uuid.UUID) -> list[Availability]:
return list(
session.exec(
select(Availability)
.where(Availability.volunteer_id == volunteer_id)
.order_by(Availability.date, Availability.start_time)
)
)
@router.get(
"/volunteers/{volunteer_id}/availability",
response_model=list[AvailabilityRead],
)
def list_availability(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> list[Availability]:
_ensure_can_read(account, volunteer_id)
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
return _windows(session, volunteer_id)
@router.put(
"/volunteers/{volunteer_id}/availability",
response_model=list[AvailabilityRead],
)
def replace_availability(
volunteer_id: uuid.UUID,
payload: AvailabilitySet,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> list[Availability]:
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
for w in payload.windows:
if not edition.start_date <= w.date <= edition.end_date:
raise HTTPException(
status_code=422,
detail={
"code": "availability.date_outside_edition",
"params": {"date": w.date.isoformat()},
},
)
if w.start_time == w.end_time:
raise HTTPException(
status_code=422,
detail={"code": "availability.empty_window"},
)
session.execute(
delete(Availability).where(Availability.volunteer_id == volunteer_id)
)
# Dé-doublonne (date, start, end) : la contrainte d'unicité l'imposerait.
seen: set[tuple] = set()
for w in payload.windows:
key = (w.date, w.start_time, w.end_time)
if key in seen:
continue
seen.add(key)
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer_id,
date=w.date,
start_time=w.start_time,
end_time=w.end_time,
)
)
session.commit()
return _windows(session, volunteer_id)
@router.get("/availability", response_model=list[AvailabilityRead])
def list_edition_availability(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> list[Availability]:
"""Toutes les disponibilités de l'édition (pour la grille des besoins)."""
return list(
session.exec(
select(Availability)
.where(Availability.edition_id == edition.id)
.order_by(
Availability.volunteer_id,
Availability.date,
Availability.start_time,
)
)
)
+1 -32
View File
@@ -254,33 +254,6 @@ class DayPartUpdate(ApiModel):
order: int | None = None
# --------------------------------------------------------------------------- #
# Availability (disponibilités déclarées par le bénévole) #
# --------------------------------------------------------------------------- #
class AvailabilityWindowIn(ApiModel):
date: dt.date
start_time: dt.time
# end_time <= start_time ⇒ la fenêtre se termine le lendemain.
end_time: dt.time
class AvailabilitySet(ApiModel):
"""Remplace l'intégralité des disponibilités d'un bénévole."""
windows: list[AvailabilityWindowIn] = Field(default_factory=list)
class AvailabilityRead(ApiModel):
id: uuid.UUID
edition_id: uuid.UUID
volunteer_id: uuid.UUID
date: dt.date
start_time: dt.time
end_time: dt.time
created_at: dt.datetime
updated_at: dt.datetime
# --------------------------------------------------------------------------- #
# Assignment #
# --------------------------------------------------------------------------- #
@@ -714,15 +687,12 @@ class StatsArtistWish(ApiModel):
class StatsNeedDay(ApiModel):
"""Par journée de festival (`validation/engine.py:festival_day`) : heures
× personnes demandées (effectif minimal / idéal), heures de disponibilité
déclarées, heures déjà affectées."""
× personnes demandées (effectif minimal / idéal), heures déjà affectées."""
date: dt.date
shifts: int
min_hours: float
ideal_hours: float
available_hours: float
available_volunteers: int
assigned_hours: float
@@ -745,7 +715,6 @@ class StatsNeedPosition(ApiModel):
class StatsNeeds(ApiModel):
by_day: list[StatsNeedDay]
by_position: list[StatsNeedPosition]
volunteers_with_availability: int
volunteers_with_assignment: int
+1 -15
View File
@@ -30,7 +30,6 @@ from app.models import (
Account,
Artist,
Assignment,
Availability,
DayPart,
Edition,
FeePaymentStatus,
@@ -126,9 +125,6 @@ def get_stats(
assignments = list(
session.exec(select(Assignment).where(Assignment.edition_id == edition.id))
)
availabilities = list(
session.exec(select(Availability).where(Availability.edition_id == edition.id))
)
artists = list(
session.exec(
select(Artist)
@@ -203,8 +199,7 @@ def get_stats(
shifts_by_id = {s.id: s for s in shifts}
day_rows: dict[dt.date, dict] = defaultdict(
lambda: {
"shifts": 0, "min_hours": 0.0, "ideal_hours": 0.0, "available_hours": 0.0,
"available_volunteers": set(), "assigned_hours": 0.0,
"shifts": 0, "min_hours": 0.0, "ideal_hours": 0.0, "assigned_hours": 0.0,
}
)
for s in shifts:
@@ -219,12 +214,6 @@ def get_stats(
day_rows[festival_day(s.starts_at, boundary)]["assigned_hours"] += _hours(
s.starts_at, s.ends_at
)
for w in availabilities:
starts_at = dt.datetime.combine(w.date, w.start_time)
end_date = w.date if w.end_time > w.start_time else w.date + dt.timedelta(days=1)
row = day_rows[festival_day(starts_at, boundary)]
row["available_hours"] += _hours(starts_at, dt.datetime.combine(end_date, w.end_time))
row["available_volunteers"].add(w.volunteer_id)
wishers = Counter(w.position_id for w in position_wishes)
assigned_by_shift = Counter(a.shift_id for a in assignments)
@@ -239,8 +228,6 @@ def get_stats(
shifts=row["shifts"],
min_hours=_round(row["min_hours"]),
ideal_hours=_round(row["ideal_hours"]),
available_hours=_round(row["available_hours"]),
available_volunteers=len(row["available_volunteers"]),
assigned_hours=_round(row["assigned_hours"]),
)
for day, row in sorted(day_rows.items())
@@ -260,7 +247,6 @@ def get_stats(
)
for p in positions
],
volunteers_with_availability=len({w.volunteer_id for w in availabilities}),
volunteers_with_assignment=len({a.volunteer_id for a in assignments}),
)
-5
View File
@@ -43,9 +43,6 @@
"import.row.updated_existing": "Bénévole existant mis à jour.",
"import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l'édition.",
"validation.SHIFT_OVERLAP": "Chevauchement avec un autre créneau du bénévole.",
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées le {date} ({uncovered_minutes} min non couvertes).",
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h d'affectation cumulées le {date}.",
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux.",
"notification.no_recipients": "Aucun bénévole ciblé.",
"email.magic_link.subject": "Ton lien de connexion",
"email.magic_link.body": "Bonjour,\n\nVoici ton lien de connexion, valable {minutes} minutes :\n\n{link}\n\nSi tu n'es pas à l'origine de cette demande, ignore simplement cet email.",
@@ -58,8 +55,6 @@
"email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !",
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
"email.planning.shift_line": "- {position} : {start} → {end}",
"availability.date_outside_edition": "La date {date} est hors des dates de l'édition.",
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes.",
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
"badge.image_unreadable": "L'image est illisible ou corrompue.",
"badge.empty_image": "L'image est vide.",
-2
View File
@@ -7,7 +7,6 @@ from app.api.accounts import router as account_router
from app.api.artists import router as artist_router
from app.api.assignments import router as assignment_router
from app.api.auth import router as auth_router
from app.api.availability import router as availability_router
from app.api.badges import router as badge_router
from app.api.day_parts import router as day_part_router
from app.api.editions import router as edition_router
@@ -67,7 +66,6 @@ app.include_router(account_router)
app.include_router(import_router)
app.include_router(artist_router)
app.include_router(volunteer_router)
app.include_router(availability_router)
app.include_router(position_router)
app.include_router(shift_router)
app.include_router(assignment_router)
@@ -0,0 +1,55 @@
"""Suppression des disponibilités déclarées
Revision ID: 0022_drop_availability
Revises: 0021_payment_option_free_amount
Create Date: 2026-10-05
Les fenêtres de disponibilité (`availability`) disparaissent, avec la règle
`OUTSIDE_AVAILABILITY` qui les exploitait. Les données existantes sont
perdues : le `downgrade` recrée la table vide. La case « je suis
disponible » du formulaire public (`volunteer.self_declared_available`)
n'est pas concernée.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0022_drop_availability"
down_revision: str | None = "0021_payment_option_free_amount"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.drop_index(op.f("ix_availability_volunteer_id"), table_name="availability")
op.drop_index(op.f("ix_availability_edition_id"), table_name="availability")
op.drop_table("availability")
def downgrade() -> None:
op.create_table(
"availability",
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
sa.Column("date", sa.Date(), nullable=False),
sa.Column("start_time", sa.Time(), nullable=False),
sa.Column("end_time", sa.Time(), nullable=False),
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"volunteer_id", "date", "start_time", "end_time",
name="uq_availability_window",
),
)
op.create_index(
op.f("ix_availability_edition_id"), "availability", ["edition_id"], unique=False
)
op.create_index(
op.f("ix_availability_volunteer_id"), "availability", ["volunteer_id"], unique=False
)
-2
View File
@@ -9,7 +9,6 @@ from app.models.domain import (
Account,
Artist,
Assignment,
Availability,
BadgeField,
BadgeTemplate,
DayPart,
@@ -41,7 +40,6 @@ __all__ = [
"Artist",
"Assignment",
"AssignmentSource",
"Availability",
"BadgeAlign",
"BadgeField",
"BadgeFieldType",
+2 -38
View File
@@ -184,8 +184,7 @@ class Volunteer(TimestampMixin, table=True):
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
companion_wish_note: str | None = None
# Cases à cocher du formulaire public — purement informatives : aucune
# logique n'y est attachée (pas de lien avec `Availability`, pas de
# workflow de paiement).
# logique n'y est attachée (pas de workflow de paiement).
self_declared_available: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
@@ -237,9 +236,6 @@ class Volunteer(TimestampMixin, table=True):
link_model=VolunteerPositionWish,
sa_relationship_kwargs=_PASSIVE,
)
availabilities: list["Availability"] = Relationship(
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
)
assignments: list["Assignment"] = Relationship(
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
)
@@ -352,43 +348,11 @@ class Shift(TimestampMixin, table=True):
)
class Availability(TimestampMixin, table=True):
"""Déclaration du bénévole : « tel jour, je peux venir de telle à telle
heure ». Fenêtre horaire libre (pas liée aux créneaux types). Plusieurs
fenêtres par jour possibles.
Heures naïves, locales Europe/Paris. `end_time <= start_time` ⇒ la fenêtre
passe minuit (se termine le lendemain). À ne pas confondre avec Assignment
(décision de l'organisation).
"""
__table_args__ = (
UniqueConstraint(
"volunteer_id", "date", "start_time", "end_time",
name="uq_availability_window",
),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE"
)
volunteer_id: uuid.UUID = Field(
foreign_key="volunteer.id", index=True, ondelete="CASCADE"
)
date: dt.date
start_time: dt.time
end_time: dt.time
volunteer: Volunteer = Relationship(back_populates="availabilities")
class Assignment(TimestampMixin, table=True):
"""Décision de l'organisation : « tu tiens tel créneau ».
Existe ⇒ le bénévole est affecté (pas d'étape de confirmation). Retirer une
affectation, c'est supprimer la ligne. À ne pas confondre avec Availability
(déclaration du bénévole).
affectation, c'est supprimer la ligne.
"""
__table_args__ = (
+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:
+11 -43
View File
@@ -6,17 +6,13 @@ coordination qui affecte (dans l'app, via l'écran habituel — le solveur se
contente de faire remonter ces candidats en tête de liste).
Contrainte dure : aucun chevauchement de créneaux pour un même bénévole (règle
`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (disponibilité, repos,
chaînage entre créneaux consécutifs, vœu de poste, vœu de compagnon, vœu
d'artiste, équité de charge) est un terme de l'objectif, pas une contrainte —
comme le moteur de validation, qui reste seul juge au moment où la
coordination confirme une affectation.
Pas de plafond de cumul journalier côté solveur : `DAILY_HOURS_EXCEEDED`
reste vérifiée par la validation, à la confirmation.
`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (chaînage entre
créneaux consécutifs, vœu de poste, vœu de compagnon, vœu d'artiste, équité de
charge) est un terme de l'objectif, pas une contrainte. Aucune limite d'heures
par journée ni de temps de repos : ni le solveur ni la validation n'en
tiennent compte.
"""
import datetime as dt
from ortools.sat.python import cp_model
from app.solver.models import (
@@ -26,22 +22,16 @@ from app.solver.models import (
SolverSolution,
SolverVolunteer,
)
from app.validation.engine import uncovered_minutes
from app.validation.models import ShiftInfo
_EIGHT_HOURS_MIN = 8 * 60
# Poids de l'objectif (unités arbitraires, cf. docstring de `_score`).
# L'écart à un effectif minimal non atteint prime sur tout le reste : le
# solveur doit toujours proposer quelqu'un pour un créneau sous-staffé, quitte
# à ce que la disponibilité soit imparfaite (avertissement, pas un blocage —
# la validation reste seule juge à la confirmation). Au-delà du minimum, un
# candidat mal assorti peut légitimement ne pas être suggéré du tout.
# à ce que le candidat soit mal assorti. Au-delà du minimum, un candidat mal
# assorti peut légitimement ne pas être suggéré du tout.
_URGENT_BASE = 1000 # créneau encore sous son effectif minimal
_NORMAL_BASE = 20 # créneau entre effectif minimal et idéal
_WISH_BONUS = 30
_AVAIL_PENALTY_PER_HALF_HOUR = 5
_REST_PENALTY = 60
_FAIRNESS_PENALTY_PER_HOUR = 2
# Le créneau lui ferait manquer le passage d'un artiste qu'il veut voir.
_ARTIST_CONFLICT_PENALTY = 80
@@ -58,22 +48,6 @@ def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
return a.starts_at < b.ends_at and b.starts_at < a.ends_at
def _has_insufficient_rest(volunteer: SolverVolunteer, shift: SolverShift) -> bool:
candidate = shift.shift_info
for other in volunteer.existing_shifts:
if _overlaps(candidate, other):
continue
if other.ends_at <= candidate.starts_at:
gap = candidate.starts_at - other.ends_at
elif candidate.ends_at <= other.starts_at:
gap = other.starts_at - candidate.ends_at
else:
continue
if gap < dt.timedelta(minutes=_EIGHT_HOURS_MIN):
return True
return False
def _chained_from_predecessor(volunteer: SolverVolunteer, shift: SolverShift) -> bool:
"""Le bénévole a déjà (affectation décidée) l'un des créneaux qui
précèdent immédiatement celui-ci, le même jour — n'importe quel poste :
@@ -88,20 +62,14 @@ def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int:
"""Score d'affinité (bénévole, créneau) : plus haut = meilleur candidat.
Purement additif (aucune interaction entre deux affectations proposées),
ce qui garde le modèle CP-SAT linéaire simple. Repos, chaînage et
compagnons ne sont vérifiés que contre les affectations déjà décidées,
pas entre deux suggestions du même calcul : la validation finale (à la
confirmation) reste seule juge en cas de cas limite entre deux
suggestions (et deux compagnons suggérés ensemble dans le même calcul ne
se renforcent pas mutuellement, faute d'interaction entre variables).
ce qui garde le modèle CP-SAT linéaire simple. Chaînage et compagnons ne
sont vérifiés que contre les affectations déjà décidées, pas entre deux
suggestions du même calcul (deux compagnons suggérés ensemble ne se
renforcent pas mutuellement, faute d'interaction entre variables).
"""
score = _URGENT_BASE if shift.already_assigned < shift.min_staff else _NORMAL_BASE
if shift.position_id in volunteer.wished_position_ids:
score += _WISH_BONUS
missed = uncovered_minutes(shift.shift_info, volunteer.availability)
score -= (missed // 30) * _AVAIL_PENALTY_PER_HALF_HOUR
if _has_insufficient_rest(volunteer, shift):
score -= _REST_PENALTY
if _chained_from_predecessor(volunteer, shift):
score -= _CHAIN_PENALTY
companions_present = len(volunteer.wished_companion_ids & shift.assigned_volunteer_ids)
+1 -11
View File
@@ -9,7 +9,6 @@ from sqlmodel import Session, select
from app.models import (
Artist,
Assignment,
Availability,
DayPart,
Edition,
Shift,
@@ -20,7 +19,7 @@ from app.models import (
)
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
from app.validation.engine import day_boundary, festival_day
from app.validation.models import AvailabilityWindow, ShiftInfo
from app.validation.models import ShiftInfo
def _artist_window(artist: Artist) -> ShiftInfo:
@@ -93,14 +92,6 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
if assigned_count.get(s.id, 0) < s.ideal_staff
)
availabilities: dict = defaultdict(list)
for a in session.exec(
select(Availability).where(Availability.edition_id == edition.id)
):
availabilities[a.volunteer_id].append(
AvailabilityWindow(date=a.date, start_time=a.start_time, end_time=a.end_time)
)
wishes: dict = defaultdict(set)
for w in session.exec(
select(VolunteerPositionWish).join(
@@ -141,7 +132,6 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
solver_volunteers = tuple(
SolverVolunteer(
id=str(v.id),
availability=tuple(availabilities.get(v.id, ())),
wished_position_ids=frozenset(wishes.get(v.id, ())),
wished_companion_ids=frozenset(companion_wishes.get(v.id, ())),
wished_artist_windows=tuple(artist_wishes.get(v.id, ())),
+3 -4
View File
@@ -2,13 +2,13 @@
Le solveur ne connaît que ces structures simples (jamais les modèles
SQLModel), comme le moteur de validation dont il réutilise les briques
(`app.validation.models`, `app.validation.engine.uncovered_minutes`).
(`app.validation.models`).
"""
import datetime as dt
from dataclasses import dataclass, field
from app.validation.models import AvailabilityWindow, ShiftInfo
from app.validation.models import ShiftInfo
@dataclass(frozen=True)
@@ -39,7 +39,6 @@ class SolverShift:
@dataclass(frozen=True)
class SolverVolunteer:
id: str
availability: tuple[AvailabilityWindow, ...] = ()
wished_position_ids: frozenset[str] = frozenset()
# Bénévoles (0 à N) avec qui il voudrait être affecté. Le vœu est saisi à
# sens unique (`VolunteerCompanionWish`) mais rendu réciproque ici par le
@@ -50,7 +49,7 @@ class SolverVolunteer:
# lui ferait manquer le passage.
wished_artist_windows: tuple[ShiftInfo, ...] = ()
# Créneaux déjà occupés (affectations existantes, décisions figées) :
# comptent pour chevauchement, repos, chaînage.
# comptent pour chevauchement et chaînage.
existing_shifts: tuple[ShiftInfo, ...] = ()
# Total déjà affecté sur l'édition (minutes), pour répartir la charge.
existing_minutes: int = 0
-2
View File
@@ -5,7 +5,6 @@ Voir CLAUDE.md § 3 pour les règles et la sémantique.
from app.validation.engine import has_blocking, validate
from app.validation.models import (
AvailabilityWindow,
DayPartWindow,
Severity,
ShiftInfo,
@@ -15,7 +14,6 @@ from app.validation.models import (
)
__all__ = [
"AvailabilityWindow",
"DayPartWindow",
"Severity",
"ShiftInfo",
+2 -107
View File
@@ -7,7 +7,6 @@ sortie. Règles et sémantique figées : voir CLAUDE.md § 3.
import datetime as dt
from app.validation.models import (
AvailabilityWindow,
Severity,
ShiftInfo,
ValidationInput,
@@ -15,15 +14,10 @@ from app.validation.models import (
ViolationCode,
)
_EIGHT_HOURS = dt.timedelta(hours=8)
def validate(data: ValidationInput) -> list[Violation]:
violations: list[Violation] = []
violations.extend(_check_overlap(data))
violations.extend(_check_outside_availability(data))
violations.extend(_check_daily_hours(data))
violations.extend(_check_insufficient_rest(data))
return violations
@@ -55,57 +49,8 @@ def _check_overlap(data: ValidationInput) -> list[Violation]:
# --------------------------------------------------------------------------- #
# OUTSIDE_AVAILABILITY (avertissement) #
# --------------------------------------------------------------------------- #
def _window_interval(w: AvailabilityWindow) -> tuple[dt.datetime, dt.datetime]:
start = dt.datetime.combine(w.date, w.start_time)
end_day = w.date if w.end_time > w.start_time else w.date + dt.timedelta(days=1)
return start, dt.datetime.combine(end_day, w.end_time)
def uncovered_minutes(
shift: ShiftInfo, windows: tuple[AvailabilityWindow, ...]
) -> int:
"""Minutes du créneau qui ne tombent dans aucune fenêtre de disponibilité.
Le bénévole peut déclarer plusieurs fenêtres ; on regarde leur union.
"""
cursor = shift.starts_at
intervals = sorted(
(iv for iv in map(_window_interval, windows) if iv[1] > iv[0]),
key=lambda iv: iv[0],
)
gap = dt.timedelta()
for start, end in intervals:
if end <= cursor:
continue
if start > shift.ends_at:
break
if start > cursor:
gap += min(start, shift.ends_at) - cursor
cursor = max(cursor, end)
if cursor >= shift.ends_at:
return int(gap.total_seconds() // 60)
if cursor < shift.ends_at:
gap += shift.ends_at - cursor
return int(gap.total_seconds() // 60)
def _check_outside_availability(data: ValidationInput) -> list[Violation]:
missing = uncovered_minutes(data.candidate_shift, data.availabilities)
if missing == 0:
return []
return [
Violation.of(
ViolationCode.OUTSIDE_AVAILABILITY,
date=data.candidate_shift.starts_at.date().isoformat(),
uncovered_minutes=missing,
)
]
# --------------------------------------------------------------------------- #
# DAILY_HOURS_EXCEEDED (avertissement) #
# « Journée de festival » — plus utilisée par la validation elle-même, mais #
# partagée par le solveur, les feuilles de poste et les statistiques. #
# --------------------------------------------------------------------------- #
_MINUTES_PER_DAY = 24 * 60
@@ -161,53 +106,3 @@ def festival_day(moment: dt.datetime, boundary: dt.time) -> dt.date:
if moment.time() >= boundary:
return moment.date()
return moment.date() - dt.timedelta(days=1)
def _check_daily_hours(data: ValidationInput) -> list[Violation]:
boundary = day_boundary(data.day_parts)
candidate = data.candidate_shift
key = festival_day(candidate.starts_at, boundary)
total = candidate.ends_at - candidate.starts_at
for other in data.existing_shifts:
if festival_day(other.starts_at, boundary) == key:
total += other.ends_at - other.starts_at
if total > _EIGHT_HOURS:
return [
Violation.of(
ViolationCode.DAILY_HOURS_EXCEEDED,
date=key.isoformat(),
total_minutes=int(total.total_seconds() // 60),
)
]
return []
# --------------------------------------------------------------------------- #
# INSUFFICIENT_REST (avertissement) #
# --------------------------------------------------------------------------- #
def _check_insufficient_rest(data: ValidationInput) -> list[Violation]:
candidate = data.candidate_shift
out: list[Violation] = []
for other in data.existing_shifts:
if _overlaps(candidate, other):
continue
if other.ends_at <= candidate.starts_at:
gap = candidate.starts_at - other.ends_at
direction = "BEFORE"
elif candidate.ends_at <= other.starts_at:
gap = other.starts_at - candidate.ends_at
direction = "AFTER"
else: # pragma: no cover - inatteignable : non-chevauchement ⇒ avant ou après
continue
if gap < _EIGHT_HOURS:
out.append(
Violation.of(
ViolationCode.INSUFFICIENT_REST,
other_shift_id=other.id,
direction=direction,
gap_minutes=int(gap.total_seconds() // 60),
)
)
return out
-24
View File
@@ -8,15 +8,11 @@ from sqlmodel import Session, select
from app.models import (
Assignment,
Availability,
DayPart,
Edition,
Shift,
Volunteer,
)
from app.validation.models import (
AvailabilityWindow,
DayPartWindow,
ShiftInfo,
ValidationInput,
)
@@ -30,10 +26,6 @@ def build_validation_input(
shift: Shift,
exclude_assignment_id: uuid.UUID | None = None,
) -> ValidationInput:
day_parts = list(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
# Toute affectation existante du bénévole compte (une affectation = une
# décision, toujours active).
stmt = (
@@ -49,17 +41,6 @@ def build_validation_input(
for s in session.exec(stmt)
)
declarations = tuple(
AvailabilityWindow(
date=a.date, start_time=a.start_time, end_time=a.end_time
)
for a in session.exec(
select(Availability).where(
Availability.volunteer_id == volunteer.id
)
)
)
return ValidationInput(
candidate_shift=ShiftInfo(
id=str(shift.id),
@@ -67,9 +48,4 @@ def build_validation_input(
ends_at=shift.ends_at,
),
existing_shifts=existing_shifts,
availabilities=declarations,
day_parts=tuple(
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order)
for dp in day_parts
),
)
+2 -22
View File
@@ -18,16 +18,10 @@ class Severity(StrEnum):
class ViolationCode(StrEnum):
SHIFT_OVERLAP = "SHIFT_OVERLAP"
OUTSIDE_AVAILABILITY = "OUTSIDE_AVAILABILITY"
DAILY_HOURS_EXCEEDED = "DAILY_HOURS_EXCEEDED"
INSUFFICIENT_REST = "INSUFFICIENT_REST"
SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
ViolationCode.SHIFT_OVERLAP: Severity.BLOCKING,
ViolationCode.OUTSIDE_AVAILABILITY: Severity.WARNING,
ViolationCode.DAILY_HOURS_EXCEEDED: Severity.WARNING,
ViolationCode.INSUFFICIENT_REST: Severity.WARNING,
}
@@ -53,8 +47,8 @@ class ShiftInfo:
@dataclass(frozen=True)
class DayPartWindow:
"""Créneau type. Ne sert plus qu'à fixer la borne de « journée » pour le
cumul des 8 h (début du premier créneau type)."""
"""Créneau type. Ne sert plus qu'à fixer la borne de « journée de
festival » (`engine.day_boundary`)."""
code: str
start_time: dt.time
@@ -63,18 +57,6 @@ class DayPartWindow:
order: int
@dataclass(frozen=True)
class AvailabilityWindow:
"""« Le <date>, je peux de <start_time> à <end_time>. »
`end_time <= start_time` ⇒ la fenêtre se termine le lendemain.
"""
date: dt.date
start_time: dt.time
end_time: dt.time
@dataclass(frozen=True)
class ValidationInput:
"""Tout ce qu'il faut pour valider une affectation candidate.
@@ -85,5 +67,3 @@ class ValidationInput:
candidate_shift: ShiftInfo
existing_shifts: tuple[ShiftInfo, ...] = ()
availabilities: tuple[AvailabilityWindow, ...] = ()
day_parts: tuple[DayPartWindow, ...] = ()