- Lien d'inscription protégé par un jeton (FormTemplate.access_token, /inscription/<token>), avec régénération depuis l'écran de configuration. - Reçus fiscaux mécénat (cerfa 11580*05) : un PDF par bénévole ayant payé en mécénat, donateur/montant (chiffres et toutes lettres en français, app/mecenat/number_words.py)/date complétés par-dessus le modèle officiel. - Jeu de démonstration : bénévoles avec cotisation classique ou mécénat payée. - Infobulles (HelpTip et détail de cellule de la grille des besoins) repositionnées en position: fixed via JS, bornées à l'écran. - Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une URL (fuyait le jeton d'inscription), sujet retiré du log de remise des emails (pouvait porter un nom de bénévole), photo du formulaire public revalidée par décodage réel avant stockage, export Excel protégé contre l'injection de formule, sondage du statut de paiement limité en fréquence, pyjwt mis à jour. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
725 lines
23 KiB
Python
725 lines
23 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,
|
|
FeePaymentStatus,
|
|
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)),
|
|
]
|
|
|
|
# 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
|
|
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" Cotisation payée (classique) : {self.fee_paid_classique}",
|
|
f" Cotisation payée (mécénat) : {self.fee_paid_mecenat}",
|
|
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
|
|
)
|
|
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 _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 _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 _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()
|