From 6c4297b5fc3f06f7d6a16fe5a576c2fea9a215f1 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Thu, 10 Sep 2026 21:05:14 +0200 Subject: [PATCH] =?UTF-8?q?Ajout=20de=20`python=20-m=20app.bootstrap`=20:?= =?UTF-8?q?=20instance=20vierge=20sans=20jeu=20de=20d=C3=A9mo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crée une édition + le premier compte coordination (mot de passe) + 3 créneaux types par défaut, et affiche l'UUID à mettre dans VITE_EDITION_ID. Documenté dans README (« Vraie instance, sans jeu de démo »). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM --- CLAUDE.md | 2 + README.md | 33 ++++++++- backend/app/bootstrap.py | 121 ++++++++++++++++++++++++++++++++ backend/tests/test_bootstrap.py | 66 +++++++++++++++++ 4 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 backend/app/bootstrap.py create mode 100644 backend/tests/test_bootstrap.py diff --git a/CLAUDE.md b/CLAUDE.md index c3591b8..26054d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -269,6 +269,7 @@ fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)* │ │ ├── importing/ # import CSV/Excel (parser, values, service) │ │ ├── models/ # tables SQLModel │ │ ├── validation/ # moteur pur (models, engine) + loader (base) +│ │ ├── bootstrap.py # instance vierge : édition + 1er compte │ │ ├── seed.py # jeu de démonstration (python -m app.seed) │ │ ├── i18n/ │ │ │ ├── __init__.py # t(key, **params) @@ -297,6 +298,7 @@ Aucune dépendance locale requise hors Docker. | Créer une migration | `docker compose run --rm backend uv run alembic revision --autogenerate -m "..."` | | Appliquer les migrations | `docker compose run --rm backend uv run alembic upgrade head` | | Vérifier migration ↔ modèle | `docker compose run --rm backend uv run alembic check` (après `upgrade head`) | +| Instance vierge (édition + 1er compte) | `docker compose run --rm backend uv run python -m app.bootstrap --name … --start … --end … --email … --password …` | | Charger le jeu de démo | `docker compose run --rm backend uv run python -m app.seed` (après `alembic upgrade head`) | --- diff --git a/README.md b/README.md index 0c14b14..c20c573 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,37 @@ docker compose up --build - Documentation interactive : http://localhost:8000/docs Le frontend démarre en mode `mock` (jeu de démonstration en mémoire, aucun -backend requis). Pour le brancher sur l'API réelle : `VITE_API_MODE=http` et -`VITE_EDITION_ID=` dans `.env` (voir `frontend/README.md`). +backend requis). + +### Vraie instance, sans jeu de démo + +```bash +docker compose up -d db backend +docker compose run --rm backend uv run alembic upgrade head +docker compose run --rm backend uv run python -m app.bootstrap \ + --name "Festival 2026" --start 2026-07-01 --end 2026-07-04 \ + --email coordination@monfestival.fr --password "un-mot-de-passe-solide" +``` + +La commande crée une édition vierge, le premier compte coordination (connexion +par mot de passe) et 3 créneaux types par défaut, puis affiche l'UUID de +l'édition. Renseigner alors dans `.env` : + +``` +VITE_API_MODE=http +VITE_EDITION_ID= +``` + +puis `docker compose up frontend`. + +### Jeu de démonstration (optionnel) + +```bash +docker compose run --rm backend uv run python -m app.seed +``` + +120 bénévoles fictifs, 15 postes, 4 jours. Identifiants affichés en fin de +commande. ## Tests diff --git a/backend/app/bootstrap.py b/backend/app/bootstrap.py new file mode 100644 index 0000000..6fc7574 --- /dev/null +++ b/backend/app/bootstrap.py @@ -0,0 +1,121 @@ +"""Crée une édition vierge et son premier compte coordination. + +À utiliser pour démarrer une vraie instance, sans le jeu de démonstration : + + docker compose run --rm backend uv run python -m app.bootstrap \\ + --name "Festival 2026" --start 2026-07-01 --end 2026-07-04 \\ + --email coordination@monfestival.fr --password "mot-de-passe-solide" + +Imprime l'UUID de l'édition à reporter dans VITE_EDITION_ID. +""" + +import argparse +import datetime as dt +import sys + +from sqlmodel import Session, select + +from app.auth.passwords import MIN_LENGTH, hash_password +from app.db import engine +from app.models import Account, DayPart, Edition, Role + +# (code, libellé, début, fin) — fin <= début ⇒ passe minuit. +_DEFAULT_DAY_PARTS = [ + ("MATIN", "Matin", dt.time(8, 0), dt.time(13, 0)), + ("APRES_MIDI", "Après-midi", dt.time(13, 0), dt.time(19, 0)), + ("SOIREE", "Soirée", dt.time(19, 0), dt.time(2, 0)), +] + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(prog="python -m app.bootstrap") + parser.add_argument("--name", required=True) + parser.add_argument("--start", required=True, help="AAAA-MM-JJ") + parser.add_argument("--end", required=True, help="AAAA-MM-JJ") + parser.add_argument("--email", required=True, help="Compte coordination") + parser.add_argument("--password", required=True) + parser.add_argument("--year", type=int, default=None) + parser.add_argument( + "--no-day-parts", + action="store_true", + help="Ne pas créer les créneaux types par défaut", + ) + parser.add_argument("--retention-days", type=int, default=None) + return parser.parse_args(argv) + + +def run(args: argparse.Namespace, session: Session) -> Edition: + start = dt.date.fromisoformat(args.start) + end = dt.date.fromisoformat(args.end) + if end < start: + raise SystemExit("--end doit être postérieur ou égal à --start") + if len(args.password) < MIN_LENGTH: + raise SystemExit(f"--password : au moins {MIN_LENGTH} caractères") + + edition = Edition( + year=args.year or start.year, + name=args.name, + start_date=start, + end_date=end, + retention_days=args.retention_days, + ) + session.add(edition) + session.flush() + + existing = session.exec( + select(Account) + .where(Account.edition_id == edition.id) + .where(Account.email == args.email.strip()) + ).first() + if existing is not None: + raise SystemExit("Un compte avec cet email existe déjà.") + + session.add( + Account( + edition_id=edition.id, + email=args.email.strip(), + role=Role.COORDINATOR, + password_hash=hash_password(args.password), + ) + ) + + if not args.no_day_parts: + for order, (code, label, begin, finish) in enumerate( + _DEFAULT_DAY_PARTS + ): + session.add( + DayPart( + edition_id=edition.id, + code=code, + label=label, + start_time=begin, + end_time=finish, + order=order, + ) + ) + + session.commit() + session.refresh(edition) + return edition + + +def main() -> None: + args = _parse_args(sys.argv[1:]) + with Session(engine) as session: + edition = run(args, session) + + print(f"Édition créée : {edition.id}") + print(f" {edition.name} ({edition.start_date} → {edition.end_date})") + print(f"Compte coordination : {args.email} (mot de passe défini)") + if not args.no_day_parts: + print( + "Créneaux types : " + + ", ".join(code for code, *_ in _DEFAULT_DAY_PARTS) + ) + print() + print("Frontend : VITE_API_MODE=http " + f"VITE_EDITION_ID={edition.id}") + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_bootstrap.py b/backend/tests/test_bootstrap.py new file mode 100644 index 0000000..14aa3d4 --- /dev/null +++ b/backend/tests/test_bootstrap.py @@ -0,0 +1,66 @@ +import argparse +import datetime as dt + +import pytest +from sqlmodel import select + +from app.bootstrap import run +from app.models import Account, DayPart, Edition, Role + + +def _args(**overrides) -> argparse.Namespace: + base = { + "name": "Festival 2027", + "start": "2027-07-01", + "end": "2027-07-04", + "email": "coord@monfestival.fr", + "password": "un-mot-de-passe-solide", + "year": None, + "no_day_parts": False, + "retention_days": None, + } + base.update(overrides) + return argparse.Namespace(**base) + + +def test_creates_edition_coordinator_and_default_day_parts(session): + edition = run(_args(), session) + + reloaded = session.get(Edition, edition.id) + assert reloaded.name == "Festival 2027" + assert reloaded.year == 2027 + assert reloaded.start_date == dt.date(2027, 7, 1) + + account = session.exec( + select(Account).where(Account.edition_id == edition.id) + ).one() + assert account.role is Role.COORDINATOR + assert account.auth_method == "password" + + codes = [ + d.code + for d in session.exec( + select(DayPart).where(DayPart.edition_id == edition.id) + ) + ] + assert set(codes) == {"MATIN", "APRES_MIDI", "SOIREE"} + + +def test_no_day_parts_flag(session): + edition = run(_args(no_day_parts=True), session) + assert ( + session.exec( + select(DayPart).where(DayPart.edition_id == edition.id) + ).all() + == [] + ) + + +def test_rejects_short_password(session): + with pytest.raises(SystemExit): + run(_args(password="court"), session) + + +def test_rejects_end_before_start(session): + with pytest.raises(SystemExit): + run(_args(start="2027-07-04", end="2027-07-01"), session)