Vœu de compagnon : être affecté avec 0 à N autres bénévoles

Nouveau vœu sur la fiche du bénévole — « je voudrais être affecté avec tel(s)
bénévole(s) » — pris en compte à la fois pour le rendre visible et par le
solveur.

Backend :
- `VolunteerCompanionWish` (`volunteer_id`, `companion_id`, à sens unique,
  `CHECK (volunteer_id <> companion_id)`) — migration 0010.
- API : `wished_companion_ids` sur `VolunteerCreate`/`Update`/`Read`, mêmes
  règles que les autres vœux (ids inconnus → 422 `volunteer.unknown` ; se
  souhaiter soi-même → 422 `volunteer.cannot_wish_self`).
- Solveur (`engine.py`) : bonus (+25 par correspondance) si un des compagnons
  souhaités du candidat est déjà affecté (décision déjà prise) au créneau —
  cumulable si plusieurs compagnons sont présents. Comme les autres termes de
  l'objectif, un bonus/malus, jamais une contrainte.
- Seed de démo : ~20 % des bénévoles ont un vœu de compagnon (couple, amis).

Frontend :
- Fiche bénévole (`ConfigVolunteers.tsx`) : sélecteur de compagnons (ajout et
  édition), avec une bulle d'aide expliquant le sens unique et l'effet sur le
  solveur — c'est ce qui « fait apparaître » le lien entre bénévoles.
- Panneau d'affectation : badge « avec N compagnon(s) souhaité(s) » sur un
  candidat, et bonus correspondant dans le classement par défaut
  (`candidateChanceScore`).
- Mock (`runSolver`) : même bonus, pour que la démonstration se comporte
  pareil que le vrai solveur.

CLAUDE.md à jour (modèle de domaine, section 4, journal).

