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
@@ -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,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, ())),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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, ...] = ()
|
||||
|
||||
@@ -4,7 +4,6 @@ import datetime as dt
|
||||
|
||||
from app.models import (
|
||||
Artist,
|
||||
Availability,
|
||||
Position,
|
||||
PositionCategory,
|
||||
Shift,
|
||||
@@ -74,34 +73,5 @@ def make_volunteer(
|
||||
return volunteer
|
||||
|
||||
|
||||
def make_availability(
|
||||
session, edition, volunteer, *, date, start_time, end_time
|
||||
):
|
||||
"""Fenêtre de disponibilité libre. `end_time <= start_time` ⇒ passe minuit."""
|
||||
row = Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=date,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def declare_available_all_day(session, edition, volunteer, date):
|
||||
"""Disponible de 00:00 à 23:59 le jour donné (couvre tout un créneau
|
||||
diurne ; pour la nuit, déclarer une fenêtre dédiée)."""
|
||||
make_availability(
|
||||
session,
|
||||
edition,
|
||||
volunteer,
|
||||
date=date,
|
||||
start_time=dt.time(0, 0),
|
||||
end_time=dt.time(23, 59),
|
||||
)
|
||||
|
||||
|
||||
def dt_at(day: int, hour: int, minute: int = 0) -> dt.datetime:
|
||||
return dt.datetime(2026, 7, day, hour, minute)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import datetime as dt
|
||||
|
||||
from tests.factories import (
|
||||
declare_available_all_day,
|
||||
dt_at,
|
||||
make_position,
|
||||
make_shift,
|
||||
@@ -13,16 +12,12 @@ def _url(edition, suffix=""):
|
||||
return f"/editions/{edition.id}/assignments{suffix}"
|
||||
|
||||
|
||||
def _setup(session, edition, *, available=True):
|
||||
def _setup(session, edition):
|
||||
position = make_position(session, edition)
|
||||
shift = make_shift(
|
||||
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18)
|
||||
)
|
||||
volunteer = make_volunteer(session, edition, email="v@ex.fr")
|
||||
if available:
|
||||
declare_available_all_day(
|
||||
session, edition, volunteer, dt.date(2026, 7, 10)
|
||||
)
|
||||
return volunteer, shift
|
||||
|
||||
|
||||
@@ -39,16 +34,21 @@ def test_create_assignment_clean(client, edition, session, day_parts):
|
||||
assert body["assignment"]["locked"] is False
|
||||
|
||||
|
||||
def test_create_assignment_returns_warnings(client, edition, session, day_parts):
|
||||
# Bénévole non déclaré disponible sur le créneau.
|
||||
volunteer, shift = _setup(session, edition, available=False)
|
||||
resp = client.post(
|
||||
_url(edition),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||
def test_long_day_without_rest_raises_no_warning(client, edition, session, day_parts):
|
||||
# Plus de limite d'heures par jour ni de repos minimal : 14h–18h puis
|
||||
# 18h–04h (14 h d'affilée) passe sans avertissement.
|
||||
volunteer, shift = _setup(session, edition)
|
||||
position = make_position(session, edition, name="Bar")
|
||||
following = make_shift(
|
||||
session, edition, position, start=dt_at(10, 18), end=dt_at(11, 4)
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
codes = {w["code"] for w in resp.json()["warnings"]}
|
||||
assert "OUTSIDE_AVAILABILITY" in codes
|
||||
for s in (shift, following):
|
||||
resp = client.post(
|
||||
_url(edition),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(s.id)},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["warnings"] == []
|
||||
|
||||
|
||||
def test_create_assignment_blocked_by_overlap(client, edition, session, day_parts):
|
||||
@@ -110,7 +110,7 @@ def test_assignment_wrong_edition_is_422(client, edition, session, day_parts):
|
||||
|
||||
|
||||
def test_validate_endpoint_does_not_persist(client, edition, session, day_parts):
|
||||
volunteer, shift = _setup(session, edition, available=False)
|
||||
volunteer, shift = _setup(session, edition)
|
||||
resp = client.post(
|
||||
_url(edition, "/validate"),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||
@@ -118,14 +118,12 @@ def test_validate_endpoint_does_not_persist(client, edition, session, day_parts)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["blocking"] is False
|
||||
assert any(v["code"] == "OUTSIDE_AVAILABILITY" for v in body["violations"])
|
||||
assert body["violations"] == []
|
||||
assert client.get(_url(edition)).json() == []
|
||||
|
||||
|
||||
def test_patch_locked_keeps_assignment_and_returns_warnings(
|
||||
client, edition, session, day_parts
|
||||
):
|
||||
volunteer, shift = _setup(session, edition, available=False)
|
||||
def test_patch_locked_keeps_assignment(client, edition, session, day_parts):
|
||||
volunteer, shift = _setup(session, edition)
|
||||
aid = client.post(
|
||||
_url(edition),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||
@@ -134,9 +132,7 @@ def test_patch_locked_keeps_assignment_and_returns_warnings(
|
||||
resp = client.patch(_url(edition, f"/{aid}"), json={"locked": True})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["assignment"]["locked"] is True
|
||||
assert any(
|
||||
w["code"] == "OUTSIDE_AVAILABILITY" for w in resp.json()["warnings"]
|
||||
)
|
||||
assert resp.json()["warnings"] == []
|
||||
|
||||
|
||||
def test_delete_assignment(client, edition, session, day_parts):
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
from app.models import Role
|
||||
from tests.factories import make_volunteer
|
||||
|
||||
|
||||
def _url(edition, volunteer, suffix=""):
|
||||
return f"/editions/{edition.id}/volunteers/{volunteer.id}/availability{suffix}"
|
||||
|
||||
|
||||
def test_put_then_get_returns_windows_sorted(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-11", "start_time": "14:00", "end_time": "19:00"},
|
||||
{"date": "2026-07-10", "start_time": "08:00", "end_time": "13:00"},
|
||||
{"date": "2026-07-10", "start_time": "19:00", "end_time": "02:00"},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
got = [(w["date"], w["start_time"], w["end_time"]) for w in resp.json()]
|
||||
assert got == [
|
||||
("2026-07-10", "08:00:00", "13:00:00"),
|
||||
("2026-07-10", "19:00:00", "02:00:00"),
|
||||
("2026-07-11", "14:00:00", "19:00:00"),
|
||||
]
|
||||
assert client.get(_url(edition, v)).json() == resp.json()
|
||||
|
||||
|
||||
def test_put_replaces_the_whole_set_and_dedupes(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-10", "start_time": "08:00", "end_time": "12:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-11", "start_time": "09:00", "end_time": "17:00"},
|
||||
{"date": "2026-07-11", "start_time": "09:00", "end_time": "17:00"},
|
||||
]
|
||||
},
|
||||
)
|
||||
body = resp.json()
|
||||
assert len(body) == 1
|
||||
assert body[0]["date"] == "2026-07-11"
|
||||
|
||||
|
||||
def test_put_rejects_date_outside_edition(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-08-01", "start_time": "08:00", "end_time": "12:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "availability.date_outside_edition"
|
||||
|
||||
|
||||
def test_put_rejects_empty_window(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-10", "start_time": "09:00", "end_time": "09:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "availability.empty_window"
|
||||
|
||||
|
||||
def test_edition_wide_listing(client, edition, session):
|
||||
v1 = make_volunteer(session, edition, first_name="A", email="a@e.fr")
|
||||
v2 = make_volunteer(session, edition, first_name="B", email="b@e.fr")
|
||||
for v in (v1, v2):
|
||||
client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-10", "start_time": "08:00", "end_time": "13:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
body = client.get(f"/editions/{edition.id}/availability").json()
|
||||
assert {w["volunteer_id"] for w in body} == {str(v1.id), str(v2.id)}
|
||||
|
||||
|
||||
def test_lead_can_read_but_not_write(edition, session, make_account, as_account):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
api = as_account(lead)
|
||||
assert api.get(_url(edition, v)).status_code == 200
|
||||
resp = api.put(_url(edition, v), json={"windows": []})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_volunteer_reads_only_their_own(edition, session, make_account, as_account):
|
||||
me = make_volunteer(session, edition, email="me@e.fr")
|
||||
other = make_volunteer(session, edition, email="other@e.fr")
|
||||
account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="acc@e.fr")
|
||||
api = as_account(account)
|
||||
assert api.get(_url(edition, me)).status_code == 200
|
||||
assert api.get(_url(edition, other)).status_code == 403
|
||||
@@ -14,7 +14,6 @@ from app.models import (
|
||||
from tests.factories import (
|
||||
dt_at,
|
||||
make_artist,
|
||||
make_availability,
|
||||
make_position,
|
||||
make_shift,
|
||||
make_volunteer,
|
||||
@@ -141,10 +140,6 @@ def test_needs_are_grouped_by_festival_day(client, edition, session):
|
||||
)
|
||||
alex = make_volunteer(session, edition, first_name="Alex")
|
||||
sam = make_volunteer(session, edition, first_name="Sam")
|
||||
make_availability(
|
||||
session, edition, alex,
|
||||
date=dt.date(2026, 7, 9), start_time=dt.time(20, 0), end_time=dt.time(2, 0),
|
||||
)
|
||||
session.add_all([
|
||||
Assignment(edition_id=edition.id, volunteer_id=alex.id, shift_id=late.id),
|
||||
VolunteerPositionWish(volunteer_id=alex.id, position_id=bar.id),
|
||||
@@ -159,8 +154,6 @@ def test_needs_are_grouped_by_festival_day(client, edition, session):
|
||||
"shifts": 2,
|
||||
"min_hours": 3.0,
|
||||
"ideal_hours": 5.0,
|
||||
"available_hours": 6.0,
|
||||
"available_volunteers": 1,
|
||||
"assigned_hours": 2.0,
|
||||
}
|
||||
]
|
||||
@@ -176,7 +169,6 @@ def test_needs_are_grouped_by_festival_day(client, edition, session):
|
||||
"wishes": 2,
|
||||
}
|
||||
]
|
||||
assert needs["volunteers_with_availability"] == 1
|
||||
assert needs["volunteers_with_assignment"] == 1
|
||||
|
||||
|
||||
|
||||
@@ -6,11 +6,10 @@ from openpyxl import load_workbook
|
||||
from pypdf import PdfReader
|
||||
from sqlmodel import select
|
||||
|
||||
from app.models import Assignment, Availability, FeePaymentStatus, Role, Volunteer
|
||||
from app.models import Assignment, FeePaymentStatus, Role, Volunteer
|
||||
from tests.factories import (
|
||||
dt_at,
|
||||
make_artist,
|
||||
make_availability,
|
||||
make_position,
|
||||
make_shift,
|
||||
make_volunteer,
|
||||
@@ -208,14 +207,6 @@ def test_delete_is_a_full_cascade(client, edition, session):
|
||||
shift = make_shift(
|
||||
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
|
||||
)
|
||||
make_availability(
|
||||
session,
|
||||
edition,
|
||||
volunteer,
|
||||
date=dt.date(2026, 7, 10),
|
||||
start_time=dt.time(8, 0),
|
||||
end_time=dt.time(13, 0),
|
||||
)
|
||||
session.add(
|
||||
Assignment(
|
||||
edition_id=edition.id,
|
||||
@@ -230,7 +221,6 @@ def test_delete_is_a_full_cascade(client, edition, session):
|
||||
assert client.get(_url(edition, f"/{volunteer.id}")).status_code == 404
|
||||
assert session.exec(select(Volunteer)).all() == []
|
||||
assert session.exec(select(Assignment)).all() == []
|
||||
assert session.exec(select(Availability)).all() == []
|
||||
# Le créneau subsiste.
|
||||
from app.models import Shift
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from sqlalchemy import text
|
||||
from app.models import (
|
||||
Assignment,
|
||||
AssignmentSource,
|
||||
Availability,
|
||||
Edition,
|
||||
Position,
|
||||
PositionCategory,
|
||||
@@ -25,7 +24,6 @@ EXPECTED_TABLES = {
|
||||
"magic_link_token",
|
||||
"position",
|
||||
"shift",
|
||||
"availability",
|
||||
"assignment",
|
||||
"badge_template",
|
||||
"badge_field",
|
||||
@@ -101,31 +99,6 @@ def test_create_core_graph(session):
|
||||
assert volunteer.assignments[0].id == assignment.id
|
||||
|
||||
|
||||
def test_availability_window_round_trip(session):
|
||||
edition = _edition(session)
|
||||
volunteer = Volunteer(
|
||||
edition_id=edition.id, first_name="Sam", last_name="Nuit"
|
||||
)
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
session.refresh(volunteer)
|
||||
|
||||
avail = Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=dt.date(2026, 7, 9),
|
||||
start_time=dt.time(19, 0),
|
||||
end_time=dt.time(2, 0), # passe minuit
|
||||
)
|
||||
session.add(avail)
|
||||
session.commit()
|
||||
session.expire_all()
|
||||
|
||||
reloaded = session.get(Availability, avail.id)
|
||||
assert reloaded.start_time == dt.time(19, 0)
|
||||
assert reloaded.end_time == dt.time(2, 0)
|
||||
|
||||
|
||||
def test_volunteer_email_can_be_shared(session):
|
||||
# Un couple, une famille : même boîte mail, deux bénévoles distincts —
|
||||
# ce n'est plus une contrainte d'unicité en base (voir migration 0009).
|
||||
|
||||
@@ -13,9 +13,6 @@ def test_build_creates_a_realistic_dataset(session):
|
||||
assert summary.assignments > 100
|
||||
assert summary.understaffed_shifts >= 3 # « quelques trous d'effectif »
|
||||
assert summary.blocking == 0 # aucune violation bloquante
|
||||
# Au moins deux règles d'avertissement représentées, quelques alertes.
|
||||
assert len(summary.warnings) >= 2
|
||||
assert sum(summary.warnings.values()) >= 3
|
||||
# Programmation et vœux des bénévoles : le mode démo doit les illustrer.
|
||||
assert summary.artists > 0
|
||||
assert summary.position_wishes > 0
|
||||
@@ -58,7 +55,6 @@ def test_build_is_deterministic_and_idempotent(session):
|
||||
)
|
||||
assert second.assignments == first.assignments
|
||||
assert second.understaffed_shifts == first.understaffed_shifts
|
||||
assert dict(second.warnings) == dict(first.warnings)
|
||||
assert second.artists == first.artists
|
||||
assert second.position_wishes == first.position_wishes
|
||||
assert second.artist_wishes == first.artist_wishes
|
||||
|
||||
@@ -4,7 +4,7 @@ import datetime as dt
|
||||
|
||||
from app.solver.engine import solve
|
||||
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
|
||||
from app.validation.models import AvailabilityWindow, ShiftInfo
|
||||
from app.validation.models import ShiftInfo
|
||||
|
||||
|
||||
def _dt(day: int, hour: int, minute: int = 0) -> dt.datetime:
|
||||
@@ -39,7 +39,6 @@ def _shift(
|
||||
def _volunteer(
|
||||
vid: str,
|
||||
*,
|
||||
availability=(),
|
||||
wished_position_ids=frozenset(),
|
||||
wished_companion_ids=frozenset(),
|
||||
wished_artist_windows=(),
|
||||
@@ -48,7 +47,6 @@ def _volunteer(
|
||||
) -> SolverVolunteer:
|
||||
return SolverVolunteer(
|
||||
id=vid,
|
||||
availability=tuple(availability),
|
||||
wished_position_ids=frozenset(wished_position_ids),
|
||||
wished_companion_ids=frozenset(wished_companion_ids),
|
||||
wished_artist_windows=tuple(wished_artist_windows),
|
||||
@@ -57,10 +55,6 @@ def _volunteer(
|
||||
)
|
||||
|
||||
|
||||
def _full_day(day: int) -> AvailabilityWindow:
|
||||
return AvailabilityWindow(dt.date(2026, 7, day), dt.time(6, 0), dt.time(4, 0))
|
||||
|
||||
|
||||
def _by_shift(solution, shift_id: str) -> tuple[str, ...]:
|
||||
for s in solution.suggestions:
|
||||
if s.shift_id == shift_id:
|
||||
@@ -75,7 +69,7 @@ def test_no_variables_returns_empty_solution():
|
||||
|
||||
def test_fully_staffed_shift_gets_no_suggestion():
|
||||
shift = _shift(ideal_staff=1, already_assigned=1)
|
||||
volunteer = _volunteer("v1", availability=[_full_day(10)])
|
||||
volunteer = _volunteer("v1")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,)))
|
||||
assert solution.suggestions == ()
|
||||
|
||||
@@ -84,105 +78,77 @@ def test_picks_the_only_free_volunteer_over_a_busy_one():
|
||||
shift = _shift()
|
||||
busy = _volunteer(
|
||||
"busy",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 15), ends_at=_dt(10, 19))],
|
||||
)
|
||||
free = _volunteer("free", availability=[_full_day(10)])
|
||||
free = _volunteer("free")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(busy, free)))
|
||||
assert _by_shift(solution, "s1") == ("free",)
|
||||
|
||||
|
||||
def test_does_not_exceed_ideal_staff_cap():
|
||||
shift = _shift(ideal_staff=2, already_assigned=1) # il ne manque qu'une place
|
||||
a = _volunteer("a", availability=[_full_day(10)])
|
||||
b = _volunteer("b", availability=[_full_day(10)])
|
||||
a = _volunteer("a")
|
||||
b = _volunteer("b")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(a, b)))
|
||||
assert len(_by_shift(solution, "s1")) == 1
|
||||
|
||||
|
||||
def test_prefers_volunteer_with_matching_position_wish():
|
||||
shift = _shift(position_id="regie")
|
||||
plain = _volunteer("plain", availability=[_full_day(10)])
|
||||
plain = _volunteer("plain")
|
||||
wisher = _volunteer(
|
||||
"wisher", availability=[_full_day(10)], wished_position_ids={"regie"}
|
||||
"wisher", wished_position_ids={"regie"}
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(plain, wisher)))
|
||||
assert _by_shift(solution, "s1") == ("wisher",)
|
||||
|
||||
|
||||
def test_prefers_fully_available_volunteer_over_partially_available():
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
full = _volunteer("full", availability=[_full_day(10)])
|
||||
partial = _volunteer(
|
||||
"partial",
|
||||
availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))],
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial)))
|
||||
assert _by_shift(solution, "s1") == ("full",)
|
||||
|
||||
|
||||
def test_ranks_both_candidates_when_two_slots_are_open():
|
||||
shift = _shift(ideal_staff=2)
|
||||
full = _volunteer("full", availability=[_full_day(10)])
|
||||
partial = _volunteer(
|
||||
"partial",
|
||||
availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))],
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial)))
|
||||
assert _by_shift(solution, "s1") == ("full", "partial")
|
||||
|
||||
|
||||
def test_never_double_books_a_volunteer_across_two_new_suggestions():
|
||||
# Deux créneaux qui se chevauchent, un seul bénévole disponible pour les
|
||||
# deux : le solveur ne le propose qu'une fois.
|
||||
shift_a = _shift("a", start=_dt(10, 14), end=_dt(10, 18))
|
||||
shift_b = _shift("b", start=_dt(10, 16), end=_dt(10, 20))
|
||||
only = _volunteer("only", availability=[_full_day(10)])
|
||||
only = _volunteer("only")
|
||||
solution = solve(SolverInput(shifts=(shift_a, shift_b), volunteers=(only,)))
|
||||
picked_a = _by_shift(solution, "a")
|
||||
picked_b = _by_shift(solution, "b")
|
||||
assert len(picked_a) + len(picked_b) == 1
|
||||
|
||||
|
||||
def test_avoids_volunteer_with_insufficient_rest_since_last_shift():
|
||||
# < 8 h entre la fin d'un créneau déjà décidé et le début de celui-ci.
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
def test_short_rest_is_not_penalized():
|
||||
# Plus de repos minimal (8 h) : 4 h après un créneau déjà décidé, le
|
||||
# bénévole reste à égalité avec un autre.
|
||||
shift = _shift(ideal_staff=2, start=_dt(10, 14), end=_dt(10, 18))
|
||||
tired = _volunteer(
|
||||
"tired",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 8), ends_at=_dt(10, 10))], # repos 4 h
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 8), ends_at=_dt(10, 10))],
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
fresh = _volunteer("fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(tired, fresh)))
|
||||
assert _by_shift(solution, "s1") == ("fresh",)
|
||||
assert set(_by_shift(solution, "s1")) == {"tired", "fresh"}
|
||||
|
||||
|
||||
def test_daily_cumulative_hours_no_longer_penalized():
|
||||
# Le solveur ne plafonne plus le cumul de 8 h/jour (retiré) : seule la
|
||||
# validation, à la confirmation, le signale désormais
|
||||
# (`DAILY_HOURS_EXCEEDED`). Repos respecté (8 h pile) ; cumul du jour
|
||||
# (7 h + 2 h = 9 h) aurait auparavant été pénalisé.
|
||||
# Aucun plafond de cumul journalier : 7 h + 2 h = 9 h sur la journée ne
|
||||
# pénalise pas le bénévole.
|
||||
shift = _shift(ideal_staff=2, start=_dt(10, 21), end=_dt(10, 23))
|
||||
long_day = _volunteer(
|
||||
"long_day",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 6), ends_at=_dt(10, 13))],
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
fresh = _volunteer("fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(long_day, fresh)))
|
||||
assert set(_by_shift(solution, "s1")) == {"long_day", "fresh"}
|
||||
|
||||
|
||||
def test_prefers_volunteer_without_the_immediately_preceding_shift():
|
||||
# Repos suffisant (8 h pile) entre les deux créneaux : seul le chaînage
|
||||
# doit différencier les deux bénévoles.
|
||||
# Seul le chaînage doit différencier les deux bénévoles.
|
||||
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"})
|
||||
chained = _volunteer(
|
||||
"chained",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
fresh = _volunteer("fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained, fresh)))
|
||||
assert _by_shift(solution, "s1") == ("fresh",)
|
||||
|
||||
@@ -196,10 +162,9 @@ def test_chain_penalty_applies_across_different_positions():
|
||||
)
|
||||
chained = _volunteer(
|
||||
"chained",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
fresh = _volunteer("fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained, fresh)))
|
||||
assert _by_shift(solution, "s1") == ("fresh",)
|
||||
|
||||
@@ -210,7 +175,6 @@ def test_still_suggests_the_only_candidate_despite_chaining():
|
||||
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"})
|
||||
chained = _volunteer(
|
||||
"chained",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained,)))
|
||||
@@ -227,9 +191,9 @@ def test_an_unrelated_existing_shift_does_not_trigger_the_chain_penalty():
|
||||
)
|
||||
unrelated = ShiftInfo(id="not-the-predecessor", starts_at=_dt(10, 8), ends_at=_dt(10, 12))
|
||||
almost_chained = _volunteer(
|
||||
"almost_chained", availability=[_full_day(10)], existing_shifts=[unrelated]
|
||||
"almost_chained", existing_shifts=[unrelated]
|
||||
)
|
||||
truly_fresh = _volunteer("truly_fresh", availability=[_full_day(10)])
|
||||
truly_fresh = _volunteer("truly_fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(almost_chained, truly_fresh)))
|
||||
assert set(_by_shift(solution, "s1")) == {"almost_chained", "truly_fresh"}
|
||||
|
||||
@@ -241,9 +205,9 @@ def test_avoids_volunteer_whose_wished_artist_plays_during_the_shift():
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
passage = ShiftInfo(id="art1", starts_at=_dt(10, 16), ends_at=_dt(10, 17))
|
||||
fan = _volunteer(
|
||||
"fan", availability=[_full_day(10)], wished_artist_windows=[passage]
|
||||
"fan", wished_artist_windows=[passage]
|
||||
)
|
||||
indiff = _volunteer("indiff", availability=[_full_day(10)])
|
||||
indiff = _volunteer("indiff")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan, indiff)))
|
||||
assert _by_shift(solution, "s1") == ("indiff",)
|
||||
|
||||
@@ -254,7 +218,7 @@ def test_still_suggests_the_only_candidate_despite_an_artist_conflict():
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
passage = ShiftInfo(id="art1", starts_at=_dt(10, 16), ends_at=_dt(10, 17))
|
||||
fan = _volunteer(
|
||||
"fan", availability=[_full_day(10)], wished_artist_windows=[passage]
|
||||
"fan", wished_artist_windows=[passage]
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,)))
|
||||
assert _by_shift(solution, "s1") == ("fan",)
|
||||
@@ -266,7 +230,7 @@ def test_artist_conflict_outside_shift_hours_has_no_effect():
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
passage = ShiftInfo(id="art1", starts_at=_dt(10, 20), ends_at=_dt(10, 21))
|
||||
fan = _volunteer(
|
||||
"fan", availability=[_full_day(10)], wished_artist_windows=[passage]
|
||||
"fan", wished_artist_windows=[passage]
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,)))
|
||||
assert _by_shift(solution, "s1") == ("fan",)
|
||||
@@ -277,9 +241,9 @@ def test_prefers_volunteer_whose_wished_companion_is_already_on_the_shift():
|
||||
# préféré à « stranger », qui n'a pas ce lien.
|
||||
shift = _shift(ideal_staff=2, already_assigned=1, assigned_volunteer_ids={"copain"})
|
||||
buddy = _volunteer(
|
||||
"buddy", availability=[_full_day(10)], wished_companion_ids={"copain"}
|
||||
"buddy", wished_companion_ids={"copain"}
|
||||
)
|
||||
stranger = _volunteer("stranger", availability=[_full_day(10)])
|
||||
stranger = _volunteer("stranger")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(buddy, stranger)))
|
||||
assert _by_shift(solution, "s1") == ("buddy",)
|
||||
|
||||
@@ -290,10 +254,10 @@ def test_companion_bonus_scales_with_number_of_matches():
|
||||
ideal_staff=4, already_assigned=2, assigned_volunteer_ids={"a", "b"}
|
||||
)
|
||||
two_friends = _volunteer(
|
||||
"two_friends", availability=[_full_day(10)], wished_companion_ids={"a", "b"}
|
||||
"two_friends", wished_companion_ids={"a", "b"}
|
||||
)
|
||||
one_friend = _volunteer(
|
||||
"one_friend", availability=[_full_day(10)], wished_companion_ids={"a"}
|
||||
"one_friend", wished_companion_ids={"a"}
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(two_friends, one_friend)))
|
||||
assert _by_shift(solution, "s1") == ("two_friends", "one_friend")
|
||||
@@ -302,7 +266,7 @@ def test_companion_bonus_scales_with_number_of_matches():
|
||||
def test_wished_companion_absent_from_the_shift_has_no_effect():
|
||||
shift = _shift()
|
||||
volunteer = _volunteer(
|
||||
"v", availability=[_full_day(10)], wished_companion_ids={"not-here"}
|
||||
"v", wished_companion_ids={"not-here"}
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,)))
|
||||
assert _by_shift(solution, "s1") == ("v",)
|
||||
|
||||
@@ -4,7 +4,6 @@ règle, plus les cas limites de la sémantique figée (CLAUDE.md § 3)."""
|
||||
import datetime as dt
|
||||
|
||||
from app.validation import (
|
||||
AvailabilityWindow,
|
||||
DayPartWindow,
|
||||
Severity,
|
||||
ShiftInfo,
|
||||
@@ -33,14 +32,6 @@ def _codes(violations) -> list[ViolationCode]:
|
||||
return [v.code for v in violations]
|
||||
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SHIFT_OVERLAP #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -48,233 +39,42 @@ 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)),),
|
||||
availabilities=(_win(10, 8, 23),),
|
||||
)
|
||||
assert ViolationCode.SHIFT_OVERLAP not in _codes(validate(data))
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
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]
|
||||
assert len(overlap) == 1
|
||||
assert overlap[0].severity is Severity.BLOCKING
|
||||
assert overlap[0].params["other_shift_id"] == "e"
|
||||
assert _codes(violations) == [ViolationCode.SHIFT_OVERLAP]
|
||||
assert violations[0].severity is Severity.BLOCKING
|
||||
assert violations[0].params["other_shift_id"] == "e"
|
||||
assert has_blocking(violations) is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# OUTSIDE_AVAILABILITY #
|
||||
# Plus de limite de temps dans la journée #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_availability_pass_when_shift_within_declared_window():
|
||||
def test_long_day_and_short_rest_raise_nothing():
|
||||
"""Ni cumul journalier (> 8 h) ni repos minimal (< 8 h) : seuls les
|
||||
chevauchements sont signalés."""
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
availabilities=(_win(10, 13, 19),),
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 22)),
|
||||
existing_shifts=(
|
||||
_shift("matin", _dt(10, 6), _dt(10, 14)),
|
||||
_shift("nuit", _dt(10, 23), _dt(11, 3)),
|
||||
),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_availability_pass_when_window_exactly_matches_shift():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
availabilities=(_win(10, 14, 18),),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_availability_triggers_when_nothing_declared():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert violations[0].severity is Severity.WARNING
|
||||
assert violations[0].params["date"] == "2026-07-10"
|
||||
assert violations[0].params["uncovered_minutes"] == 240
|
||||
|
||||
|
||||
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, 16), _dt(10, 20)),
|
||||
availabilities=(_win(10, 13, 19),),
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert violations[0].params["uncovered_minutes"] == 60
|
||||
|
||||
|
||||
def test_availability_union_of_several_windows_covers_the_shift():
|
||||
data = ValidationInput(
|
||||
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_midnight_crossing_window_covers_night_shift():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 22), _dt(11, 2)),
|
||||
availabilities=(_win(10, 19, 4),), # 19:00 -> 04:00 le lendemain
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# DAILY_HOURS_EXCEEDED #
|
||||
# --------------------------------------------------------------------------- #
|
||||
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=(_win(10, 8, 18),),
|
||||
)
|
||||
assert ViolationCode.DAILY_HOURS_EXCEEDED not in _codes(validate(data))
|
||||
|
||||
|
||||
def test_daily_hours_triggers_above_eight():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h
|
||||
existing_shifts=(_shift("e", _dt(10, 20), _dt(10, 20, 30)),), # +30 min
|
||||
day_parts=DAY_PARTS,
|
||||
)
|
||||
violations = [
|
||||
v for v in validate(data) if v.code is ViolationCode.DAILY_HOURS_EXCEEDED
|
||||
]
|
||||
assert len(violations) == 1
|
||||
assert violations[0].params["total_minutes"] == 510
|
||||
assert violations[0].params["date"] == "2026-07-10"
|
||||
|
||||
|
||||
def test_daily_hours_day_starts_at_first_day_part_boundary():
|
||||
# Frontière de journée = 06:00 (début de MORNING). Un créneau de nuit qui
|
||||
# commence à 03:00 le 11 appartient à la journée du 10.
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 8), _dt(10, 15)), # 7 h, journée 10
|
||||
existing_shifts=(_shift("e", _dt(11, 3), _dt(11, 5)),), # 2 h, journée 10
|
||||
day_parts=DAY_PARTS,
|
||||
)
|
||||
violations = [
|
||||
v for v in validate(data) if v.code is ViolationCode.DAILY_HOURS_EXCEEDED
|
||||
]
|
||||
assert len(violations) == 1
|
||||
assert violations[0].params["date"] == "2026-07-10"
|
||||
|
||||
|
||||
def test_daily_hours_ignores_other_days():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h journée 10
|
||||
existing_shifts=(_shift("e", _dt(9, 9), _dt(9, 17)),), # 8 h journée 9
|
||||
day_parts=DAY_PARTS,
|
||||
)
|
||||
assert ViolationCode.DAILY_HOURS_EXCEEDED not in _codes(validate(data))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# INSUFFICIENT_REST #
|
||||
# --------------------------------------------------------------------------- #
|
||||
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
|
||||
availabilities=(_win(10, 8, 12),),
|
||||
)
|
||||
assert ViolationCode.INSUFFICIENT_REST not in _codes(validate(data))
|
||||
|
||||
|
||||
def test_rest_pass_at_exactly_eight_hours():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 4), _dt(10, 12)),
|
||||
existing_shifts=(_shift("e", _dt(10, 20), _dt(10, 22)),), # écart 8 h pile
|
||||
)
|
||||
assert ViolationCode.INSUFFICIENT_REST not in _codes(validate(data))
|
||||
|
||||
|
||||
def test_rest_triggers_for_shift_before():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 14)),
|
||||
existing_shifts=(_shift("e", _dt(10, 2), _dt(10, 6)),), # écart 4 h
|
||||
)
|
||||
violations = [
|
||||
v for v in validate(data) if v.code is ViolationCode.INSUFFICIENT_REST
|
||||
]
|
||||
assert len(violations) == 1
|
||||
assert violations[0].params["direction"] == "BEFORE"
|
||||
assert violations[0].params["gap_minutes"] == 240
|
||||
|
||||
|
||||
def test_rest_triggers_for_shift_after():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 14)),
|
||||
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 20)),), # écart 4 h
|
||||
)
|
||||
violations = [
|
||||
v for v in validate(data) if v.code is ViolationCode.INSUFFICIENT_REST
|
||||
]
|
||||
assert len(violations) == 1
|
||||
assert violations[0].params["direction"] == "AFTER"
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Combinaisons #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_clean_assignment_has_no_violation():
|
||||
data = ValidationInput(
|
||||
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=(_win(10, 13, 19),),
|
||||
)
|
||||
data = ValidationInput(candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)))
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_multiple_rules_fire_together():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),), # chevauchement
|
||||
# aucune dispo déclarée ⇒ OUTSIDE_AVAILABILITY
|
||||
)
|
||||
codes = set(_codes(validate(data)))
|
||||
assert {
|
||||
ViolationCode.SHIFT_OVERLAP,
|
||||
ViolationCode.OUTSIDE_AVAILABILITY,
|
||||
} <= 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)),
|
||||
# pas de dispo ⇒ un seul avertissement OUTSIDE_AVAILABILITY
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert has_blocking(violations) is False
|
||||
assert has_blocking([]) is False
|
||||
|
||||
|
||||
# --- Début de « journée de festival » (day_boundary) ------------------------
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import datetime as dt
|
||||
|
||||
from app.models import Assignment
|
||||
from app.validation import AvailabilityWindow
|
||||
from app.validation.loader import build_validation_input
|
||||
from tests.factories import (
|
||||
dt_at,
|
||||
make_availability,
|
||||
make_position,
|
||||
make_shift,
|
||||
make_volunteer,
|
||||
@@ -21,11 +17,6 @@ def test_build_input_gathers_everything_from_db(client, edition, session, day_pa
|
||||
session, edition, position, start=dt_at(11, 9), end=dt_at(11, 12)
|
||||
)
|
||||
volunteer = make_volunteer(session, edition, email="v@ex.fr")
|
||||
make_availability(
|
||||
session, edition, volunteer,
|
||||
date=dt.date(2026, 7, 10),
|
||||
start_time=dt.time(13, 0), end_time=dt.time(19, 0),
|
||||
)
|
||||
session.add(
|
||||
Assignment(
|
||||
edition_id=edition.id, volunteer_id=volunteer.id,
|
||||
@@ -41,10 +32,6 @@ def test_build_input_gathers_everything_from_db(client, edition, session, day_pa
|
||||
assert data.candidate_shift.id == str(candidate.id)
|
||||
# Toute affectation existante compte, sauf le créneau candidat lui-même.
|
||||
assert [s.id for s in data.existing_shifts] == [str(other_shift.id)]
|
||||
assert len(data.day_parts) == 3
|
||||
assert data.availabilities == (
|
||||
AvailabilityWindow(dt.date(2026, 7, 10), dt.time(13, 0), dt.time(19, 0)),
|
||||
)
|
||||
|
||||
|
||||
def test_exclude_assignment_id_is_honoured(client, edition, session, day_parts):
|
||||
|
||||
Reference in New Issue
Block a user