diff --git a/CLAUDE.md b/CLAUDE.md index ee630de..ab702fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ colonne, route et variable utilise la colonne « Code / base ». | Créneau | `Shift` | | Disponibilité (fenêtre horaire libre) | `Availability` (`date`, `start_time`, `end_time`) | | 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 voudrais ce poste » | `VolunteerPositionWish` (N-N `Volunteer` ↔ `Position`) | | 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 fiche (`artist_ids`, `wished_position_ids` ; `None`/absent = inchangé, `[]` = vider). -- **`Artist`** : programmation de l'édition (`name`, `performance_date`). - Unique (`edition_id`, `name`, `performance_date`). La 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`). CRUD +- **`Artist`** : programmation de l'édition (`name`, `performance_date`, + `start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le + lendemain). Unique (`edition_id`, `name`, `performance_date`). La + 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 l'édition ; écriture : COORDINATION). - 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 du cumul de 8 h ce jour-là, et si le repos avec une affectation déjà 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 charge). - Simplification assumée : cumul de 8 h et repos ne sont vérifiés que diff --git a/backend/app/api/artists.py b/backend/app/api/artists.py index 851cdc0..8d5dc81 100644 --- a/backend/app/api/artists.py +++ b/backend/app/api/artists.py @@ -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: diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index d3cbf21..d74519e 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -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 diff --git a/backend/app/migrations/versions/0008_artist_performance_time.py b/backend/app/migrations/versions/0008_artist_performance_time.py new file mode 100644 index 0000000..7e6dc30 --- /dev/null +++ b/backend/app/migrations/versions/0008_artist_performance_time.py @@ -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") diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 7ff395f..888dc16 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -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", diff --git a/backend/app/solver/engine.py b/backend/app/solver/engine.py index 8e3a295..b4536ff 100644 --- a/backend/app/solver/engine.py +++ b/backend/app/solver/engine.py @@ -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 diff --git a/backend/app/solver/loader.py b/backend/app/solver/loader.py index 13569cd..499bb05 100644 --- a/backend/app/solver/loader.py +++ b/backend/app/solver/loader.py @@ -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, ()) diff --git a/backend/app/solver/models.py b/backend/app/solver/models.py index f0c4737..5777116 100644 --- a/backend/app/solver/models.py +++ b/backend/app/solver/models.py @@ -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, ...] = () diff --git a/backend/tests/factories.py b/backend/tests/factories.py index f05438f..2c685d0 100644 --- a/backend/tests/factories.py +++ b/backend/tests/factories.py @@ -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() diff --git a/backend/tests/test_api_artists.py b/backend/tests/test_api_artists.py index 7149c3a..357c26a 100644 --- a/backend/tests/test_api_artists.py +++ b/backend/tests/test_api_artists.py @@ -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 diff --git a/backend/tests/test_api_solver.py b/backend/tests/test_api_solver.py index b61d918..cc7bdfe 100644 --- a/backend/tests/test_api_solver.py +++ b/backend/tests/test_api_solver.py @@ -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 diff --git a/backend/tests/test_solver_engine.py b/backend/tests/test_solver_engine.py index d43c1a9..cc2a14b 100644 --- a/backend/tests/test_solver_engine.py +++ b/backend/tests/test_solver_engine.py @@ -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",)