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, ...] = ()
|
||||
|
||||
Reference in New Issue
Block a user