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
+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)