From 990b5d0db8cb78e2a81f65b3fdf991aaad8ca891 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Fri, 23 Oct 2026 23:51:00 +0200 Subject: [PATCH 1/2] Seed fiera: utenti, stazione, ricette e storico misure (#85) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB --- scripts/seed_fiera.py | 458 +++++++++++++++++++++++++++ src/backend/tests/test_seed_fiera.py | 194 ++++++++++++ 2 files changed, 652 insertions(+) create mode 100644 scripts/seed_fiera.py create mode 100644 src/backend/tests/test_seed_fiera.py diff --git a/scripts/seed_fiera.py b/scripts/seed_fiera.py new file mode 100644 index 0000000..3a06121 --- /dev/null +++ b/scripts/seed_fiera.py @@ -0,0 +1,458 @@ +"""Seed per il PC della fiera: utenti, stazione, tre ricette e quattro settimane di misure. + +Cosa fa + Popola un database TieMeasureFlow (SQLite del pacchetto Windows) cosi' che al primo + avvio NON compaia la procedura di setup e la demo abbia gia' qualcosa da mostrare: + + - utenti adriano (admin), maker, capoturno, operatore (password nella costante + UTENTI qui sotto, decise da Adriano per la fiera); + - la stazione ST-DEFAULT (quella del pacchetto, vedi packaging/windows/avvio.py) + con camera_url http://127.0.0.1:8765 e camera_source camera:601020 (Itala); + - FIERA-FLANGIA «Flangia Ø80» (5 quote) e FIERA-ALBERO «Albero rettificato» + (4 quote), con circa 4 settimane di storico fino a ieri (lun-ven 8-17, una + produzione chiusa per giorno, lotti e seriali coerenti, misure con Cpk diversi + per quota, una deriva lenta nell'ultima settimana, una quota con 1-3% fuori + tolleranza); + - FIERA-DXF «Profilo DXF (visione)»: un task dxf_compare senza ricetta di visione + (si crea in fiera dal pannello «Crea ricetta da DXF»). + + «Setup fatto» per il backend vuol dire: esiste almeno un utente admin + (api/routers/setup.py::_admin_exists), quindi basta creare adriano. + +Come si usa (da daitarn, TMF fermo sul PC, su una COPIA del file SQLite) + uv run python scripts/seed_fiera.py --db C:/percorso/tmf.db [--replace] [--dry-run] + [--fine AAAA-MM-GG] + + Il file deve avere gia' lo schema (alembic upgrade head: lo fa l'exe al primo + avvio). --replace toglie solo i dati FIERA-* (ricette, misure, produzioni) e li + rifa'; senza, un secondo giro non duplica le ricette. Gli utenti e la stazione + sono idempotenti: se esistono si aggiornano password, ruoli e camera. + --dry-run fa tutto e annulla (stampa i numeri). Il seme casuale e' fisso: stesso + comando e stessa data di fine, stessi dati. + +Da cosa dipende + src.backend (modelli ORM, auth_service, measurement_service), SQLAlchemy + + aiosqlite, tzdata su Windows (gia' in pyproject). Non usa MySQL. +""" +import argparse +import asyncio +import random +import sys +from datetime import date, datetime, time, timedelta, timezone +from pathlib import Path +from zoneinfo import ZoneInfo + +from sqlalchemy import delete, event, select +from sqlalchemy.ext.asyncio import ( + AsyncSession, async_sessionmaker, create_async_engine, +) + +# La radice del repo nel path, cosi' "src.backend" si importa lanciando lo script da ovunque +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from src.backend.database import async_session_factory # noqa: E402 +from src.backend.models.orm.measurement import Measurement # noqa: E402 +from src.backend.models.orm.production import ( # noqa: E402 + ProductionEvent, ProductionRun, +) +from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: E402 +from src.backend.models.orm.setting import RecipeVersionAudit # noqa: E402 +from src.backend.models.orm.station import ( # noqa: E402 + Station, StationRecipeAssignment, +) +from src.backend.models.orm.task import RecipeSubtask, RecipeTask # noqa: E402 +from src.backend.models.orm.task_result import TaskResult # noqa: E402 +from src.backend.models.orm.user import User # noqa: E402 +from src.backend.services import auth_service # noqa: E402 +from src.backend.services.measurement_service import calculate_pass_fail # noqa: E402 + +CODES = ("FIERA-FLANGIA", "FIERA-ALBERO", "FIERA-DXF") +STATION_CODE = "ST-DEFAULT" # STATION_CODE del pacchetto Windows (avvio.py PREDEFINITI) +CAMERA_URL = "http://127.0.0.1:8765" +CAMERA_SOURCE = "camera:601020" # Itala sul PC fiera +SEED = 20261026 # seme fisso: dati ripetibili +FUSO = ZoneInfo("Europe/Rome") # le misure sono salvate in UTC naive, l'orario di turno e' locale +GIORNI_STORICO = 28 # circa 4 settimane + +# (username, password, nome, ruoli, admin). Password decise da Adriano per la fiera. +UTENTI = [ + ("adriano", "!Adriano77", "Adriano Dal Pastro", + ["Maker", "MeasurementTec", "Metrologist", "Supervisor"], True), + ("maker", "Fiera2026!", "Marco Ferrari (Maker)", ["Maker"], False), + ("capoturno", "Fiera2026!", "Luca Bianchi (Capoturno)", + ["Supervisor", "MeasurementTec"], False), + ("operatore", "Fiera2026!", "Giulia Rossi (Operatore)", ["MeasurementTec"], False), +] + + +def _quota(marker, descrizione, nominale, tol, unita="mm", dec=3): + """Quota simmetrica: tolleranza = nominale +/- tol, warning al 70% della tolleranza.""" + return dict( + marker_number=marker, description=descrizione, nominal=nominale, + ltl=round(nominale - tol, dec), lwl=round(nominale - 0.7 * tol, dec), + uwl=round(nominale + 0.7 * tol, dec), utl=round(nominale + tol, dec), + unit=unita, + ) + + +# Profilo statistico di una quota: scostamento medio (in frazioni di tol), sigma (in +# frazioni di tol) e deriva (frazione di tol aggiunta linearmente nell'ultima settimana). +# Cpk ~ (1 - |scostamento|) / (3 * sigma): sigma 0.2 -> 1,67; 0.33 -> 1,0. +FLANGIA = [ + # (task, quota, scostamento, sigma, deriva) + (0, _quota(1, "Diametro esterno Ø80", 80.0, 0.05), 0.0, 0.20, 0.0), # Cpk ~1,67 capace + (0, _quota(2, "Diametro foro centrale Ø25", 25.0, 0.02), 0.0, 0.33, 0.0), # Cpk ~1,0 al limite + (1, _quota(3, "Spessore flangia", 12.0, 0.10), 0.0, 0.22, 0.55), # deriva ultima settimana + (1, _quota(4, "Interasse fori Ø60", 60.0, 0.10), 0.10, 0.25, 0.0), # Cpk ~1,2 + (1, _quota(5, "Profondita' lamatura", 8.0, 0.15), 0.50, 0.244, 0.0), # ~2% fuori tolleranza +] +ALBERO = [ + (0, _quota(1, "Diametro cuscinetto Ø20 h6", 20.0, 0.010), 0.0, 0.20, 0.0), # Cpk ~1,67 + (0, _quota(2, "Diametro sede Ø25 j5", 25.0, 0.008), 0.0, 0.25, 0.0), # Cpk ~1,33 + (1, _quota(3, "Lunghezza totale", 150.0, 0.10), 0.0, 0.33, 0.0), # Cpk ~1,0 + (1, _quota(4, "Rugosita' Ra", 0.40, 0.20, unita="µm"), 0.20, 0.25, 0.0), # Cpk ~1,07 +] + + +def _ora_utc(giorno: date, minuti_da_mezzanotte: int) -> datetime: + """Ora locale di officina -> datetime UTC naive, come le salva il backend.""" + locale = datetime.combine(giorno, time(0, 0), tzinfo=FUSO) + timedelta(minutes=minuti_da_mezzanotte) + return locale.astimezone(timezone.utc).replace(tzinfo=None) + + +def _giorni_lavorativi(fine: date) -> list[date]: + """Lun-ven degli ultimi GIORNI_STORICO giorni, fino a `fine` compreso.""" + giorni = [fine - timedelta(days=i) for i in range(GIORNI_STORICO)] + return sorted(g for g in giorni if g.weekday() < 5) + + +def _slot_giornalieri(rng: random.Random, n: int) -> list[int]: + """n orari (minuti dalla mezzanotte) in 8-12 e 13-17, ogni ~30 min con un po' di scarto.""" + mezz_ore = [8 * 60 + 30 * i for i in range(8)] + [13 * 60 + 30 * i for i in range(8)] + scelti = sorted(rng.sample(mezz_ore, n)) + return [m + rng.randint(0, 9) for m in scelti] + + +async def _upsert_utenti(session: AsyncSession) -> dict[str, User]: + """Crea gli utenti o, se esistono, ne aggiorna password, nome, ruoli e flag admin.""" + utenti = {} + for username, password, nome, ruoli, admin in UTENTI: + esistente = (await session.execute( + select(User).where(User.username == username) + )).scalar_one_or_none() + if esistente is None: + utenti[username] = await auth_service.create_user( + session, username=username, password=password, display_name=nome, + roles=ruoli, is_admin=admin, + ) + else: + esistente.password_hash = auth_service.hash_password(password) + esistente.display_name = nome + esistente.roles = ruoli + esistente.is_admin = admin + esistente.active = True + utenti[username] = esistente + await session.flush() + return utenti + + +async def _upsert_stazione(session: AsyncSession, owner: User) -> Station: + """La stazione del pacchetto, con la camera Itala; se c'e' gia' ne aggiorna la camera.""" + stazione = (await session.execute( + select(Station).where(Station.code == STATION_CODE) + )).scalar_one_or_none() + if stazione is None: + stazione = Station( + code=STATION_CODE, name="Postazione fiera", location="Stand", + created_by=owner.id, + ) + session.add(stazione) + stazione.camera_url = CAMERA_URL + stazione.camera_source = CAMERA_SOURCE + await session.flush() + return stazione + + +async def _conta_e_cancella(session: AsyncSession, ricette: list[Recipe]) -> dict: + """Toglie le ricette FIERA-* e tutto cio' che e' misurato contro di esse. Ritorna i conteggi.""" + conti = {"misure": 0, "produzioni": 0, "eventi": 0} + for ricetta in ricette: + versioni = (await session.execute( + select(RecipeVersion.id).where(RecipeVersion.recipe_id == ricetta.id) + )).scalars().all() + runs = (await session.execute( + select(ProductionRun.id).where(ProductionRun.recipe_id == ricetta.id) + )).scalars().all() + if versioni: + conti["misure"] += (await session.execute( + delete(Measurement).where(Measurement.version_id.in_(versioni)) + )).rowcount + await session.execute( # esiti dei task di queste versioni (FK sulle versioni) + delete(TaskResult).where(TaskResult.version_id.in_(versioni)) + ) + if runs: + conti["eventi"] += (await session.execute( + delete(ProductionEvent).where(ProductionEvent.run_id.in_(runs)) + )).rowcount + conti["produzioni"] += (await session.execute( + delete(ProductionRun).where(ProductionRun.id.in_(runs)) + )).rowcount + await session.execute( + delete(StationRecipeAssignment).where(StationRecipeAssignment.recipe_id == ricetta.id) + ) + await session.execute( + delete(RecipeVersionAudit).where(RecipeVersionAudit.recipe_id == ricetta.id) + ) + # Versioni, task e quote se ne vanno con la cascade ORM della ricetta. + await session.delete(ricetta) + await session.flush() + return conti + + +async def _nuova_ricetta(session, owner, stazione, *, code, name, description, + interval, requires_serial, note_versione): + """Ricetta + versione 1 corrente + assegnazione alla stazione.""" + ricetta = Recipe( + code=code, name=name, description=description, created_by=owner.id, active=True, + measurement_interval_minutes=interval, requires_lot=True, + requires_serial=requires_serial, allow_manual_input=True, + ) + session.add(ricetta) + await session.flush() + versione = RecipeVersion( + recipe_id=ricetta.id, version_number=1, is_current=True, + created_by=owner.id, change_notes=note_versione, + ) + session.add(versione) + session.add(StationRecipeAssignment( + station_id=stazione.id, recipe_id=ricetta.id, assigned_by=owner.id, + )) + await session.flush() + return ricetta, versione + + +async def _task(session, versione, ordine, titolo, tipo, direttiva=None, + descrizione=None, quote=()): + task = RecipeTask( + version_id=versione.id, order_index=ordine, title=titolo, task_type=tipo, + directive=direttiva, description=descrizione, + ) + session.add(task) + await session.flush() + sottotask = [] + for q in quote: + st = RecipeSubtask(task_id=task.id, **q) + session.add(st) + sottotask.append(st) + await session.flush() + return task, sottotask + + +async def _ricetta_meccanica(session, owner, stazione, *, code, name, description, + interval, requires_serial, profilo, titoli_task): + """Ricetta con una nota iniziale e un task di misura per ogni gruppo di quote del profilo.""" + ricetta, versione = await _nuova_ricetta( + session, owner, stazione, code=code, name=name, description=description, + interval=interval, requires_serial=requires_serial, + note_versione="Ricetta dimostrativa per la fiera", + ) + await _task(session, versione, 0, "Preparazione del pezzo", "note", + direttiva="Prima di misurare", + descrizione="Pulire il pezzo e controllare che sia a **temperatura ambiente**.") + sottotask_per_quota = {} + for indice, titolo in enumerate(titoli_task): + quote = [q for (t, q, *_r) in profilo if t == indice] + _t, sottotask = await _task( + session, versione, indice + 1, titolo, "measure", + direttiva="Misurare le quote in sequenza", quote=quote, + ) + for st in sottotask: + sottotask_per_quota[st.marker_number] = st + return ricetta, versione, sottotask_per_quota + + +def _valore(rng, quota, scost, sigma, deriva, frazione_tempo): + """Un valore: normale attorno a nominale + scostamento (+ deriva nell'ultima settimana).""" + tol = quota["utl"] - quota["nominal"] + media = quota["nominal"] + tol * (scost + deriva * max(0.0, (frazione_tempo - 0.75) / 0.25)) + return round(rng.gauss(media, tol * sigma), 6) + + +async def _storico(session, rng, fine, owner_ricetta, stazione, utenti, ricetta, versione, + sottotask, profilo, pezzi_giorno, prefisso_lotto, prefisso_seriale): + """Una produzione chiusa per giorno lavorativo, con i suoi pezzi, eventi e misure.""" + giorni = _giorni_lavorativi(fine) + operatore, capoturno = utenti["operatore"], utenti["capoturno"] + task_ids = {} # marker -> id del task di misura + for marker, st in sottotask.items(): + task_ids[marker] = st.task_id + ordine_task = sorted(set(task_ids.values())) + ultimo_task = ordine_task[-1] + contatore = 0 + totale = 0 + for indice_giorno, giorno in enumerate(giorni): + frazione = indice_giorno / max(1, len(giorni) - 1) # 0 = primo giorno, 1 = ieri + n = rng.randint(*pezzi_giorno) + slot = _slot_giornalieri(rng, n) + lotto = f"{prefisso_lotto}{giorno:%y%m%d}" + inizio, fine_run = _ora_utc(giorno, slot[0] - 5), _ora_utc(giorno, slot[-1] + 10) + run = ProductionRun( + station_id=stazione.id, recipe_id=ricetta.id, version_id=versione.id, + operator_id=operatore.id, lot_number=lotto, status="closed", + measurement_interval_minutes=ricetta.measurement_interval_minutes, + cycle_count=n, started_at=inizio, closed_at=fine_run, closed_by=capoturno.id, + active_station_id=None, + ) + session.add(run) + await session.flush() + session.add(ProductionEvent(run_id=run.id, event_type="start", user_id=operatore.id, + created_at=inizio)) + for numero, minuti in enumerate(slot, start=1): + contatore += 1 + seriale = f"{prefisso_seriale}{contatore:05d}" + istante = _ora_utc(giorno, minuti) + for marker in sorted(sottotask): + st = sottotask[marker] + _t, quota, scost, sigma, deriva = next(p for p in profilo if p[1]["marker_number"] == marker) + valore = _valore(rng, quota, scost, sigma, deriva, frazione) + esito, scarto = calculate_pass_fail(valore, st) + m = Measurement( + subtask_id=st.id, version_id=versione.id, measured_by=operatore.id, + value=valore, pass_fail=esito, deviation=scarto, lot_number=lotto, + serial_number=seriale, + input_method="usb_caliper" if rng.random() < 0.75 else "manual", + input_duration_ms=rng.randint(2500, 9000), + production_run_id=run.id, + # un fuori tolleranza passa solo con l'ok del capoturno (come in produzione) + supervisor_id=capoturno.id if esito == "fail" else None, + authorised_at=istante + timedelta(minutes=1) if esito == "fail" else None, + measured_at=istante + timedelta(seconds=20 * marker), + ) + session.add(m) + totale += 1 + # un task_measured per ogni task di misura non finale, poi il ciclo completato + for tid in ordine_task: + tipo = "cycle_completed" if tid == ultimo_task else "task_measured" + session.add(ProductionEvent( + run_id=run.id, event_type=tipo, user_id=operatore.id, task_id=tid, + created_at=istante + timedelta(minutes=1), + )) + session.add(ProductionEvent(run_id=run.id, event_type="close", user_id=capoturno.id, + supervisor_id=capoturno.id, created_at=fine_run)) + await session.flush() + return contatore, totale + + +async def _ricetta_dxf(session, owner, stazione): + """Demo visione: un task dxf_compare, senza grafo (si crea in fiera dal pannello DXF).""" + ricetta, versione = await _nuova_ricetta( + session, owner, stazione, code="FIERA-DXF", name="Profilo DXF (visione)", + description="Confronto di un profilo con il disegno DXF tramite la camera. " + "La ricetta di visione si crea in fiera dal pannello «Crea ricetta da DXF».", + interval=None, requires_serial=False, note_versione="Ricetta dimostrativa per la fiera", + ) + await _task(session, versione, 0, "Preparazione del pezzo", "note", + direttiva="Prima di misurare", + descrizione="Appoggiare il pezzo sul piano della camera, **in piano**.") + await _task( + session, versione, 1, "Confronto profilo con DXF", "dxf_compare", + direttiva="Acquisire e confrontare", + descrizione="Lo scostamento massimo del profilo rispetto al DXF deve restare entro la tolleranza.", + quote=[_quota(1, "Scostamento massimo profilo", 0.0, 0.15)], + ) + return ricetta + + +async def seed(replace: bool, dry_run: bool, fine: date) -> int: + """Esegue il seed in una sola transazione; con dry_run annulla tutto alla fine.""" + rng = random.Random(SEED) + async with async_session_factory() as session: + utenti = await _upsert_utenti(session) + owner = utenti["adriano"] + stazione = await _upsert_stazione(session, owner) + + esistenti = list((await session.execute( + select(Recipe).where(Recipe.code.in_(CODES)) + )).scalars().all()) + if esistenti and not replace: + print(f"Ricette FIERA gia' presenti ({', '.join(r.code for r in esistenti)}): " + "non duplico. Usa --replace per rifarle.") + else: + if esistenti: + conti = await _conta_e_cancella(session, esistenti) + print(f"--replace: rimosse {', '.join(r.code for r in esistenti)} " + f"({conti['misure']} misure, {conti['produzioni']} produzioni, " + f"{conti['eventi']} eventi)") + + r1, v1, sub1 = await _ricetta_meccanica( + session, owner, stazione, code="FIERA-FLANGIA", name="Flangia Ø80", + description="Flangia tornita in alluminio: diametri, spessore, interasse fori " + "e profondita' della lamatura. Lotto e seriale obbligatori.", + interval=30, requires_serial=True, profilo=FLANGIA, + titoli_task=["Misure diametrali", "Spessore e posizione fori"], + ) + n1, m1 = await _storico(session, rng, fine, owner, stazione, utenti, r1, v1, sub1, + FLANGIA, (10, 14), "LF", "FL") + r2, v2, sub2 = await _ricetta_meccanica( + session, owner, stazione, code="FIERA-ALBERO", name="Albero rettificato", + description="Albero in acciaio rettificato: sedi dei cuscinetti, lunghezza e " + "rugosita'. Tolleranze strette.", + interval=30, requires_serial=False, profilo=ALBERO, + titoli_task=["Diametri rettificati", "Lunghezza e rugosita'"], + ) + n2, m2 = await _storico(session, rng, fine, owner, stazione, utenti, r2, v2, sub2, + ALBERO, (8, 11), "LA", "AL") + await _ricetta_dxf(session, owner, stazione) + print(f"FIERA-FLANGIA: {n1} pezzi, {m1} misure; FIERA-ALBERO: {n2} pezzi, {m2} misure") + + if dry_run: + await session.rollback() + print("--dry-run: nessuna modifica salvata.") + else: + await session.commit() + print(f"Seed fiera completato (stazione {stazione.code}, utenti: " + f"{', '.join(u[0] for u in UTENTI)}).") + return 0 + + +def _factory_per_file(db: Path): + """Sessioni async su un file SQLite qualsiasi, con le stesse PRAGMA dell'app (FK attive).""" + engine = create_async_engine(f"sqlite+aiosqlite:///{db.resolve().as_posix()}", echo=False) + + @event.listens_for(engine.sync_engine, "connect") + def _pragma(dbapi_connection, _record) -> None: + cur = dbapi_connection.cursor() + cur.execute("PRAGMA foreign_keys=ON") + cur.execute("PRAGMA busy_timeout=5000") + cur.close() + + return engine, async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def _main_async(args) -> int: + global async_session_factory + engine, async_session_factory = _factory_per_file(args.db) + try: + fine = date.fromisoformat(args.fine) if args.fine else date.today() - timedelta(days=1) + return await seed(args.replace, args.dry_run, fine) + finally: + await engine.dispose() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--db", required=True, type=Path, help="file SQLite con lo schema gia' creato") + parser.add_argument("--replace", action="store_true", + help="rimuove i dati FIERA-* (ricette, misure, produzioni) e li rifa'") + parser.add_argument("--dry-run", action="store_true", help="esegue tutto e annulla") + parser.add_argument("--fine", default=None, help="ultimo giorno dello storico AAAA-MM-GG (default: ieri)") + args = parser.parse_args() + if not args.db.is_file(): + print(f"File non trovato: {args.db}") + return 2 + return asyncio.run(_main_async(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/backend/tests/test_seed_fiera.py b/src/backend/tests/test_seed_fiera.py new file mode 100644 index 0000000..13f5bed --- /dev/null +++ b/src/backend/tests/test_seed_fiera.py @@ -0,0 +1,194 @@ +"""Test di scripts/seed_fiera.py (issue #85): il seed del PC della fiera. + +Lo script non e' un modulo del pacchetto: si carica da file con importlib, come +test_seed_collaudo. La parte "dato" gira sul DB in memoria dei test; l'ultimo test +percorre invece la strada vera (file SQLite con `alembic upgrade head` + --db). +""" +import importlib.util +import sqlite3 +from argparse import Namespace +from datetime import date +from pathlib import Path + +import pytest +from alembic import command +from alembic.config import Config +from sqlalchemy import func, select + +from src.backend.config import Settings, settings +from src.backend.models.orm.measurement import Measurement +from src.backend.models.orm.recipe import Recipe +from src.backend.models.orm.station import Station, StationRecipeAssignment +from src.backend.models.orm.user import User +from src.backend.services.auth_service import authenticate_user +from src.backend.tests.conftest import TestSessionFactory, test_engine + +_SCRIPT_PATH = Path(__file__).resolve().parents[3] / "scripts" / "seed_fiera.py" +_ALEMBIC_INI = Path(__file__).resolve().parents[1] / "migrations" / "alembic.ini" +FINE = date(2026, 10, 22) # data fissa: il test non dipende dal giorno in cui gira + + +@pytest.fixture +def seed_fiera(monkeypatch): + spec = importlib.util.spec_from_file_location("seed_fiera", _SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + monkeypatch.setattr(module, "async_session_factory", TestSessionFactory) + return module + + +async def _utente(db_session, username) -> User: + return (await db_session.execute( + select(User).where(User.username == username) + )).scalar_one() + + +class TestSeedFiera: + async def test_utenti_ruoli_e_login(self, seed_fiera, db_session): + assert await seed_fiera.seed(False, False, FINE) == 0 + + attesi = { + "adriano": ("!Adriano77", True), + "maker": ("Fiera2026!", False), + "capoturno": ("Fiera2026!", False), + "operatore": ("Fiera2026!", False), + } + for username, (password, admin) in attesi.items(): + utente = await authenticate_user(db_session, username, password) + assert utente is not None, username + assert utente.is_admin is admin + assert (await _utente(db_session, "maker")).roles == ["Maker"] + assert "Supervisor" in (await _utente(db_session, "capoturno")).roles + assert (await _utente(db_session, "operatore")).roles == ["MeasurementTec"] + assert {"Maker", "Metrologist"} <= set((await _utente(db_session, "adriano")).roles) + + async def test_setup_risulta_completato(self, seed_fiera, client, monkeypatch): + import src.backend.api.routers.setup as setup_mod + monkeypatch.setattr(settings, "setup_password", "test-setup-password") + monkeypatch.setattr(setup_mod, "engine", test_engine) + monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory) + + # prima del seed il frontend vede il setup da fare, dopo no + assert (await client.get("/api/setup/status")).json() == {"initialised": False} + await seed_fiera.seed(False, False, FINE) + assert (await client.get("/api/setup/status")).json() == {"initialised": True} + + async def test_stazione_e_ricette(self, seed_fiera, db_session): + await seed_fiera.seed(False, False, FINE) + + stazione = (await db_session.execute( + select(Station).where(Station.code == "ST-DEFAULT") + )).scalar_one() + assert stazione.camera_url == "http://127.0.0.1:8765" + assert stazione.camera_source == "camera:601020" + + assegnate = (await db_session.execute( + select(Recipe.code) + .join(StationRecipeAssignment, StationRecipeAssignment.recipe_id == Recipe.id) + .where(StationRecipeAssignment.station_id == stazione.id) + )).scalars().all() + assert sorted(assegnate) == ["FIERA-ALBERO", "FIERA-DXF", "FIERA-FLANGIA"] + + # la ricetta DXF ha un task dxf_compare e nessun grafo di visione + from src.backend.models.orm.task import RecipeTask + dxf = (await db_session.execute( + select(RecipeTask).where(RecipeTask.task_type == "dxf_compare") + )).scalars().all() + assert len(dxf) == 1 and dxf[0].vision_json is None + + async def test_misure_nel_numero_atteso(self, seed_fiera, db_session): + await seed_fiera.seed(False, False, FINE) + + # ogni pezzo ha una misura per quota: pezzi 150-300 per ricetta + for codice, quote in (("FIERA-FLANGIA", 5), ("FIERA-ALBERO", 4)): + misure = (await db_session.execute( + select(func.count(Measurement.id)) + .where(Measurement.version_id.in_( + select(seed_fiera.RecipeVersion.id) + .join(Recipe, Recipe.id == seed_fiera.RecipeVersion.recipe_id) + .where(Recipe.code == codice) + )) + )).scalar_one() + assert misure % quote == 0 + assert 150 <= misure // quote <= 300, (codice, misure // quote) + + # lun-ven soltanto, e le misure compaiono nelle produzioni + giorni = (await db_session.execute( + select(seed_fiera.ProductionRun.started_at) + )).scalars().all() + assert giorni and all(g.weekday() < 5 for g in giorni) + senza_run = (await db_session.execute( + select(func.count(Measurement.id)).where(Measurement.production_run_id.is_(None)) + )).scalar_one() + assert senza_run == 0 + + async def test_statistiche_numeriche(self, seed_fiera, client, db_session): + await seed_fiera.seed(False, False, FINE) + adriano = await _utente(db_session, "adriano") + from src.backend.services.auth_service import issue_api_key + chiave = await issue_api_key(db_session, adriano) + intestazioni = {"X-API-Key": chiave} + + flangia = (await db_session.execute( + select(Recipe).where(Recipe.code == "FIERA-FLANGIA") + )).scalar_one() + quote = (await client.get( + "/api/statistics/subtasks", headers=intestazioni, + params={"recipe_id": flangia.id}, + )).json() + assert len(quote) == 5 + cpk = [] + for quota in quote: + risposta = await client.get( + "/api/statistics/capability", headers=intestazioni, + params={"recipe_id": flangia.id, "subtask_id": quota["id"]}, + ) + assert risposta.status_code == 200 + d = risposta.json() + for campo in ("mean", "std_dev", "cp", "cpk"): + assert isinstance(d[campo], (int, float)), (quota["id"], campo) + cpk.append(d["cpk"]) + # Cpk diversi per quota: una capace, una al limite o sotto + assert max(cpk) > 1.4 + assert min(cpk) < 1.1 + + async def test_secondo_giro_non_duplica_e_replace_rifa(self, seed_fiera, db_session): + async def conta(): + return [ + (await db_session.execute(select(func.count(t.id)))).scalar_one() + for t in (Recipe, Measurement, User) + ] + + await seed_fiera.seed(False, False, FINE) + primo = await conta() + await seed_fiera.seed(False, False, FINE) + assert await conta() == primo # utenti aggiornati, ricette e misure non duplicate + + await seed_fiera.seed(True, False, FINE) + assert await conta() == primo # stessi numeri: il seme e' fisso + + async def test_dry_run_non_salva(self, seed_fiera, db_session): + await seed_fiera.seed(False, True, FINE) + assert (await db_session.execute(select(func.count(User.id)))).scalar_one() == 0 + assert (await db_session.execute(select(func.count(Measurement.id)))).scalar_one() == 0 + + +async def test_file_sqlite_con_alembic(seed_fiera, tmp_path, monkeypatch): + """La strada vera: schema da `alembic upgrade head`, poi il seed con --db.""" + db = tmp_path / "tmf.db" + url = f"sqlite+aiosqlite:///{db.as_posix()}" + monkeypatch.setattr(Settings, "database_url", property(lambda self: url)) + # alembic fa asyncio.run(): fuori dal loop di pytest-asyncio, in un thread + import asyncio + await asyncio.to_thread(command.upgrade, Config(str(_ALEMBIC_INI)), "head") + + argomenti = Namespace(db=db, replace=False, dry_run=False, fine=FINE.isoformat()) + assert await seed_fiera._main_async(argomenti) == 0 + + con = sqlite3.connect(db) + try: + assert con.execute("SELECT COUNT(*) FROM users WHERE is_admin=1").fetchone()[0] == 1 + assert con.execute("SELECT COUNT(*) FROM recipes").fetchone()[0] == 3 + assert con.execute("SELECT COUNT(*) FROM measurements").fetchone()[0] > 1000 + finally: + con.close() From c554fcfda7b01df921ea005e7721ca44c00be16f Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sat, 24 Oct 2026 00:55:20 +0200 Subject: [PATCH 2/2] seed fiera: commento sulle password corretto (#85) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB --- scripts/seed_fiera.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/seed_fiera.py b/scripts/seed_fiera.py index 3a06121..439ff3d 100644 --- a/scripts/seed_fiera.py +++ b/scripts/seed_fiera.py @@ -74,7 +74,7 @@ SEED = 20261026 # seme fisso: dati ripetibili FUSO = ZoneInfo("Europe/Rome") # le misure sono salvate in UTC naive, l'orario di turno e' locale GIORNI_STORICO = 28 # circa 4 settimane -# (username, password, nome, ruoli, admin). Password decise da Adriano per la fiera. +# (username, password, nome, ruoli, admin). adriano: password del PC fiera; le altre scelte per la demo (#85). UTENTI = [ ("adriano", "!Adriano77", "Adriano Dal Pastro", ["Maker", "MeasurementTec", "Metrologist", "Supervisor"], True),