Vérifié : 194 tests backend (dont 13 nouveaux), ruff, alembic check ;
typecheck/lint/build/smoke frontend ; solveur testé en direct sur le jeu de
démo reseedé (0,25 s, 47 suggestions).

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-15 21:50:30 +02:00
co-authored by Claude Sonnet 5
parent b97b95c606
commit 6a4bcf8782
23 changed files with 469 additions and 42 deletions
+7 -2
View File
@@ -49,9 +49,11 @@ class VolunteerCreate(ApiModel):
email: str | None = None
phone: str | None = None
comment: str | None = None
# Vœux du bénévole : artistes à voir, postes souhaités.
# Vœux du bénévole : artistes à voir, postes souhaités, bénévoles avec
# qui il voudrait être affecté (à sens unique, 0 à N).
artist_ids: list[uuid.UUID] = Field(default_factory=list)
wished_position_ids: list[uuid.UUID] = Field(default_factory=list)
wished_companion_ids: list[uuid.UUID] = Field(default_factory=list)
class VolunteerUpdate(ApiModel):
@@ -60,9 +62,11 @@ class VolunteerUpdate(ApiModel):
email: str | None = None
phone: str | None = None
comment: str | None = None
# None = inchangé ; [] = vider. Idem pour artist_ids / wished_position_ids.
# None = inchangé ; [] = vider. Idem pour artist_ids / wished_position_ids
# / wished_companion_ids.
artist_ids: list[uuid.UUID] | None = None
wished_position_ids: list[uuid.UUID] | None = None
wished_companion_ids: list[uuid.UUID] | None = None
class VolunteerRead(ApiModel):
@@ -75,6 +79,7 @@ class VolunteerRead(ApiModel):
comment: str | None
artist_ids: list[uuid.UUID]
wished_position_ids: list[uuid.UUID]
wished_companion_ids: list[uuid.UUID]
created_at: dt.datetime
updated_at: dt.datetime
+44 -1
View File
@@ -25,6 +25,7 @@ from app.models import (
Role,
Volunteer,
VolunteerArtist,
VolunteerCompanionWish,
VolunteerPositionWish,
)
@@ -68,11 +69,26 @@ def _wished_position_ids(
)
def _companion_ids(session: Session, volunteer_id: uuid.UUID) -> list[uuid.UUID]:
return list(
session.exec(
select(Volunteer.id)
.join(
VolunteerCompanionWish,
VolunteerCompanionWish.companion_id == Volunteer.id,
)
.where(VolunteerCompanionWish.volunteer_id == volunteer_id)
.order_by(Volunteer.last_name, Volunteer.first_name)
)
)
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
return VolunteerRead(
**volunteer.model_dump(),
artist_ids=_artist_ids(session, volunteer.id),
wished_position_ids=_wished_position_ids(session, volunteer.id),
wished_companion_ids=_companion_ids(session, volunteer.id),
)
@@ -133,6 +149,27 @@ def _set_wished_positions(
)
def _set_companions(
session: Session, volunteer: Volunteer, companion_ids: list[uuid.UUID]
) -> None:
ids = _resolve_owned_ids(
session, Volunteer, volunteer.edition_id, companion_ids, "volunteer.unknown"
)
if volunteer.id in ids:
raise HTTPException(
status_code=422, detail={"code": "volunteer.cannot_wish_self"}
)
replace_links(
session,
VolunteerCompanionWish,
filter_column=VolunteerCompanionWish.volunteer_id,
filter_value=volunteer.id,
pairs=[
{"volunteer_id": volunteer.id, "companion_id": cid} for cid in ids
],
)
@router.post("", response_model=VolunteerRead, status_code=status.HTTP_201_CREATED)
def create_volunteer(
payload: VolunteerCreate,
@@ -142,12 +179,15 @@ def create_volunteer(
) -> VolunteerRead:
volunteer = Volunteer(
edition_id=edition.id,
**payload.model_dump(exclude={"artist_ids", "wished_position_ids"}),
**payload.model_dump(
exclude={"artist_ids", "wished_position_ids", "wished_companion_ids"}
),
)
session.add(volunteer)
session.flush()
_set_artists(session, volunteer, payload.artist_ids)
_set_wished_positions(session, volunteer, payload.wished_position_ids)
_set_companions(session, volunteer, payload.wished_companion_ids)
session.commit()
session.refresh(volunteer)
return _read(session, volunteer)
@@ -191,6 +231,7 @@ def update_volunteer(
changes = payload.model_dump(exclude_unset=True)
artist_ids = changes.pop("artist_ids", None)
wished_position_ids = changes.pop("wished_position_ids", None)
wished_companion_ids = changes.pop("wished_companion_ids", None)
for field, value in changes.items():
setattr(volunteer, field, value)
session.flush()
@@ -198,6 +239,8 @@ def update_volunteer(
_set_artists(session, volunteer, artist_ids)
if wished_position_ids is not None:
_set_wished_positions(session, volunteer, wished_position_ids)
if wished_companion_ids is not None:
_set_companions(session, volunteer, wished_companion_ids)
session.commit()
session.refresh(volunteer)
return _read(session, volunteer)
+2
View File
@@ -14,6 +14,8 @@
"artist.duplicate": "Un artiste porte déjà ce nom à cette date dans l'édition.",
"artist.unknown": "Un des artistes choisis n'appartient pas à cette édition.",
"position.unknown": "Un des postes choisis n'appartient pas à cette édition.",
"volunteer.unknown": "Un des bénévoles choisis n'appartient pas à cette édition.",
"volunteer.cannot_wish_self": "Un bénévole ne peut pas se souhaiter lui-même comme compagnon.",
"assignment.wrong_edition": "Le bénévole ou le créneau n'appartient pas à cette édition.",
"assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau.",
"auth.invalid_token": "Lien de connexion invalide ou expiré.",
@@ -0,0 +1,39 @@
"""Vœu de bénévole : être affecté avec tel(s) autre(s) bénévole(s)
Revision ID: 0010_companion_wish
Revises: 0009_volunteer_email_not_unique
Create Date: 2026-09-15
Table `volunteer_companion_wish` : de 0 à N vœux par bénévole, à sens unique
(A peut souhaiter B sans que B ne le souhaite en retour). Le solveur (section
4 de CLAUDE.md) favorise un candidat dont l'un des compagnons souhaités est
déjà affecté au créneau.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0010_companion_wish"
down_revision: str | None = "0009_volunteer_email_not_unique"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"volunteer_companion_wish",
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
sa.Column("companion_id", sa.Uuid(), nullable=False),
sa.CheckConstraint(
"volunteer_id <> companion_id", name="ck_companion_wish_not_self"
),
sa.ForeignKeyConstraint(["companion_id"], ["volunteer.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("volunteer_id", "companion_id"),
)
def downgrade() -> None:
op.drop_table("volunteer_companion_wish")
+2 -1
View File
@@ -17,6 +17,7 @@ from app.models.domain import (
Shift,
Volunteer,
VolunteerArtist,
VolunteerCompanionWish,
VolunteerPositionWish,
)
from app.models.enums import (
@@ -41,7 +42,7 @@ __all__ = [
"TimestampMixin",
"Volunteer",
"VolunteerArtist",
"VolunteerCompanionWish",
"VolunteerPositionWish",
"VolunteerSkill",
"local_now",
]
+23 -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, Time
from sqlalchemy import Boolean, CheckConstraint, Column, Integer, Time
from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
@@ -52,6 +52,28 @@ class VolunteerPositionWish(SQLModel, table=True):
)
class VolunteerCompanionWish(SQLModel, table=True):
"""Vœu d'un bénévole : « je voudrais être affecté avec ce bénévole ».
De 0 à N par bénévole, à sens unique (A peut souhaiter B sans que B ne le
souhaite en retour) — le solveur (section 4 de CLAUDE.md) le lit dans ce
seul sens."""
__tablename__ = "volunteer_companion_wish"
__table_args__ = (
CheckConstraint(
"volunteer_id <> companion_id", name="ck_companion_wish_not_self"
),
)
volunteer_id: uuid.UUID = Field(
foreign_key="volunteer.id", primary_key=True, ondelete="CASCADE"
)
companion_id: uuid.UUID = Field(
foreign_key="volunteer.id", primary_key=True, ondelete="CASCADE"
)
# --------------------------------------------------------------------------- #
# Édition et découpage des journées #
# --------------------------------------------------------------------------- #
+20 -2
View File
@@ -31,6 +31,7 @@ from app.models import (
Shift,
Volunteer,
VolunteerArtist,
VolunteerCompanionWish,
VolunteerPositionWish,
)
from app.validation import (
@@ -47,8 +48,8 @@ SEED = 20260709
DEMO_EDITION_NAME = "Festival des Lucioles"
DEMO_COORDINATION_PASSWORD = "coordination-lucioles-2026"
DEMO_YEAR = 2026
START = dt.date(2026, 7, 9)
END = dt.date(2026, 7, 12)
START = dt.date(2026, 10, 30)
END = dt.date(2026, 10, 31)
FIRST_NAMES = [
"Camille",
@@ -214,6 +215,7 @@ class Summary:
artists: int = 0
position_wishes: int = 0
artist_wishes: int = 0
companion_wishes: int = 0
warnings: Counter = field(default_factory=Counter)
blocking: int = 0
@@ -229,6 +231,7 @@ class Summary:
f" Artistes programmés : {self.artists}",
f" Vœux de poste déclarés : {self.position_wishes}",
f" Vœux d'artiste déclarés : {self.artist_wishes}",
f" Vœux de compagnon déclarés : {self.companion_wishes}",
f" Avertissements actifs : {sum(self.warnings.values())}",
]
for code, count in sorted(self.warnings.items()):
@@ -398,6 +401,20 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
VolunteerArtist(volunteer_id=volunteer.id, artist_id=artist.id)
)
n_artist_wishes += 1
# Vœux de compagnon (couple, amis) : à sens unique, choisis parmi les
# autres bénévoles déjà créés.
n_companion_wishes = 0
for volunteer in volunteers:
others = [v for v in volunteers if v.id != volunteer.id]
roll = rng.random()
n_wishes = 2 if roll < 0.03 else (1 if roll < 0.23 else 0)
for companion in rng.sample(others, k=min(n_wishes, len(others))):
session.add(
VolunteerCompanionWish(
volunteer_id=volunteer.id, companion_id=companion.id
)
)
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))
@@ -519,6 +536,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
artists=len(artists),
position_wishes=n_position_wishes,
artist_wishes=n_artist_wishes,
companion_wishes=n_companion_wishes,
)
_verify(session, edition, summary)
return summary
+14 -8
View File
@@ -7,10 +7,10 @@ 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 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.
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.
"""
@@ -49,6 +49,8 @@ _ARTIST_CONFLICT_PENALTY = 80
# n'importe quel poste) : limite les enchaînements de shifts plutôt que de
# faire tourner un poste en particulier.
_CHAIN_PENALTY = 40
# Par compagnon souhaité déjà affecté au créneau (0 à N vœux par bénévole).
_COMPANION_BONUS_PER_MATCH = 25
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
@@ -85,10 +87,12 @@ 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 et chaînage 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.
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).
"""
score = _URGENT_BASE if shift.already_assigned < shift.min_staff else _NORMAL_BASE
if shift.position_id in volunteer.wished_position_ids:
@@ -99,6 +103,8 @@ def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int:
score -= _REST_PENALTY
if _chained_from_predecessor(volunteer, shift):
score -= _CHAIN_PENALTY
companions_present = len(volunteer.wished_companion_ids & shift.assigned_volunteer_ids)
score += _COMPANION_BONUS_PER_MATCH * companions_present
if any(
_overlaps(shift.shift_info, passage) for passage in volunteer.wished_artist_windows
):
+13
View File
@@ -14,6 +14,7 @@ from app.models import (
Shift,
Volunteer,
VolunteerArtist,
VolunteerCompanionWish,
VolunteerPositionWish,
)
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
@@ -61,8 +62,10 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
assigned_count: dict = defaultdict(int)
shifts_by_volunteer: dict = defaultdict(list)
assigned_volunteers_by_shift: dict = defaultdict(set)
for a in assignments:
assigned_count[a.shift_id] += 1
assigned_volunteers_by_shift[a.shift_id].add(str(a.volunteer_id))
shift = shifts_by_id.get(a.shift_id)
if shift is not None:
shifts_by_volunteer[a.volunteer_id].append(shift)
@@ -77,6 +80,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
ideal_staff=s.ideal_staff,
already_assigned=assigned_count.get(s.id, 0),
predecessor_shift_ids=predecessor_ids.get(s.id, frozenset()),
assigned_volunteer_ids=frozenset(assigned_volunteers_by_shift.get(s.id, ())),
)
for s in shifts
if assigned_count.get(s.id, 0) < s.ideal_staff
@@ -98,6 +102,14 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
):
wishes[w.volunteer_id].add(str(w.position_id))
companion_wishes: dict = defaultdict(set)
for w in session.exec(
select(VolunteerCompanionWish).join(
Volunteer, Volunteer.id == VolunteerCompanionWish.volunteer_id
).where(Volunteer.edition_id == edition.id)
):
companion_wishes[w.volunteer_id].add(str(w.companion_id))
artists_by_id = {
a.id: a
for a in session.exec(select(Artist).where(Artist.edition_id == edition.id))
@@ -120,6 +132,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_companion_ids=frozenset(companion_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)
+5
View File
@@ -27,6 +27,9 @@ class SolverShift:
# chaînage (voir `engine._score`), pour limiter les enchaînements de
# shifts plutôt que cibler la rotation d'un poste en particulier.
predecessor_shift_ids: frozenset[str] = frozenset()
# Bénévoles déjà affectés à ce créneau (décisions déjà prises) : sert au
# bonus de compagnon de route (voir `engine._score`).
assigned_volunteer_ids: frozenset[str] = frozenset()
@property
def shift_info(self) -> ShiftInfo:
@@ -38,6 +41,8 @@ class SolverVolunteer:
id: str
availability: tuple[AvailabilityWindow, ...] = ()
wished_position_ids: frozenset[str] = frozenset()
# Bénévoles (0 à N) avec qui il voudrait être affecté — à sens unique.
wished_companion_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.