fix(scripts): guardie di seed_collaudo.py per produzione e cancellazioni (#17)

--supervisor-password non ha piu' un default committato: se il
capoturno va creato e l'opzione manca, ne genera una casuale
(secrets.token_urlsafe(12)) e la stampa una sola volta.

--conferma-produzione richiesto quando APP_ENV (src.backend.config,
stessa regola di #33) non e' dev/test.

--replace elenca quante Measurement/ProductionRun/ProductionEvent
cancellerebbe e si ferma senza cancellare finche' non si aggiunge --si.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:45:14 +02:00
parent 54bc384650
commit 8864b2021b
2 changed files with 280 additions and 11 deletions
+90 -11
View File
@@ -21,16 +21,28 @@ are already in place:
Re-running finds the recipes already there and stops. --replace deletes them
first, along with anything measured against them - which is what you want between
one acceptance session and the next, and never what you want by accident.
one acceptance session and the next, and never what you want by accident: it
prints how many Measurement/ProductionRun/ProductionEvent rows that is and
refuses to touch them without --si.
Refuses to run against a real deployment (APP_ENV outside dev/test, same rule
as src.backend.config) unless --conferma-produzione is also given: this script
deletes data, and a shop floor is not where to find that out by mistake.
If --supervisor-password is not given and the capoturno account does not exist
yet, a random one is generated and printed once - never committed, never a
default anyone could look up.
"""
import argparse
import asyncio
import secrets
import sys
from sqlalchemy import delete, select
from sqlalchemy import delete, func, select
sys.path.insert(0, "/app")
from src.backend.config import settings # noqa: E402
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
@@ -86,6 +98,43 @@ async def _find_recipes(session, codes):
return list(result.scalars().all())
async def _count_replace_impact(session, recipes) -> dict:
"""How many Measurement/ProductionRun/ProductionEvent --replace would delete.
Read-only mirror of what _delete_recipes actually removes, so the count
printed before the confirmation matches the count deleted after it.
"""
version_ids = []
run_ids = []
for recipe in recipes:
version_ids += (await session.execute(
select(RecipeVersion.id).where(RecipeVersion.recipe_id == recipe.id)
)).scalars().all()
run_ids += (await session.execute(
select(ProductionRun.id).where(ProductionRun.recipe_id == recipe.id)
)).scalars().all()
measurements = 0
if version_ids:
measurements = (await session.execute(
select(func.count()).select_from(Measurement)
.where(Measurement.version_id.in_(version_ids))
)).scalar_one()
events = 0
if run_ids:
events = (await session.execute(
select(func.count()).select_from(ProductionEvent)
.where(ProductionEvent.run_id.in_(run_ids))
)).scalar_one()
return {
"measurements": measurements,
"production_runs": len(run_ids),
"production_events": events,
}
async def _delete_recipes(session, recipes):
"""Remove the collaudo recipes and everything measured against them."""
for recipe in recipes:
@@ -176,25 +225,33 @@ async def _ensure_supervisor(session, username, password):
An admin is accepted by the authorisation check, but the acceptance session
should exercise the role the shop floor will actually use.
`password` may be None: it is only needed when the account does not exist
yet, in which case a random one is generated here rather than falling
back to a committed default. Returns (user, created, password_used) so
the caller can print the generated one - the only place it appears.
"""
existing = (await session.execute(
select(User).where(User.username == username)
)).scalar_one_or_none()
if existing is not None:
return existing, False
return existing, False, password
password_used = password or secrets.token_urlsafe(12)
user = await auth_service.create_user(
session,
username=username,
password=password,
password=password_used,
display_name="Capo Turno (collaudo)",
roles=["Supervisor", "MeasurementTec"],
)
await session.flush()
return user, True
return user, True, password_used
async def seed(station_code: str, replace: bool, supervisor_password: str) -> int:
async def seed(
station_code: str, replace: bool, supervisor_password: str | None, si: bool,
) -> int:
async with async_session_factory() as session:
owner = (await session.execute(
select(User).where(User.is_admin == True) # noqa: E712
@@ -217,6 +274,14 @@ async def seed(station_code: str, replace: bool, supervisor_password: str) -> in
print("Usa --replace per rifarle da zero (cancella anche le misure).")
return 1
if existing:
impact = await _count_replace_impact(session, existing)
print(f"--replace cancellera' ({', '.join(r.code for r in existing)}):")
print(f" {impact['measurements']} misure")
print(f" {impact['production_runs']} produzioni")
print(f" {impact['production_events']} eventi di produzione")
if not si:
print("Nessuna cancellazione eseguita. Aggiungi --si per confermare.")
return 1
await _delete_recipes(session, existing)
print(f"Rimosse: {', '.join(r.code for r in existing)}")
@@ -286,7 +351,7 @@ async def seed(station_code: str, replace: bool, supervisor_password: str) -> in
)
await _assign(session, station, recipe_b, owner.id)
supervisor, created = await _ensure_supervisor(
supervisor, created, password_used = await _ensure_supervisor(
session, "capoturno", supervisor_password,
)
@@ -296,7 +361,7 @@ async def seed(station_code: str, replace: bool, supervisor_password: str) -> in
print(" COLLAUDO-A ciclo di misura, lotto obbligatorio, intervallo 2 min")
print(" COLLAUDO-B solo calibro, lotto e seriale obbligatori")
if created:
print(f"Capoturno creato: {supervisor.username} / {supervisor_password}")
print(f"Capoturno creato: {supervisor.username} / {password_used}")
print(" cambiare la password prima di qualunque uso reale.")
else:
print(f"Capoturno gia' presente: {supervisor.username} (password invariata)")
@@ -309,11 +374,25 @@ def main() -> int:
help="codice della stazione a cui assegnare le ricette")
parser.add_argument("--replace", action="store_true",
help="cancella le ricette di collaudo esistenti e le misure fatte su di esse")
parser.add_argument("--supervisor-password", default="Collaudo2026!",
help="password del capoturno, se va creato")
parser.add_argument("--si", action="store_true",
help="conferma la cancellazione di --replace (senza, si limita a mostrarla)")
parser.add_argument("--supervisor-password", default=None,
help="password del capoturno, se va creato; "
"se omessa ne viene generata una casuale e stampata una volta")
parser.add_argument("--conferma-produzione", action="store_true",
help="richiesto per lanciare lo script con APP_ENV di produzione")
args = parser.parse_args()
if settings.app_env not in ("dev", "test") and not args.conferma_produzione:
print(
f"APP_ENV={settings.app_env!r} indica una produzione: "
"questo script scrive ricette e utenti di collaudo. "
"Rilancia con --conferma-produzione se e' davvero quello che vuoi."
)
return 2
return asyncio.run(
seed(args.station, args.replace, args.supervisor_password)
seed(args.station, args.replace, args.supervisor_password, args.si)
)