Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad6ce5d11d |
@@ -1,458 +0,0 @@
|
||||
"""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 "123" per tutti, chiesta da Adriano per la fiera (#85); il login non controlla la lunghezza.
|
||||
UTENTI = [
|
||||
("adriano", "123", "Adriano Dal Pastro",
|
||||
["Maker", "MeasurementTec", "Metrologist", "Supervisor"], True),
|
||||
("maker", "123", "Marco Ferrari (Maker)", ["Maker"], False),
|
||||
("capoturno", "123", "Luca Bianchi (Capoturno)",
|
||||
["Supervisor", "MeasurementTec"], False),
|
||||
("operatore", "123", "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())
|
||||
@@ -4,8 +4,11 @@ It does not know, and must not know, whether the server or the station computed
|
||||
the numbers: that is what lets a station's configuration change without touching
|
||||
the frontend or the recipes.
|
||||
"""
|
||||
import base64
|
||||
import binascii
|
||||
import io
|
||||
import json
|
||||
from typing import Literal
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
@@ -26,7 +29,7 @@ from src.backend.models.api.vision import (
|
||||
from src.backend.models.orm.vision import VisionResult
|
||||
from src.backend.models.orm.task import RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services import vision_service
|
||||
from src.backend.services import station_service, vision_service
|
||||
|
||||
router = APIRouter(prefix="/api/vision", tags=["vision"])
|
||||
|
||||
@@ -185,6 +188,30 @@ def _422(errors: list[str]) -> HTTPException:
|
||||
)
|
||||
|
||||
|
||||
async def _store_validate_assign(
|
||||
task: RecipeTask, graph: dict, placed: dict[str, bytes],
|
||||
) -> tuple[str, dict]:
|
||||
"""Scrive la cartella ricetta, la fa validare dal worker, la assegna al task.
|
||||
|
||||
Condiviso da upload_recipe e dxf-compare con save (#83): stesso
|
||||
comportamento, un solo posto. Non committa. Ritorna (recipe_dir, check).
|
||||
"""
|
||||
# The folder is content-addressed and immutable; writing it before the
|
||||
# validation is harmless (an orphan folder is never referenced) and the
|
||||
# worker needs it on disk to validate meta files.
|
||||
try:
|
||||
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
|
||||
except ValueError as exc:
|
||||
raise _422([str(exc)])
|
||||
check = await vision_service.validate_recipe(graph, recipe_dir)
|
||||
if not check["ok"]:
|
||||
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
|
||||
|
||||
task.vision_json = graph
|
||||
task.vision_recipe_dir = recipe_dir
|
||||
return recipe_dir, check
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
|
||||
async def upload_recipe(
|
||||
task_id: int,
|
||||
@@ -218,19 +245,7 @@ async def upload_recipe(
|
||||
if errors:
|
||||
raise _422(errors)
|
||||
|
||||
# The folder is content-addressed and immutable; writing it before the
|
||||
# validation is harmless (an orphan folder is never referenced) and the
|
||||
# worker needs it on disk to validate meta files.
|
||||
try:
|
||||
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
|
||||
except ValueError as exc:
|
||||
raise _422([str(exc)])
|
||||
check = await vision_service.validate_recipe(graph, recipe_dir)
|
||||
if not check["ok"]:
|
||||
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
|
||||
|
||||
task.vision_json = graph
|
||||
task.vision_recipe_dir = recipe_dir
|
||||
recipe_dir, check = await _store_validate_assign(task, graph, placed)
|
||||
await db.commit()
|
||||
return VisionRecipeUploadResponse(
|
||||
task_id=task.id,
|
||||
@@ -325,3 +340,77 @@ async def preview(
|
||||
) -> dict:
|
||||
task = await _task_or_404(db, task_id)
|
||||
return await vision_service.preview(db, task, await _read_image(image))
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/recipe/dxf-compare")
|
||||
async def create_dxf_compare_recipe(
|
||||
task_id: int,
|
||||
image: UploadFile = File(...),
|
||||
dxf: UploadFile = File(...),
|
||||
station_id: int | None = Form(None, gt=0),
|
||||
px_per_mm: float | None = Form(None, gt=0),
|
||||
band_mm: float = Form(0.1, gt=0),
|
||||
min_points_pct: float = Form(95, ge=0, le=100),
|
||||
polarity: Literal["dark", "light"] = Form("dark"),
|
||||
save: bool = Form(False),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_maker),
|
||||
) -> dict:
|
||||
"""Ricetta DXF compare da un'immagine e un DXF, senza Studio (#83).
|
||||
|
||||
La scala e' `px_per_mm`, o quella della stazione (letta dall'acquisitore).
|
||||
Con save=false si vede il risultato e non si scrive nulla; con save=true la
|
||||
ricetta va sul task come con l'upload, e se il task non ha nessuna quota di
|
||||
visione nasce la quota «Punti in tolleranza».
|
||||
"""
|
||||
task = await _task_or_404(db, task_id)
|
||||
if task.task_type not in vision_service.VISION_TASK_TYPES:
|
||||
raise _422([f"task type {task.task_type!r} does not run a vision recipe"])
|
||||
|
||||
station = None
|
||||
if station_id is not None:
|
||||
station = await station_service.get_station(db, station_id) # 404 se non esiste
|
||||
if px_per_mm is None:
|
||||
calibration, reason = await vision_service.fetch_station_calibration(station)
|
||||
if calibration is None:
|
||||
# irraggiungibile e non calibrata sono due guasti diversi per chi lo legge
|
||||
raise _422([
|
||||
"acquisitore della stazione non raggiungibile"
|
||||
if reason == vision_service.CAL_UNREACHABLE else "stazione non calibrata"
|
||||
])
|
||||
px_per_mm = float(calibration["px_per_mm"])
|
||||
|
||||
image_bytes = await _read_image(image)
|
||||
dxf_bytes = await dxf.read()
|
||||
if not validate_file_size(len(dxf_bytes)):
|
||||
raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"])
|
||||
|
||||
body = await vision_service.dxf_compare_recipe(
|
||||
image_bytes, dxf_bytes,
|
||||
px_per_mm=px_per_mm, band_mm=band_mm, min_points_pct=min_points_pct,
|
||||
polarity=polarity,
|
||||
worker_url=(station.vision_worker_url or None) if station is not None else None,
|
||||
)
|
||||
# i file stanno nella ricetta salvata, non nella risposta al client
|
||||
files = body.pop("files")
|
||||
response = {"result": body, "saved": False, "recipe_dir": None, "outputs": None}
|
||||
if not save:
|
||||
return response
|
||||
|
||||
uploads: list[tuple[str, bytes]] = []
|
||||
for name, encoded in files.items():
|
||||
try:
|
||||
uploads.append((name, base64.b64decode(encoded, validate=True)))
|
||||
except (binascii.Error, ValueError, TypeError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker's answer was malformed: file {name!r} is not base64",
|
||||
)
|
||||
placed, errors = vision_service.match_recipe_files(body["graph"], uploads)
|
||||
if errors:
|
||||
raise _422(errors)
|
||||
recipe_dir, check = await _store_validate_assign(task, body["graph"], placed)
|
||||
await vision_service.ensure_points_quote(db, task, min_points_pct)
|
||||
await db.commit()
|
||||
response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"])
|
||||
return response
|
||||
|
||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
@@ -24,6 +25,8 @@ from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||
from src.backend.services import measurement_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Worker responses whose status and reason are forwarded as-is rather than
|
||||
# flattened into a generic 502 - see `_call_worker`.
|
||||
_FORWARDED_WORKER_STATUSES = frozenset({
|
||||
@@ -140,6 +143,136 @@ async def _call_worker(
|
||||
return report
|
||||
|
||||
|
||||
async def call_worker_multipart(
|
||||
path: str,
|
||||
*,
|
||||
files: dict,
|
||||
data: dict,
|
||||
worker_url: str | None = None,
|
||||
timeout: float = 120.0,
|
||||
) -> dict:
|
||||
"""POST multipart generico al worker (#83): /recipes/dxf-compare.
|
||||
|
||||
Stessa traduzione degli errori di `_call_worker`: 504 sul timeout, 502 sul
|
||||
trasporto o su una risposta non JSON-oggetto; 422/503/504 del worker si
|
||||
inoltrano cosi' come sono. Worker della stazione o, se manca, il centrale.
|
||||
"""
|
||||
base = (worker_url or settings.vision_worker_url).rstrip("/")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(f"{base}{path}", files=files, data=data)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
detail=f"the vision worker did not answer in time: {exc} ({base})",
|
||||
) from exc
|
||||
except (httpx.TransportError, httpx.HTTPError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker is unreachable: {exc} ({base})",
|
||||
) from exc
|
||||
|
||||
if response.status_code in _FORWARDED_WORKER_STATUSES:
|
||||
try:
|
||||
detail = response.json().get("detail", response.text)
|
||||
except ValueError:
|
||||
detail = response.text
|
||||
raise HTTPException(status_code=response.status_code, detail=detail)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker refused the job: {response.text}",
|
||||
)
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker's answer was malformed: not valid JSON ({exc})",
|
||||
) from exc
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="the vision worker's answer was malformed: expected a JSON object",
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
async def dxf_compare_recipe(
|
||||
image_bytes: bytes, dxf_bytes: bytes, *, px_per_mm: float, band_mm: float,
|
||||
min_points_pct: float, polarity: str, worker_url: str | None = None,
|
||||
) -> dict:
|
||||
"""W2: ricetta dxf-compare da immagine + DXF (#83). Sostituita dai test."""
|
||||
body = await call_worker_multipart(
|
||||
"/recipes/dxf-compare",
|
||||
files={
|
||||
"image": ("frame.png", image_bytes, "image/png"),
|
||||
"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream"),
|
||||
},
|
||||
data={
|
||||
"px_per_mm": str(px_per_mm), "band_mm": str(band_mm),
|
||||
"min_points_pct": str(min_points_pct), "polarity": polarity,
|
||||
},
|
||||
worker_url=worker_url,
|
||||
)
|
||||
# forma minima del contratto W2: senza graph e files non c'e' nulla da salvare
|
||||
if not isinstance(body.get("graph"), dict) or not isinstance(body.get("files"), dict):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="the vision worker's answer was malformed: missing graph or files",
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
# Stati di fetch_station_calibration (#83)
|
||||
CAL_OK = "ok"
|
||||
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
|
||||
CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore inutilizzabile)
|
||||
CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde
|
||||
|
||||
|
||||
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
|
||||
"""Legge la calibrazione dall'acquisitore della stazione (C1, #83).
|
||||
|
||||
GET {camera_url}/camera/calibration?source={camera_source}. La calibrazione
|
||||
vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok")
|
||||
oppure (None, motivo) per distinguere «non calibrata» da «irraggiungibile».
|
||||
Un px_per_mm non finito o <= 0 conta come non calibrata.
|
||||
"""
|
||||
if station is None or not station.camera_url or not station.camera_source:
|
||||
return None, CAL_NO_CAMERA
|
||||
url = f"{station.camera_url.rstrip('/')}/camera/calibration"
|
||||
try:
|
||||
# timeout breve: e' una lettura di servizio, non deve bloccare l'execute
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(url, params={"source": station.camera_source})
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("acquisitore della stazione %s non raggiungibile: %s", station.code, exc)
|
||||
return None, CAL_UNREACHABLE
|
||||
if response.status_code == status.HTTP_404_NOT_FOUND:
|
||||
return None, CAL_NOT_CALIBRATED
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"acquisitore della stazione %s: HTTP %s", station.code, response.status_code,
|
||||
)
|
||||
return None, CAL_UNREACHABLE
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
logger.warning("acquisitore della stazione %s: risposta non JSON", station.code)
|
||||
return None, CAL_UNREACHABLE
|
||||
px_per_mm = body.get("px_per_mm") if isinstance(body, dict) else None
|
||||
if not (_is_measurable(px_per_mm) and px_per_mm > 0):
|
||||
return None, CAL_NOT_CALIBRATED
|
||||
return body, CAL_OK
|
||||
|
||||
|
||||
async def get_station_calibration(station: Station | None) -> dict | None:
|
||||
"""La calibrazione C1 della stazione, o None in ogni altro caso (#83)."""
|
||||
calibration, _ = await fetch_station_calibration(station)
|
||||
return calibration
|
||||
|
||||
|
||||
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
|
||||
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
|
||||
|
||||
@@ -365,11 +498,14 @@ async def execute_task(
|
||||
)
|
||||
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
||||
station_url = None
|
||||
# Calibrazione letta dall'acquisitore della stazione (#83); None = non c'e'
|
||||
calibration = None
|
||||
if run is not None:
|
||||
station = (await db.execute(
|
||||
select(Station).where(Station.id == run.station_id)
|
||||
)).scalar_one_or_none()
|
||||
station_url = (station.vision_worker_url or None) if station is not None else None
|
||||
calibration = await get_station_calibration(station)
|
||||
|
||||
if not task.vision_json:
|
||||
raise HTTPException(
|
||||
@@ -419,11 +555,15 @@ async def execute_task(
|
||||
),
|
||||
)
|
||||
|
||||
# Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si
|
||||
# passa `calibration`, la ricetta porta la sua (#46).
|
||||
# La calibrazione viene dall'acquisitore della stazione (#83); se non c'e',
|
||||
# non si passa `calibration` e la ricetta porta la sua (#46).
|
||||
worker_kwargs: dict = {}
|
||||
if calibration is not None:
|
||||
worker_kwargs["calibration"] = {"px_per_mm": float(calibration["px_per_mm"])}
|
||||
report = await _call_worker(
|
||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||
worker_url=station_url, # #45
|
||||
**worker_kwargs,
|
||||
)
|
||||
outputs = report["outputs"]
|
||||
|
||||
@@ -457,6 +597,8 @@ async def execute_task(
|
||||
station_id=run.station_id if run is not None else None,
|
||||
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
|
||||
graph_snapshot=task.vision_json,
|
||||
# copiata come il grafo (#83): ricalibrare dopo non cambia cio' che e' stato misurato
|
||||
calibration_snapshot=calibration,
|
||||
duration_ms=report.get("duration_ms"),
|
||||
)
|
||||
db.add(result)
|
||||
@@ -601,3 +743,42 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic
|
||||
return await _call_worker(
|
||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||
)
|
||||
|
||||
|
||||
async def ensure_points_quote(
|
||||
db: AsyncSession, task: RecipeTask, min_points_pct: float,
|
||||
) -> bool:
|
||||
"""Crea la quota «Punti in tolleranza» se il task non ha nessuna quota di visione (#83).
|
||||
|
||||
Una sola volta: se esiste gia' una quota con `vision_output`, non si tocca.
|
||||
Se la versione ha gia' misure non si scrive (il copy-on-write sta nel router
|
||||
dei task, qui non si aggira): ritorna False. Ritorna True se l'ha creata.
|
||||
"""
|
||||
from src.backend.services import recipe_service # import locale: evita cicli
|
||||
|
||||
existing = (await db.execute(
|
||||
select(RecipeSubtask.id)
|
||||
.where(RecipeSubtask.task_id == task.id)
|
||||
.where(RecipeSubtask.vision_output.is_not(None))
|
||||
.limit(1)
|
||||
)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return False
|
||||
if await recipe_service.version_has_measurements(db, task.version_id):
|
||||
return False
|
||||
markers = (await db.execute(
|
||||
select(RecipeSubtask.marker_number).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().all()
|
||||
db.add(RecipeSubtask(
|
||||
task_id=task.id,
|
||||
marker_number=max(markers, default=0) + 1, # unico per task (uq_task_marker)
|
||||
description="Punti in tolleranza",
|
||||
nominal=100,
|
||||
# limiti assoluti come nel resto delle quote: passa se punti% >= min_points_pct
|
||||
ltl=min_points_pct,
|
||||
utl=100,
|
||||
unit="%",
|
||||
vision_output="dxf.points_in_pct",
|
||||
))
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
"""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": ("123", True),
|
||||
"maker": ("123", False),
|
||||
"capoturno": ("123", False),
|
||||
"operatore": ("123", 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()
|
||||
@@ -0,0 +1,338 @@
|
||||
"""#83: ricetta DXF compare da camera e calibrazione letta dall'acquisitore.
|
||||
|
||||
Il worker (W2) e l'acquisitore (C1) non girano nei test: si fingono, come
|
||||
fanno gli altri test di visione. La calibrazione vive nell'acquisitore esterno,
|
||||
TMF la legge via GET {camera_url}/camera/calibration.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.task import RecipeSubtask
|
||||
from src.backend.models.orm.vision import VisionResult
|
||||
from src.backend.services import vision_service
|
||||
from src.backend.tests.conftest import auth_headers
|
||||
from src.backend.tests.test_vision_recipe_upload import an_image, make_task
|
||||
from src.backend.tests.test_vision_station_worker import ( # noqa: F401 (fixture worker_calls)
|
||||
_camera_task, _execute, _station_with_run, worker_calls,
|
||||
)
|
||||
|
||||
CAMERA_URL = "http://10.0.0.9:8200"
|
||||
CAL = {
|
||||
"source": "camera:A1", "px_per_mm": 12.5, "mm_per_px": 0.08, "rms_px": 0.2,
|
||||
"n_corners": 40, "cols": 8, "rows": 5, "square_mm": 5.0, "image_width": 640,
|
||||
"image_height": 480, "scale_spread_pct": 0.1, "calibrated_at": "2026-10-23T10:00:00",
|
||||
}
|
||||
GRAPH = {
|
||||
"schema_version": 2, "name": "dxf-compare", "tools": [],
|
||||
"meta": {"dxf": {"disegno": {"path": "disegno.dxf"}}, "calibration": {"path": "calibrazione.json"}},
|
||||
}
|
||||
OUTPUTS = [{"key": "dxf.points_in_pct", "quantity": "ratio", "unit": "%"}]
|
||||
|
||||
|
||||
def _b64(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def redirect_uploads(monkeypatch, tmp_path):
|
||||
# upload_path = parents[2] / upload_dir: un assoluto sostituisce la base
|
||||
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def w2(monkeypatch):
|
||||
"""Finto worker W2: ricorda le chiamate e risponde col corpo del contratto."""
|
||||
calls: list[dict] = []
|
||||
|
||||
async def fake(image_bytes, dxf_bytes, **kwargs):
|
||||
calls.append({"dxf": dxf_bytes, **kwargs})
|
||||
return {
|
||||
"graph": GRAPH,
|
||||
"files": {
|
||||
"disegno.dxf": _b64(dxf_bytes),
|
||||
"calibrazione.json": _b64(json.dumps({"px_per_mm": kwargs["px_per_mm"]}).encode()),
|
||||
},
|
||||
"outputs": {"dxf.points_in_pct": 99.0},
|
||||
"overlay": [], "outcome": "pass",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", fake)
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def validate_ok(monkeypatch):
|
||||
async def fake(graph, recipe_dir):
|
||||
return {"ok": True, "errors": [], "outputs": OUTPUTS}
|
||||
monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acquisitore(monkeypatch):
|
||||
"""Finto C1: `state['cal']` = dict (200), None (404), 'down' (irraggiungibile)."""
|
||||
state: dict = {"cal": CAL, "urls": []}
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, code, body=None):
|
||||
self.status_code, self._body, self.text = code, body, ""
|
||||
|
||||
def json(self):
|
||||
return self._body
|
||||
|
||||
class _Client:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
async def get(self, url, params=None):
|
||||
state["urls"].append((url, params))
|
||||
if state["cal"] == "down":
|
||||
raise httpx.ConnectError("giu'")
|
||||
if state["cal"] is None:
|
||||
return _Resp(404)
|
||||
return _Resp(200, state["cal"])
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _Client)
|
||||
return state
|
||||
|
||||
|
||||
async def _station(db_session, user, *, camera=True, worker_url=None, code="ST-C"):
|
||||
station = Station(
|
||||
code=code, name="Stazione C", active=True, created_by=user.id,
|
||||
vision_worker_url=worker_url,
|
||||
camera_url=CAMERA_URL if camera else None,
|
||||
camera_source="camera:A1" if camera else None,
|
||||
)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(station)
|
||||
return station
|
||||
|
||||
|
||||
def _post(client, user, task, *, save=False, **data):
|
||||
return client.post(
|
||||
f"/api/vision/tasks/{task.id}/recipe/dxf-compare",
|
||||
headers=auth_headers(user),
|
||||
files={
|
||||
"image": ("p.png", an_image(), "image/png"),
|
||||
"dxf": ("d.dxf", b"0\nEOF", "application/octet-stream"),
|
||||
},
|
||||
data={"save": str(save).lower(), **{k: str(v) for k, v in data.items() if v is not None}},
|
||||
)
|
||||
|
||||
|
||||
# --- lettura della calibrazione dall'acquisitore (C1) -------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_calibration_ok(db_session, maker_user, acquisitore):
|
||||
station = await _station(db_session, maker_user)
|
||||
cal, reason = await vision_service.fetch_station_calibration(station)
|
||||
assert (cal, reason) == (CAL, "ok")
|
||||
assert acquisitore["urls"] == [(f"{CAMERA_URL}/camera/calibration", {"source": "camera:A1"})]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_calibration_reasons(db_session, maker_user, acquisitore):
|
||||
station = await _station(db_session, maker_user)
|
||||
acquisitore["cal"] = None
|
||||
assert await vision_service.fetch_station_calibration(station) == (None, "not_calibrated")
|
||||
acquisitore["cal"] = "down"
|
||||
assert await vision_service.fetch_station_calibration(station) == (None, "unreachable")
|
||||
assert await vision_service.get_station_calibration(station) is None
|
||||
acquisitore["cal"] = {**CAL, "px_per_mm": 0} # valore inutilizzabile = non calibrata
|
||||
assert await vision_service.fetch_station_calibration(station) == (None, "not_calibrated")
|
||||
no_camera = await _station(db_session, maker_user, camera=False, code="ST-N")
|
||||
assert await vision_service.fetch_station_calibration(no_camera) == (None, "no_camera")
|
||||
assert await vision_service.fetch_station_calibration(None) == (None, "no_camera")
|
||||
|
||||
|
||||
# --- B3: POST /api/vision/tasks/{id}/recipe/dxf-compare -----------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_compare_preview_writes_nothing(
|
||||
client, db_session, maker_user, w2, acquisitore,
|
||||
):
|
||||
task, subtask = await make_task(db_session, maker_user)
|
||||
station = await _station(db_session, maker_user, worker_url="http://10.0.0.7:8100")
|
||||
|
||||
resp = await _post(client, maker_user, task, station_id=station.id, band_mm=0.2,
|
||||
min_points_pct=90, polarity="light")
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["saved"] is False and body["recipe_dir"] is None and body["outputs"] is None
|
||||
assert "files" not in body["result"] and body["result"]["graph"] == GRAPH
|
||||
# px_per_mm dall'acquisitore, parametri e worker della stazione
|
||||
assert w2[0]["px_per_mm"] == 12.5 and w2[0]["band_mm"] == 0.2
|
||||
assert w2[0]["min_points_pct"] == 90 and w2[0]["polarity"] == "light"
|
||||
assert w2[0]["worker_url"] == "http://10.0.0.7:8100"
|
||||
await db_session.refresh(task)
|
||||
assert task.vision_json is None and task.vision_recipe_dir is None
|
||||
quotes = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id))).scalars().all()
|
||||
assert [q.id for q in quotes] == [subtask.id] # nessuna quota nuova
|
||||
assert not (settings.upload_path / "vision" / "recipes").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_compare_save_stores_recipe_and_quote_once(
|
||||
client, db_session, maker_user, w2, validate_ok,
|
||||
):
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
|
||||
resp = await _post(client, maker_user, task, save=True, px_per_mm=10, min_points_pct=95)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["saved"] is True and body["outputs"] == OUTPUTS
|
||||
assert "files" not in body["result"]
|
||||
folder = settings.upload_path / body["recipe_dir"]
|
||||
assert (folder / "disegno.dxf").read_bytes() == b"0\nEOF"
|
||||
assert json.loads((folder / "calibrazione.json").read_text()) == {"px_per_mm": 10.0}
|
||||
await db_session.refresh(task)
|
||||
assert task.vision_json == GRAPH and task.vision_recipe_dir == body["recipe_dir"]
|
||||
|
||||
quotes = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct")
|
||||
.where(RecipeSubtask.task_id == task.id))).scalars().all()
|
||||
assert len(quotes) == 1
|
||||
q = quotes[0]
|
||||
assert q.description == "Punti in tolleranza"
|
||||
assert (float(q.nominal), float(q.ltl), float(q.utl)) == (100.0, 95.0, 100.0)
|
||||
|
||||
# secondo salvataggio: la quota non si duplica
|
||||
task_id = task.id # dopo expire_all l'attributo scaduto non si rilegge in async
|
||||
again = await _post(client, maker_user, task, save=True, px_per_mm=10)
|
||||
assert again.status_code == 200
|
||||
db_session.expire_all()
|
||||
count = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.vision_output.is_not(None))
|
||||
.where(RecipeSubtask.task_id == task_id))).scalars().all()
|
||||
assert len(count) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_compare_keeps_an_existing_vision_quote(
|
||||
client, db_session, maker_user, w2, validate_ok,
|
||||
):
|
||||
task, subtask = await make_task(db_session, maker_user)
|
||||
subtask.vision_output = "t1.diameter"
|
||||
await db_session.commit()
|
||||
|
||||
resp = await _post(client, maker_user, task, save=True, px_per_mm=10)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
quotes = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id))).scalars().all()
|
||||
assert len(quotes) == 1 # niente quota in piu'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_compare_uncalibrated_station_is_422(
|
||||
client, db_session, maker_user, w2, acquisitore,
|
||||
):
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
station = await _station(db_session, maker_user)
|
||||
acquisitore["cal"] = None
|
||||
resp = await _post(client, maker_user, task, station_id=station.id)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"] == ["stazione non calibrata"]
|
||||
assert w2 == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_compare_unreachable_acquirer_is_422_with_its_own_message(
|
||||
client, db_session, maker_user, w2, acquisitore,
|
||||
):
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
station = await _station(db_session, maker_user)
|
||||
acquisitore["cal"] = "down"
|
||||
resp = await _post(client, maker_user, task, station_id=station.id)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"] == ["acquisitore della stazione non raggiungibile"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_compare_without_station_nor_scale_is_422(client, db_session, maker_user, w2):
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
resp = await _post(client, maker_user, task)
|
||||
assert resp.status_code == 422 and resp.json()["detail"] == ["stazione non calibrata"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_compare_needs_maker(client, db_session, maker_user, measurement_tec_user, w2):
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
resp = await _post(client, measurement_tec_user, task, px_per_mm=10)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_compare_worker_422_is_forwarded(
|
||||
client, db_session, maker_user, monkeypatch,
|
||||
):
|
||||
from fastapi import HTTPException
|
||||
|
||||
async def refuse(*a, **k):
|
||||
raise HTTPException(status_code=422, detail="pezzo non trovato")
|
||||
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", refuse)
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
resp = await _post(client, maker_user, task, px_per_mm=10)
|
||||
assert resp.status_code == 422 and resp.json()["detail"] == "pezzo non trovato"
|
||||
|
||||
|
||||
# --- execute: calibrazione della stazione -------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_passes_station_calibration_and_snapshots_it(
|
||||
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||
):
|
||||
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
|
||||
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||
await db_session.commit()
|
||||
|
||||
await _execute(client, measurement_tec_user, task, run)
|
||||
|
||||
assert worker_calls[0]["calibration"] == {"px_per_mm": 12.5}
|
||||
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||
assert result.calibration_snapshot == CAL
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_without_calibration_is_unchanged(
|
||||
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||
):
|
||||
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
|
||||
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||
await db_session.commit()
|
||||
acquisitore["cal"] = "down" # acquisitore giu': si misura come prima, senza calibrazione
|
||||
|
||||
await _execute(client, measurement_tec_user, task, run)
|
||||
|
||||
assert "calibration" not in worker_calls[0]
|
||||
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||
assert result.calibration_snapshot is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_station_without_camera_never_asks(
|
||||
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||
):
|
||||
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
_, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
|
||||
|
||||
await _execute(client, measurement_tec_user, task, run)
|
||||
|
||||
assert acquisitore["urls"] == [] and "calibration" not in worker_calls[0]
|
||||
Reference in New Issue
Block a user