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
+10 -5
View File
@@ -22,7 +22,7 @@ colonne, route et variable utilise la colonne « Code / base ».
| Créneau | `Shift` | | Créneau | `Shift` |
| Disponibilité (fenêtre horaire libre) | `Availability` (`date`, `start_time`, `end_time`) | | Disponibilité (fenêtre horaire libre) | `Availability` (`date`, `start_time`, `end_time`) |
| Affectation | `Assignment` | | Affectation | `Assignment` |
| Artiste programmé | `Artist` (`name`, `performance_date`) | | Artiste programmé | `Artist` (`name`, `performance_date`, `start_time`, `end_time`) |
| Vœu « je veux voir cet artiste » | `VolunteerArtist` (N-N `Volunteer` ↔ `Artist`) | | Vœu « je veux voir cet artiste » | `VolunteerArtist` (N-N `Volunteer` ↔ `Artist`) |
| Vœu « je voudrais ce poste » | `VolunteerPositionWish` (N-N `Volunteer` ↔ `Position`) | | Vœu « je voudrais ce poste » | `VolunteerPositionWish` (N-N `Volunteer` ↔ `Position`) |
| Compte (utilisateur connecté) | `Account` | | Compte (utilisateur connecté) | `Account` |
@@ -71,10 +71,12 @@ les tables sauf les tables d'association.
t-shirt, régime, ni compétences. Porte deux listes de vœux, exposées par la t-shirt, régime, ni compétences. Porte deux listes de vœux, exposées par la
fiche (`artist_ids`, `wished_position_ids` ; `None`/absent = inchangé, fiche (`artist_ids`, `wished_position_ids` ; `None`/absent = inchangé,
`[]` = vider). `[]` = vider).
- **`Artist`** : programmation de l'édition (`name`, `performance_date`). - **`Artist`** : programmation de l'édition (`name`, `performance_date`,
Unique (`edition_id`, `name`, `performance_date`). La coordination gère la `start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
liste ; elle est présentée par jour. Les bénévoles y choisissent les lendemain). Unique (`edition_id`, `name`, `performance_date`). La
artistes qu'ils veulent voir (`volunteer_artist`). CRUD coordination gère la liste ; elle est présentée par jour. Les bénévoles y
choisissent les artistes qu'ils veulent voir (`volunteer_artist`) — le
solveur (section 4) évite de les affecter pendant ce passage. CRUD
`GET/POST/PATCH/DELETE /editions/{id}/artists` (lecture : tout compte de `GET/POST/PATCH/DELETE /editions/{id}/artists` (lecture : tout compte de
l'édition ; écriture : COORDINATION). l'édition ; écriture : COORDINATION).
- Vœux de postes : `volunteer_position_wish` (« postes que le bénévole - Vœux de postes : `volunteer_position_wish` (« postes que le bénévole
@@ -267,6 +269,9 @@ affectation n'est jamais persistée par cet endpoint.
déclarées (réutilise `validation.engine.uncovered_minutes`), au dépassement déclarées (réutilise `validation.engine.uncovered_minutes`), au dépassement
du cumul de 8 h ce jour-là, et si le repos avec une affectation déjà du cumul de 8 h ce jour-là, et si le repos avec une affectation déjà
décidée serait `< 8 h 00` ; décidée serait `< 8 h 00` ;
- malus si le créneau chevauche le passage d'un artiste que le bénévole
veut voir (`VolunteerArtist` + `Artist.start_time`/`end_time`) — lui
ferait manquer le passage ;
- léger malus pour les bénévoles déjà bien chargés (répartition de la - léger malus pour les bénévoles déjà bien chargés (répartition de la
charge). charge).
- Simplification assumée : cumul de 8 h et repos ne sont vérifiés que - Simplification assumée : cumul de 8 h et repos ne sont vérifiés que
+7 -3
View File
@@ -1,8 +1,10 @@
"""CRUD de la programmation (`Artist`) d'une édition. """CRUD de la programmation (`Artist`) d'une édition.
La coordination constitue la liste ; elle est présentée par jour de passage La coordination constitue la liste ; elle est présentée par jour et heure de
(`performance_date`). Les bénévoles y choisissent les artistes qu'ils veulent passage (`performance_date`, `start_time`, `end_time`). Les bénévoles y
voir (vœux stockés dans `volunteer_artist`, exposés par la fiche bénévole).""" 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 import uuid
@@ -51,6 +53,8 @@ def create_artist(
edition_id=edition.id, edition_id=edition.id,
name=name, name=name,
performance_date=payload.performance_date, performance_date=payload.performance_date,
start_time=payload.start_time,
end_time=payload.end_time,
) )
session.add(artist) session.add(artist)
try: try:
+7
View File
@@ -85,11 +85,16 @@ class VolunteerRead(ApiModel):
class ArtistCreate(ApiModel): class ArtistCreate(ApiModel):
name: str name: str
performance_date: dt.date performance_date: dt.date
# end_time <= start_time ⇒ le passage finit le lendemain.
start_time: dt.time
end_time: dt.time
class ArtistUpdate(ApiModel): class ArtistUpdate(ApiModel):
name: str | None = None name: str | None = None
performance_date: dt.date | None = None performance_date: dt.date | None = None
start_time: dt.time | None = None
end_time: dt.time | None = None
class ArtistRead(ApiModel): class ArtistRead(ApiModel):
@@ -97,6 +102,8 @@ class ArtistRead(ApiModel):
edition_id: uuid.UUID edition_id: uuid.UUID
name: str name: str
performance_date: dt.date performance_date: dt.date
start_time: dt.time
end_time: dt.time
created_at: dt.datetime created_at: dt.datetime
updated_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 datetime as dt
import uuid import uuid
from sqlalchemy import Boolean, Column, Integer from sqlalchemy import Boolean, Column, Integer, Time
from sqlalchemy import Enum as SAEnum from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
@@ -117,6 +117,13 @@ class Artist(TimestampMixin, table=True):
name: str name: str
# Jour de passage, dans les dates de l'édition. # Jour de passage, dans les dates de l'édition.
performance_date: dt.date = Field(index=True) 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( fans: list["Volunteer"] = Relationship(
back_populates="wished_artists", 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 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 `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 8 h, repos, vœu de poste, vœu d'artiste, équité de charge) est un terme de
une contrainte — comme le moteur de validation, qui reste seul juge au moment l'objectif, pas une contrainte — comme le moteur de validation, qui reste seul
où la coordination confirme une affectation. juge au moment où la coordination confirme une affectation.
""" """
import datetime as dt import datetime as dt
@@ -41,6 +41,8 @@ _AVAIL_PENALTY_PER_HALF_HOUR = 5
_DAILY_OVERAGE_PENALTY_PER_MIN = 2 _DAILY_OVERAGE_PENALTY_PER_MIN = 2
_REST_PENALTY = 60 _REST_PENALTY = 60
_FAIRNESS_PENALTY_PER_HOUR = 2 _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: def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
@@ -114,6 +116,10 @@ def _score(
) )
if _has_insufficient_rest(volunteer, shift): if _has_insufficient_rest(volunteer, shift):
score -= _REST_PENALTY 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 score -= (_FAIRNESS_PENALTY_PER_HOUR * volunteer.existing_minutes) // 60
return score return score
+31
View File
@@ -1,23 +1,39 @@
"""Construit un `SolverInput` depuis la base pour une édition. Couche de """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.""" liaison : c'est le seul endroit du module `solver` qui connaît la base."""
import datetime as dt
from collections import defaultdict from collections import defaultdict
from sqlmodel import Session, select from sqlmodel import Session, select
from app.models import ( from app.models import (
Artist,
Assignment, Assignment,
Availability, Availability,
DayPart, DayPart,
Edition, Edition,
Shift, Shift,
Volunteer, Volunteer,
VolunteerArtist,
VolunteerPositionWish, VolunteerPositionWish,
) )
from app.solver.models import SolverInput, SolverShift, SolverVolunteer from app.solver.models import SolverInput, SolverShift, SolverVolunteer
from app.validation.models import AvailabilityWindow, DayPartWindow, ShiftInfo 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: def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
day_parts = list( day_parts = list(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id)) 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)) 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( volunteers = list(
session.exec(select(Volunteer).where(Volunteer.edition_id == edition.id)) 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), id=str(v.id),
availability=tuple(availabilities.get(v.id, ())), availability=tuple(availabilities.get(v.id, ())),
wished_position_ids=frozenset(wishes.get(v.id, ())), wished_position_ids=frozenset(wishes.get(v.id, ())),
wished_artist_windows=tuple(artist_wishes.get(v.id, ())),
existing_shifts=tuple( existing_shifts=tuple(
ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at) ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at)
for s in shifts_by_volunteer.get(v.id, ()) for s in shifts_by_volunteer.get(v.id, ())
+4
View File
@@ -33,6 +33,10 @@ class SolverVolunteer:
id: str id: str
availability: tuple[AvailabilityWindow, ...] = () availability: tuple[AvailabilityWindow, ...] = ()
wished_position_ids: frozenset[str] = frozenset() 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) : # Créneaux déjà occupés (affectations existantes, décisions figées) :
# comptent pour chevauchement, cumul journalier, repos. # comptent pour chevauchement, cumul journalier, repos.
existing_shifts: tuple[ShiftInfo, ...] = () 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( 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( 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.add(artist)
session.commit() session.commit()
+24 -20
View File
@@ -11,17 +11,24 @@ def _url(edition, suffix=""):
return f"/editions/{edition.id}/artists{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): def test_create_and_list_ordered_by_date_then_name(client, edition):
client.post( client.post(_url(edition), json=_payload("Ziak", "2026-07-11"))
_url(edition), json={"name": "Ziak", "performance_date": "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"))
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"}
)
assert r.status_code == 201 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() body = client.get(_url(edition)).json()
assert [(a["name"], a["performance_date"]) for a in body] == [ 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): def test_blank_name_is_422(client, edition):
r = client.post( r = client.post(_url(edition), json=_payload(" ", "2026-07-10"))
_url(edition), json={"name": " ", "performance_date": "2026-07-10"}
)
assert r.status_code == 422 assert r.status_code == 422
assert r.json()["detail"]["code"] == "artist.name_required" assert r.json()["detail"]["code"] == "artist.name_required"
def test_duplicate_name_and_date_conflicts(client, edition): 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 assert client.post(_url(edition), json=payload).status_code == 201
r = client.post(_url(edition), json=payload) r = client.post(_url(edition), json=payload)
assert r.status_code == 409 assert r.status_code == 409
assert r.json()["detail"]["code"] == "artist.duplicate" assert r.json()["detail"]["code"] == "artist.duplicate"
# Même nom, autre jour : autorisé. # Même nom, autre jour : autorisé.
r2 = client.post( r2 = client.post(_url(edition), json=_payload("Chilla", "2026-07-11"))
_url(edition), json={"name": "Chilla", "performance_date": "2026-07-11"}
)
assert r2.status_code == 201 assert r2.status_code == 201
@@ -57,11 +60,14 @@ def test_update_artist(client, edition, session):
artist = make_artist(session, edition, name="Chila") artist = make_artist(session, edition, name="Chila")
r = client.patch( r = client.patch(
_url(edition, f"/{artist.id}"), _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.status_code == 200
assert r.json()["name"] == "Chilla" assert r.json()["name"] == "Chilla"
assert r.json()["performance_date"] == "2026-07-12" 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): 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): def test_write_is_coordinator_only(edition, make_account, as_account):
lead = make_account(Role.LEAD, email="lead@e.fr") lead = make_account(Role.LEAD, email="lead@e.fr")
api = as_account(lead) api = as_account(lead)
resp = api.post( resp = api.post(_url(edition), json=_payload("X", "2026-07-10"))
_url(edition), json={"name": "X", "performance_date": "2026-07-10"}
)
assert resp.status_code == 403 assert resp.status_code == 403
# La lecture reste ouverte à tout compte de l'édition. # La lecture reste ouverte à tout compte de l'édition.
assert api.get(_url(edition)).status_code == 200 assert api.get(_url(edition)).status_code == 200
+27 -1
View File
@@ -1,7 +1,8 @@
import datetime as dt
import uuid import uuid
from app.models import Role 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=""): 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 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): def test_unknown_edition_is_404(client):
assert client.post(f"/editions/{uuid.uuid4()}/solver/suggestions").status_code == 404 assert client.post(f"/editions/{uuid.uuid4()}/solver/suggestions").status_code == 404
+40
View File
@@ -37,6 +37,7 @@ def _volunteer(
*, *,
availability=(), availability=(),
wished_position_ids=frozenset(), wished_position_ids=frozenset(),
wished_artist_windows=(),
existing_shifts=(), existing_shifts=(),
existing_minutes=0, existing_minutes=0,
) -> SolverVolunteer: ) -> SolverVolunteer:
@@ -44,6 +45,7 @@ def _volunteer(
id=vid, id=vid,
availability=tuple(availability), availability=tuple(availability),
wished_position_ids=frozenset(wished_position_ids), wished_position_ids=frozenset(wished_position_ids),
wished_artist_windows=tuple(wished_artist_windows),
existing_shifts=tuple(existing_shifts), existing_shifts=tuple(existing_shifts),
existing_minutes=existing_minutes, 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)]) fresh = _volunteer("fresh", availability=[_full_day(10)])
solution = solve(SolverInput(shifts=(shift,), volunteers=(tired, fresh))) solution = solve(SolverInput(shifts=(shift,), volunteers=(tired, fresh)))
assert _by_shift(solution, "s1") == ("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",)