Files
AdamThomasandClaude Opus 5.5 d722a81a87
ci-cd / build-backend (push) Successful in 31s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s
Retrait des disponibilités et des limites de temps dans la journée
Disponibilités déclarées supprimées (table `availability`, migration 0022,
API, éditeur de la fiche bénévole, badge de couverture du panneau
d'affectation, colonnes des statistiques) ainsi que les règles
OUTSIDE_AVAILABILITY, DAILY_HOURS_EXCEEDED et INSUFFICIENT_REST, les malus
de disponibilité et de repos du solveur et le filtre « Surchargés » de la
vue bénévoles. Seul le chevauchement reste contrôlé. La case « je suis
disponible » du formulaire public est conservée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:29:58 +02:00

565 lines
18 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
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,
DayPart,
Edition,
FeePaymentStatus,
Position,
PositionCategory,
Role,
Shift,
Volunteer,
VolunteerArtist,
VolunteerCompanionWish,
VolunteerPositionWish,
)
from app.validation import Severity, 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)),
]
# Adresses postales des bénévoles « mécénat » — voir `_add_demo_payments`.
MECENAT_ADDRESSES = [
("12 rue des Lilas", "75020", "Paris"),
("3 impasse du Petit Bois", "44000", "Nantes"),
("8 avenue de la République", "69003", "Lyon"),
("21 chemin des Vignes", "33000", "Bordeaux"),
]
@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
fee_paid_classique: int = 0
fee_paid_mecenat: int = 0
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" Cotisation payée (classique) : {self.fee_paid_classique}",
f" Cotisation payée (mécénat) : {self.fee_paid_mecenat}",
]
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()
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
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}
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 = volunteers[:]
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
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_fee_classique, n_fee_mecenat = _add_demo_payments(session, volunteers, rng)
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,
fee_paid_classique=n_fee_classique,
fee_paid_mecenat=n_fee_mecenat,
)
_verify(session, edition, summary)
return summary
def _add_demo_payments(
session: Session, volunteers: list[Volunteer], rng: random.Random
) -> tuple[int, int]:
"""Quelques bénévoles ayant payé leur cotisation via SumUp — voir
CLAUDE.md § 2, Paiement de la cotisation. `fee_payment_label`/
`fee_amount` sont une copie figée (comme en production) : aucun
`FormPaymentOption` n'a besoin d'exister pour autant. Pour que l'export
Excel et les reçus fiscaux mécénat (`/editions/{id}/volunteers/
mecenat-receipts`) aient de quoi montrer quelque chose en démonstration."""
pool = rng.sample(volunteers, k=12)
classique, mecenat = pool[:8], pool[8:]
for volunteer in classique:
volunteer.fee_payment_label = "Cotisation classique"
volunteer.fee_amount = 15.0
volunteer.fee_payment_status = FeePaymentStatus.PAID
volunteer.fee_paid_at = dt.datetime.combine(
START - dt.timedelta(days=rng.randint(5, 45)), dt.time(rng.randint(9, 21))
)
session.add(volunteer)
for i, volunteer in enumerate(mecenat):
address, postal_code, city = MECENAT_ADDRESSES[i % len(MECENAT_ADDRESSES)]
volunteer.fee_payment_label = "Mécénat"
volunteer.fee_amount = rng.choice([30.0, 50.0, 80.0, 120.0])
volunteer.fee_payment_status = FeePaymentStatus.PAID
volunteer.fee_paid_at = dt.datetime.combine(
START - dt.timedelta(days=rng.randint(5, 45)), dt.time(rng.randint(9, 21))
)
volunteer.mecenat_address = address
volunteer.mecenat_postal_code = postal_code
volunteer.mecenat_city = city
volunteer.mecenat_country = "France"
session.add(volunteer)
return len(classique), len(mecenat)
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
def main() -> None:
with Session(engine) as session:
summary = build(session)
session.commit()
print(summary)
if __name__ == "__main__":
main()