Files
benevoles/backend/app/seed.py
T
AdamThomasandClaude Sonnet 5 4a60c9ec34
ci-cd / build-backend (push) Failing after 7s
ci-cd / build-frontend (push) Failing after 6s
ci-cd / deploy (push) Skipped
ci-cd / backend-test (push) Successful in 52s
ci-cd / frontend-test (push) Successful in 32s
Jeu de démo : restaurer les 15 postes avec catégories et lieux corrects
La liste POSITIONS avait été remplacée par 16 postes tous classés BAR
et sans lieu, faisant échouer test_seed.py (assert 16 == 15).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:09:20 +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()