Files
benevoles/backend/app/seed.py
T
AdamThomasandClaude Sonnet 5 6a4bcf8782 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
2026-09-15 21:50:30 +02:00

671 lines
21 KiB
Python

"""Jeu de données de démonstration.
docker compose run --rm backend uv run python -m app.seed
Idempotent : supprime l'édition de démonstration existante puis la recrée.
Déterministe (graine fixe). Aucune donnée nominative réelle.
"""
import datetime as dt
import random
from collections import Counter
from dataclasses import dataclass, field
from sqlalchemy import delete
from sqlmodel import Session, select
from app.auth.passwords import hash_password
from app.db import engine
from app.importing.text import normalize
from app.models import (
Account,
Artist,
Assignment,
AssignmentSource,
Availability,
DayPart,
Edition,
Position,
PositionCategory,
Role,
Shift,
Volunteer,
VolunteerArtist,
VolunteerCompanionWish,
VolunteerPositionWish,
)
from app.validation import (
AvailabilityWindow,
DayPartWindow,
Severity,
ShiftInfo,
ValidationInput,
validate,
)
from app.validation.loader import build_validation_input
SEED = 20260709
DEMO_EDITION_NAME = "Festival des Lucioles"
DEMO_COORDINATION_PASSWORD = "coordination-lucioles-2026"
DEMO_YEAR = 2026
START = dt.date(2026, 10, 30)
END = dt.date(2026, 10, 31)
FIRST_NAMES = [
"Camille",
"Léa",
"Hugo",
"Nathan",
"Emma",
"Lucas",
"Chloé",
"Louis",
"Manon",
"Jules",
"Sarah",
"Tom",
"Inès",
"Théo",
"Jade",
"Raphaël",
"Alice",
"Gabriel",
"Louise",
"Adam",
"Zoé",
"Arthur",
"Lina",
"Paul",
"Rose",
"Noah",
"Anna",
"Ethan",
"Mila",
"Sacha",
"Lou",
"Naël",
"Ambre",
"Liam",
"Juliette",
"Maël",
"Nina",
"Aaron",
"Éva",
"Timéo",
]
LAST_NAMES = [
"Martin",
"Bernard",
"Dubois",
"Thomas",
"Robert",
"Richard",
"Petit",
"Durand",
"Leroy",
"Moreau",
"Simon",
"Laurent",
"Lefebvre",
"Michel",
"Garcia",
"David",
"Bertrand",
"Roux",
"Vincent",
"Fournier",
"Morel",
"Girard",
"André",
"Lefevre",
"Mercier",
"Dupont",
"Lambert",
"Bonnet",
"François",
"Martinez",
"Legrand",
"Garnier",
"Faure",
"Rousseau",
"Blanc",
"Guerin",
"Muller",
"Henry",
"Roussel",
"Nicolas",
]
_C = PositionCategory
# (nom, catégorie, lieu, _ inutilisé)
POSITIONS: list[tuple[str, PositionCategory, str, list[str]]] = [
("Bar central", _C.BAR, "Chapiteau", []),
("Bar scène", _C.BAR, "Grande scène", []),
("Billetterie entrée", _C.TICKETING, "Entrée principale", []),
("Billetterie navette", _C.TICKETING, "Parking P2", []),
("Accueil artistes", _C.WELCOME, "Backstage", []),
("Accueil public", _C.WELCOME, "Entrée principale", []),
("Parking P1", _C.PARKING, "Entrée nord", ["PERMIS_B"]),
("Parking P2", _C.PARKING, "Entrée sud", ["PERMIS_B"]),
("Propreté site", _C.CLEANING, "Ensemble du site", []),
("Propreté sanitaires", _C.CLEANING, "Zone sanitaires", []),
("Catering bénévoles", _C.CATERING, "Tente staff", []),
("Catering artistes", _C.CATERING, "Backstage", []),
("Montage structures", _C.SETUP, "Ensemble du site", ["CACES"]),
("Régie grande scène", _C.STAGE, "Grande scène", ["REGIE_SON"]),
("Poste de secours", _C.OTHER, "Infirmerie", ["SST"]),
]
# fenêtres horaires (heure de début, heure de fin ; > 24 ⇒ le lendemain)
_WINDOWS: dict[PositionCategory, dict[str, list[tuple[int, int]]]] = {
_C.BAR: {"montage": [(16, 20)], "festival": [(12, 17), (17, 23)], "demontage": []},
_C.TICKETING: {"montage": [(14, 18)], "festival": [(9, 15), (15, 21)], "demontage": [(9, 13)]},
_C.WELCOME: {"montage": [(13, 18)], "festival": [(9, 15), (15, 21)], "demontage": [(9, 13)]},
_C.PARKING: {"montage": [(9, 13)], "festival": [(8, 14), (14, 20)], "demontage": [(9, 15)]},
_C.CLEANING: {
"montage": [(8, 12)],
"festival": [(7, 11), (23, 27)],
"demontage": [(8, 14), (14, 18)],
},
_C.CATERING: {
"montage": [(7, 10), (11, 15), (18, 22)],
"festival": [(7, 10), (11, 15), (18, 22)],
"demontage": [(7, 10), (11, 15)],
},
_C.SETUP: {
"montage": [(8, 12), (13, 18)],
"festival": [(9, 12)],
"demontage": [(9, 13), (14, 18)],
},
_C.STAGE: {"montage": [(10, 16)], "festival": [(14, 19), (19, 26)], "demontage": [(10, 15)]},
_C.OTHER: {"montage": [(9, 17)], "festival": [(12, 20), (20, 28)], "demontage": [(9, 15)]},
}
_STAFF: dict[PositionCategory, tuple[int, int]] = {
_C.BAR: (3, 6),
_C.TICKETING: (2, 3),
_C.WELCOME: (2, 4),
_C.PARKING: (2, 4),
_C.CLEANING: (2, 5),
_C.CATERING: (2, 4),
_C.SETUP: (3, 6),
_C.STAGE: (1, 2),
_C.OTHER: (1, 2),
}
# Programmation : trois passages par jour de festival (pas montage/démontage).
ARTIST_NAMES = [
"La Battante", "Orchestre Miroir", "Kntongo", "Louve",
"Les Frères Vent", "Nuage Bleu", "Sœur Machine", "Comète",
]
ARTIST_SLOTS: list[tuple[dt.time, dt.time]] = [
(dt.time(18, 0), dt.time(19, 0)),
(dt.time(19, 30), dt.time(21, 0)),
(dt.time(21, 30), dt.time(23, 0)),
]
@dataclass
class Summary:
volunteers: int = 0
accounts: int = 0
positions: int = 0
shifts: int = 0
understaffed_shifts: int = 0
assignments: int = 0
artists: int = 0
position_wishes: int = 0
artist_wishes: int = 0
companion_wishes: int = 0
warnings: Counter = field(default_factory=Counter)
blocking: int = 0
def __str__(self) -> str:
lines = [
"Jeu de démonstration « Festival des Lucioles » 2026",
f" Bénévoles : {self.volunteers}",
f" Comptes (connexion) : {self.accounts}",
f" Postes : {self.positions}",
f" Créneaux : {self.shifts}",
f" Créneaux sous l'effectif minimal (trous) : {self.understaffed_shifts}",
f" Affectations : {self.assignments}",
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()):
lines.append(f" {code} : {count}")
lines.append(f" Violations bloquantes : {self.blocking}")
lines.append("")
lines.append(
" Connexion coordination (mot de passe) : "
"coordination@lucioles.test / " + DEMO_COORDINATION_PASSWORD
)
lines.append(
" Référents et bénévoles : lien magique "
"(referent0..2@lucioles.test, <prénom>.<nom><n>@example.test)"
)
return "\n".join(lines)
def _slug(value: str) -> str:
return normalize(value).replace(" ", "").replace("-", "")
def _overlap(a: Shift, b: Shift) -> bool:
return a.starts_at < b.ends_at and b.starts_at < a.ends_at
def _wipe(session: Session) -> None:
for edition in session.exec(select(Edition).where(Edition.name == DEMO_EDITION_NAME)):
session.delete(edition)
session.flush()
def build(session: Session, *, rng: random.Random | None = None) -> Summary:
rng = rng or random.Random(SEED)
_wipe(session)
edition = Edition(
year=DEMO_YEAR,
name=DEMO_EDITION_NAME,
start_date=START,
end_date=END,
retention_days=365,
)
session.add(edition)
session.flush()
day_parts = [
DayPart(
edition_id=edition.id,
code=code,
label=label,
start_time=start,
end_time=end,
order=order,
)
for order, (code, label, start, end) in enumerate(
[
("MORNING", "Matin", dt.time(6, 0), dt.time(13, 0)),
("AFTERNOON", "Après-midi", dt.time(13, 0), dt.time(19, 0)),
("EVENING", "Soirée", dt.time(19, 0), dt.time(6, 0)),
]
)
]
session.add_all(day_parts)
session.flush()
dp_windows = tuple(
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order) for dp in day_parts
)
coordination = Account(
edition_id=edition.id,
email="coordination@lucioles.test",
role=Role.COORDINATOR,
password_hash=hash_password(DEMO_COORDINATION_PASSWORD),
)
leads = [
Account(
edition_id=edition.id,
email=f"referent{i}@lucioles.test",
role=Role.LEAD,
)
for i in range(3)
]
session.add_all([coordination, *leads])
session.flush()
positions: list[Position] = []
for index, (name, category, location, _required) in enumerate(POSITIONS):
position = Position(
edition_id=edition.id,
name=name,
category=category,
location=location,
)
if index in (12, 13, 14):
position.lead_account_id = leads[index - 12].id
session.add(position)
session.flush()
positions.append(position)
dates = [START + dt.timedelta(days=k) for k in range(4)]
phase_of = {
dates[0]: "montage",
dates[1]: "festival",
dates[2]: "festival",
dates[3]: "demontage",
}
artists: list[Artist] = []
for day in dates:
if phase_of[day] != "festival":
continue
for start, end in ARTIST_SLOTS:
artist = Artist(
edition_id=edition.id,
name=ARTIST_NAMES[len(artists) % len(ARTIST_NAMES)],
performance_date=day,
start_time=start,
end_time=end,
)
session.add(artist)
artists.append(artist)
session.flush()
volunteers: list[Volunteer] = []
used_names: set[tuple[str, str]] = set()
n_position_wishes = 0
n_artist_wishes = 0
for i in range(120):
while True:
first = rng.choice(FIRST_NAMES)
last = rng.choice(LAST_NAMES)
if (first, last) not in used_names:
used_names.add((first, last))
break
email = f"{_slug(first)}.{_slug(last)}{i}@example.test" if rng.random() < 0.85 else None
volunteer = Volunteer(
edition_id=edition.id,
first_name=first,
last_name=last,
email=email,
phone=(f"06{rng.randint(10_000_000, 99_999_999)}" if rng.random() < 0.8 else None),
)
session.add(volunteer)
session.flush()
volunteers.append(volunteer)
if email is not None:
session.add(
Account(
edition_id=edition.id,
email=email,
role=Role.VOLUNTEER,
volunteer_id=volunteer.id,
)
)
# Vœux : poste qu'il voudrait tenir, artistes qu'il veut voir.
if rng.random() < 0.4:
session.add(
VolunteerPositionWish(
volunteer_id=volunteer.id,
position_id=rng.choice(positions).id,
)
)
n_position_wishes += 1
for artist in artists:
if rng.random() < 0.15:
session.add(
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))
availabilities: dict[object, list[AvailabilityWindow]] = {}
for volunteer in volunteers:
windows: list[AvailabilityWindow] = []
for day in dates:
if rng.random() < 0.8:
start, end = _FULL_DAY
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=day,
start_time=start,
end_time=end,
)
)
windows.append(AvailabilityWindow(day, start, end))
availabilities[volunteer.id] = windows
shifts: list[Shift] = []
for position, (_n, category, _l, _r) in zip(positions, POSITIONS, strict=True):
for day in dates:
for start_hour, end_hour in _WINDOWS[category][phase_of[day]]:
min_staff, ideal_staff = _STAFF[category]
min_staff = max(1, min_staff + rng.choice([-1, 0, 0, 1]))
ideal_staff = max(min_staff + 1, ideal_staff + rng.choice([-1, 0, 1]))
shift = Shift(
edition_id=edition.id,
position_id=position.id,
starts_at=dt.datetime.combine(day, dt.time(start_hour, 0)),
ends_at=dt.datetime.combine(day, dt.time(0, 0)) + dt.timedelta(hours=end_hour),
min_staff=min_staff,
ideal_staff=ideal_staff,
)
session.add(shift)
session.flush()
shifts.append(shift)
assigned: dict[object, list[Shift]] = {v.id: [] for v in volunteers}
# Trois bénévoles réservés aux scénarios d'avertissement : exclus de
# l'affectation générale pour rester libres.
reserved = volunteers[:3]
reserved_ids = {v.id for v in reserved}
assignable = [v for v in volunteers if v.id not in reserved_ids]
def _clean(volunteer: Volunteer, shift: Shift) -> bool:
data = ValidationInput(
candidate_shift=ShiftInfo(
id=str(shift.id),
starts_at=shift.starts_at,
ends_at=shift.ends_at,
),
existing_shifts=tuple(
ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at)
for s in assigned[volunteer.id]
),
availabilities=tuple(availabilities[volunteer.id]),
day_parts=dp_windows,
)
return not validate(data)
n_assignments = 0
order = shifts[:]
rng.shuffle(order)
for index, shift in enumerate(order):
if index % 16 == 0: # ~6 créneaux : trou d'effectif volontaire
target = max(0, shift.min_staff - 1)
else:
target = rng.randint(shift.min_staff, shift.ideal_staff)
pool = assignable[:]
rng.shuffle(pool)
placed = 0
for volunteer in pool:
if placed >= target:
break
if any(_overlap(shift, s) for s in assigned[volunteer.id]):
continue
if not _clean(volunteer, shift):
continue
session.add(
Assignment(
edition_id=edition.id,
volunteer_id=volunteer.id,
shift_id=shift.id,
source=AssignmentSource.MANUAL,
)
)
assigned[volunteer.id].append(shift)
placed += 1
n_assignments += 1
n_assignments += _add_demo_warnings(
session, edition, shifts, reserved, assigned, availabilities
)
session.flush()
fill: Counter = Counter()
for volunteer_shifts in assigned.values():
for shift in volunteer_shifts:
fill[shift.id] += 1
session.flush()
account_count = len(
session.exec(
select(Account).where(Account.edition_id == edition.id)
).all()
)
summary = Summary(
volunteers=len(volunteers),
accounts=account_count,
positions=len(positions),
shifts=len(shifts),
assignments=n_assignments,
understaffed_shifts=sum(1 for shift in shifts if fill[shift.id] < shift.min_staff),
artists=len(artists),
position_wishes=n_position_wishes,
artist_wishes=n_artist_wishes,
companion_wishes=n_companion_wishes,
)
_verify(session, edition, summary)
return summary
def _clear_day(session, volunteer, day, availabilities) -> None:
session.execute(
delete(Availability)
.where(Availability.volunteer_id == volunteer.id)
.where(Availability.date == day)
)
availabilities[volunteer.id] = [
w for w in availabilities[volunteer.id] if w.date != day
]
def _declare_window(
session, edition, volunteer, day, start, end, availabilities
) -> None:
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=day,
start_time=start,
end_time=end,
)
)
availabilities[volunteer.id].append(AvailabilityWindow(day, start, end))
def _add_demo_warnings(
session, edition, shifts, reserved, assigned, availabilities
) -> int:
"""Quelques affectations qui déclenchent des avertissements (et rien
d'autre) : hors disponibilité, repos insuffisant. `reserved` = trois
bénévoles tenus à l'écart de l'affectation générale.
"""
a, b, tired = reserved
added = 0
# OUTSIDE_AVAILABILITY : deux bénévoles affectés un jour où ils n'ont
# déclaré aucune disponibilité.
plain = sorted(shifts, key=lambda s: s.starts_at)
for volunteer, shift in ((a, plain[3]), (b, plain[7])):
_clear_day(session, volunteer, shift.starts_at.date(), availabilities)
_add_active(session, edition, volunteer, shift, assigned)
added += 1
# INSUFFICIENT_REST : deux créneaux espacés de moins de 8 h, sur deux
# journées (pour ne pas déclencher le cumul), tous deux couverts par une
# disponibilité.
pair = _find_rest_pair(shifts)
if pair is not None:
first, second = pair
for shift in (first, second):
_clear_day(session, tired, shift.starts_at.date(), availabilities)
_declare_window(
session,
edition,
tired,
shift.starts_at.date(),
dt.time(6, 0),
dt.time(4, 0),
availabilities,
)
_add_active(session, edition, tired, first, assigned)
_add_active(session, edition, tired, second, assigned)
added += 2
return added
def _find_rest_pair(shifts: list[Shift]) -> tuple[Shift, Shift] | None:
plain = sorted(shifts, key=lambda s: s.starts_at)
for i, first in enumerate(plain):
for second in plain[i + 1 :]:
gap = second.starts_at - first.ends_at
if (
first.ends_at <= second.starts_at
and dt.timedelta(0) < gap < dt.timedelta(hours=8)
and first.starts_at.date() != second.starts_at.date()
):
return first, second
return None
def _add_active(session, edition, volunteer, shift, assigned) -> None:
session.add(
Assignment(
edition_id=edition.id,
volunteer_id=volunteer.id,
shift_id=shift.id,
source=AssignmentSource.MANUAL,
)
)
assigned[volunteer.id].append(shift)
def _verify(session: Session, edition: Edition, summary: Summary) -> None:
session.flush()
assignments = session.exec(
select(Assignment).where(Assignment.edition_id == edition.id)
)
for assignment in assignments:
volunteer = session.get(Volunteer, assignment.volunteer_id)
shift = session.get(Shift, assignment.shift_id)
for violation in validate(
build_validation_input(
session=session,
edition=edition,
volunteer=volunteer,
shift=shift,
exclude_assignment_id=assignment.id,
)
):
if violation.severity is Severity.BLOCKING:
summary.blocking += 1
else:
summary.warnings[violation.code] += 1
def main() -> None:
with Session(engine) as session:
summary = build(session)
session.commit()
print(summary)
if __name__ == "__main__":
main()