Artiste : heure de passage ; vœux d'artistes pris en compte par le solveur

Artist gagne start_time/end_time (end <= start ⇒ finit le lendemain,
même convention que Shift/DayPart/Availability). Migration 0008.

Le solveur (app/solver/) résout maintenant les fenêtres de passage des
artistes que chaque bénévole veut voir (VolunteerArtist) et pénalise
toute suggestion qui chevaucherait un passage souhaité — le bénévole
raterait l'artiste. Comme le reste du scoring, c'est un terme de
l'objectif, pas un blocage : pour un créneau urgent sans autre
candidat, le solveur propose quand même, quitte à ce que ce soit un
pis-aller.

- SolverVolunteer.wished_artist_windows (fenêtres ShiftInfo précalculées
  par le loader depuis Artist + VolunteerArtist).
- Nouveau malus _ARTIST_CONFLICT_PENALTY dans engine.py.
- Tests : moteur (conflit détecté/ignoré hors chevauchement/toujours
  suggéré en dernier recours) + intégration API bout en bout.

176 tests, ruff et alembic check au vert. CLAUDE.md mis à jour (§2 et §4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-11 18:13:54 +02:00
co-authored by Claude Sonnet 5
parent e47c29d35b
commit fea9d37019
12 changed files with 212 additions and 35 deletions
+7 -3
View File
@@ -1,8 +1,10 @@
"""CRUD de la programmation (`Artist`) d'une édition.
La coordination constitue la liste ; elle est présentée par jour de passage
(`performance_date`). Les bénévoles y choisissent les artistes qu'ils veulent
voir (vœux stockés dans `volunteer_artist`, exposés par la fiche bénévole)."""
La coordination constitue la liste ; elle est présentée par jour et heure de
passage (`performance_date`, `start_time`, `end_time`). Les bénévoles y
choisissent les artistes qu'ils veulent voir (vœux stockés dans
`volunteer_artist`, exposés par la fiche bénévole) — le solveur en tient
compte pour éviter de les affecter pendant le passage."""
import uuid
@@ -51,6 +53,8 @@ def create_artist(
edition_id=edition.id,
name=name,
performance_date=payload.performance_date,
start_time=payload.start_time,
end_time=payload.end_time,
)
session.add(artist)
try:
+7
View File
@@ -85,11 +85,16 @@ class VolunteerRead(ApiModel):
class ArtistCreate(ApiModel):
name: str
performance_date: dt.date
# end_time <= start_time ⇒ le passage finit le lendemain.
start_time: dt.time
end_time: dt.time
class ArtistUpdate(ApiModel):
name: str | None = None
performance_date: dt.date | None = None
start_time: dt.time | None = None
end_time: dt.time | None = None
class ArtistRead(ApiModel):
@@ -97,6 +102,8 @@ class ArtistRead(ApiModel):
edition_id: uuid.UUID
name: str
performance_date: dt.date
start_time: dt.time
end_time: dt.time
created_at: dt.datetime
updated_at: dt.datetime
@@ -0,0 +1,33 @@
"""Artiste : heure de passage
Revision ID: 0008_artist_performance_time
Revises: 0007_drop_skills_avail_windows
Create Date: 2026-09-11
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0008_artist_performance_time"
down_revision: str | None = "0007_drop_skills_avail_windows"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"artist",
sa.Column("start_time", sa.Time(), server_default="20:00:00", nullable=False),
)
op.add_column(
"artist",
sa.Column("end_time", sa.Time(), server_default="21:00:00", nullable=False),
)
def downgrade() -> None:
op.drop_column("artist", "end_time")
op.drop_column("artist", "start_time")
+8 -1
View File
@@ -9,7 +9,7 @@ au niveau base (`ondelete="CASCADE"`) ; les relations ORM utilisent
import datetime as dt
import uuid
from sqlalchemy import Boolean, Column, Integer
from sqlalchemy import Boolean, Column, Integer, Time
from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
@@ -117,6 +117,13 @@ class Artist(TimestampMixin, table=True):
name: str
# Jour de passage, dans les dates de l'édition.
performance_date: dt.date = Field(index=True)
# Heure de passage. end_time <= start_time ⇒ le passage finit le lendemain.
start_time: dt.time = Field(
sa_column=Column(Time, nullable=False, server_default="20:00:00")
)
end_time: dt.time = Field(
sa_column=Column(Time, nullable=False, server_default="21:00:00")
)
fans: list["Volunteer"] = Relationship(
back_populates="wished_artists",
+9 -3
View File
@@ -7,9 +7,9 @@ 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é, cumul de
8 h, repos, vœu de poste, é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.
8 h, repos, vœu de poste, 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.
"""
import datetime as dt
@@ -41,6 +41,8 @@ _AVAIL_PENALTY_PER_HALF_HOUR = 5
_DAILY_OVERAGE_PENALTY_PER_MIN = 2
_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
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
@@ -114,6 +116,10 @@ def _score(
)
if _has_insufficient_rest(volunteer, shift):
score -= _REST_PENALTY
if any(
_overlaps(shift.shift_info, passage) for passage in volunteer.wished_artist_windows
):
score -= _ARTIST_CONFLICT_PENALTY
score -= (_FAIRNESS_PENALTY_PER_HOUR * volunteer.existing_minutes) // 60
return score
+31
View File
@@ -1,23 +1,39 @@
"""Construit un `SolverInput` depuis la base pour une édition. Couche de
liaison : c'est le seul endroit du module `solver` qui connaît la base."""
import datetime as dt
from collections import defaultdict
from sqlmodel import Session, select
from app.models import (
Artist,
Assignment,
Availability,
DayPart,
Edition,
Shift,
Volunteer,
VolunteerArtist,
VolunteerPositionWish,
)
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
from app.validation.models import AvailabilityWindow, DayPartWindow, ShiftInfo
def _artist_window(artist: Artist) -> ShiftInfo:
"""Fenêtre datée du passage de l'artiste (même convention que les
créneaux : `end_time <= start_time` ⇒ le passage finit le lendemain)."""
starts_at = dt.datetime.combine(artist.performance_date, artist.start_time)
end_date = (
artist.performance_date
if artist.end_time > artist.start_time
else artist.performance_date + dt.timedelta(days=1)
)
ends_at = dt.datetime.combine(end_date, artist.end_time)
return ShiftInfo(id=str(artist.id), starts_at=starts_at, ends_at=ends_at)
def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
day_parts = list(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
@@ -67,6 +83,20 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
):
wishes[w.volunteer_id].add(str(w.position_id))
artists_by_id = {
a.id: a
for a in session.exec(select(Artist).where(Artist.edition_id == edition.id))
}
artist_wishes: dict = defaultdict(list)
for w in session.exec(
select(VolunteerArtist).join(
Volunteer, Volunteer.id == VolunteerArtist.volunteer_id
).where(Volunteer.edition_id == edition.id)
):
artist = artists_by_id.get(w.artist_id)
if artist is not None:
artist_wishes[w.volunteer_id].append(_artist_window(artist))
volunteers = list(
session.exec(select(Volunteer).where(Volunteer.edition_id == edition.id))
)
@@ -75,6 +105,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
id=str(v.id),
availability=tuple(availabilities.get(v.id, ())),
wished_position_ids=frozenset(wishes.get(v.id, ())),
wished_artist_windows=tuple(artist_wishes.get(v.id, ())),
existing_shifts=tuple(
ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at)
for s in shifts_by_volunteer.get(v.id, ())
+4
View File
@@ -33,6 +33,10 @@ class SolverVolunteer:
id: str
availability: tuple[AvailabilityWindow, ...] = ()
wished_position_ids: frozenset[str] = frozenset()
# Passages des artistes que le bénévole veut voir (heure de passage),
# sous forme de fenêtres datées : un créneau qui chevauche l'une d'elles
# 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, cumul journalier, repos.
existing_shifts: tuple[ShiftInfo, ...] = ()
+12 -2
View File
@@ -21,10 +21,20 @@ def make_position(session, edition, *, name="Bar", category=PositionCategory.BAR
def make_artist(
session, edition, *, name="Chilla", performance_date=dt.date(2026, 7, 10)
session,
edition,
*,
name="Chilla",
performance_date=dt.date(2026, 7, 10),
start_time=dt.time(20, 0),
end_time=dt.time(21, 0),
):
artist = Artist(
edition_id=edition.id, name=name, performance_date=performance_date
edition_id=edition.id,
name=name,
performance_date=performance_date,
start_time=start_time,
end_time=end_time,
)
session.add(artist)
session.commit()
+24 -20
View File
@@ -11,17 +11,24 @@ def _url(edition, suffix=""):
return f"/editions/{edition.id}/artists{suffix}"
def _payload(name: str, performance_date: str, **overrides) -> dict:
body = {
"name": name,
"performance_date": performance_date,
"start_time": "20:00",
"end_time": "21:00",
}
body.update(overrides)
return body
def test_create_and_list_ordered_by_date_then_name(client, edition):
client.post(
_url(edition), json={"name": "Ziak", "performance_date": "2026-07-11"}
)
client.post(
_url(edition), json={"name": "Aloïse Sauvage", "performance_date": "2026-07-11"}
)
r = client.post(
_url(edition), json={"name": "Chilla", "performance_date": "2026-07-10"}
)
client.post(_url(edition), json=_payload("Ziak", "2026-07-11"))
client.post(_url(edition), json=_payload("Aloïse Sauvage", "2026-07-11"))
r = client.post(_url(edition), json=_payload("Chilla", "2026-07-10"))
assert r.status_code == 201
assert r.json()["start_time"] == "20:00:00"
assert r.json()["end_time"] == "21:00:00"
body = client.get(_url(edition)).json()
assert [(a["name"], a["performance_date"]) for a in body] == [
@@ -32,24 +39,20 @@ def test_create_and_list_ordered_by_date_then_name(client, edition):
def test_blank_name_is_422(client, edition):
r = client.post(
_url(edition), json={"name": " ", "performance_date": "2026-07-10"}
)
r = client.post(_url(edition), json=_payload(" ", "2026-07-10"))
assert r.status_code == 422
assert r.json()["detail"]["code"] == "artist.name_required"
def test_duplicate_name_and_date_conflicts(client, edition):
payload = {"name": "Chilla", "performance_date": "2026-07-10"}
payload = _payload("Chilla", "2026-07-10")
assert client.post(_url(edition), json=payload).status_code == 201
r = client.post(_url(edition), json=payload)
assert r.status_code == 409
assert r.json()["detail"]["code"] == "artist.duplicate"
# Même nom, autre jour : autorisé.
r2 = client.post(
_url(edition), json={"name": "Chilla", "performance_date": "2026-07-11"}
)
r2 = client.post(_url(edition), json=_payload("Chilla", "2026-07-11"))
assert r2.status_code == 201
@@ -57,11 +60,14 @@ def test_update_artist(client, edition, session):
artist = make_artist(session, edition, name="Chila")
r = client.patch(
_url(edition, f"/{artist.id}"),
json={"name": "Chilla", "performance_date": "2026-07-12"},
json={"name": "Chilla", "performance_date": "2026-07-12", "start_time": "22:00"},
)
assert r.status_code == 200
assert r.json()["name"] == "Chilla"
assert r.json()["performance_date"] == "2026-07-12"
assert r.json()["start_time"] == "22:00:00"
# Non transmis : inchangé.
assert r.json()["end_time"] == "21:00:00"
def test_delete_artist_removes_volunteer_wishes(client, edition, session):
@@ -88,9 +94,7 @@ def test_unknown_edition_is_404(client):
def test_write_is_coordinator_only(edition, make_account, as_account):
lead = make_account(Role.LEAD, email="lead@e.fr")
api = as_account(lead)
resp = api.post(
_url(edition), json={"name": "X", "performance_date": "2026-07-10"}
)
resp = api.post(_url(edition), json=_payload("X", "2026-07-10"))
assert resp.status_code == 403
# La lecture reste ouverte à tout compte de l'édition.
assert api.get(_url(edition)).status_code == 200
+27 -1
View File
@@ -1,7 +1,8 @@
import datetime as dt
import uuid
from app.models import Role
from tests.factories import dt_at, make_position, make_shift, make_volunteer
from tests.factories import dt_at, make_artist, make_position, make_shift, make_volunteer
def _url(edition, suffix=""):
@@ -45,6 +46,31 @@ def test_fully_staffed_shift_has_no_suggestion(client, edition, session):
assert str(shift.id) not in shift_ids
def test_avoids_volunteer_whose_wished_artist_would_be_missed(client, edition, session):
position = make_position(session, edition)
shift = make_shift(
session, edition, position,
start=dt_at(10, 14), end=dt_at(10, 18), min_staff=1, ideal_staff=1,
)
artist = make_artist(
session, edition,
performance_date=dt.date(2026, 7, 10),
start_time=dt.time(15, 0), end_time=dt.time(16, 0),
)
fan = make_volunteer(session, edition, email="fan@ex.fr")
assert client.patch(
f"/editions/{edition.id}/volunteers/{fan.id}",
json={"artist_ids": [str(artist.id)]},
).status_code == 200
indiff = make_volunteer(session, edition, email="indiff@ex.fr")
resp = client.post(_url(edition, "/suggestions"))
suggestion = next(
s for s in resp.json()["suggestions"] if s["shift_id"] == str(shift.id)
)
assert suggestion["volunteer_ids"] == [str(indiff.id)]
def test_unknown_edition_is_404(client):
assert client.post(f"/editions/{uuid.uuid4()}/solver/suggestions").status_code == 404
+40
View File
@@ -37,6 +37,7 @@ def _volunteer(
*,
availability=(),
wished_position_ids=frozenset(),
wished_artist_windows=(),
existing_shifts=(),
existing_minutes=0,
) -> SolverVolunteer:
@@ -44,6 +45,7 @@ def _volunteer(
id=vid,
availability=tuple(availability),
wished_position_ids=frozenset(wished_position_ids),
wished_artist_windows=tuple(wished_artist_windows),
existing_shifts=tuple(existing_shifts),
existing_minutes=existing_minutes,
)
@@ -146,3 +148,41 @@ def test_avoids_volunteer_who_would_exceed_eight_hours_that_day():
fresh = _volunteer("fresh", availability=[_full_day(10)])
solution = solve(SolverInput(shifts=(shift,), volunteers=(tired, fresh)))
assert _by_shift(solution, "s1") == ("fresh",)
def test_avoids_volunteer_whose_wished_artist_plays_during_the_shift():
# Le créneau (14 h - 18 h) chevauche le passage de l'artiste (16 h - 17 h)
# que « fan » veut voir : le solveur préfère « indiff », qui n'a pas ce
# conflit.
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]
)
indiff = _volunteer("indiff", availability=[_full_day(10)])
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan, indiff)))
assert _by_shift(solution, "s1") == ("indiff",)
def test_still_suggests_the_only_candidate_despite_an_artist_conflict():
# Le conflit d'artiste reste un avertissement, pas un blocage : s'il n'y a
# personne d'autre pour un créneau urgent, le solveur propose quand même.
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]
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,)))
assert _by_shift(solution, "s1") == ("fan",)
def test_artist_conflict_outside_shift_hours_has_no_effect():
# Le passage (20 h - 21 h) ne chevauche pas le créneau (14 h - 18 h) :
# aucun malus, « fan » reste un candidat normal.
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]
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,)))
assert _by_shift(solution, "s1") == ("fan",)