Compare commits

..

11 Commits

Author SHA1 Message Date
Adriano 8ce7b489d6 Merge branch 'lavoro/daitarn-tmf-seedfiera' into lavoro/daitarn-tmf-dxfcal 2026-10-24 10:17:47 +02:00
Adriano bc90d327f1 seed fiera: password 123 per tutti, chiesta da Adriano (#85)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 10:17:45 +02:00
Adriano e46965cb51 Merge branch 'lavoro/daitarn-tmf-seedfiera' into lavoro/daitarn-tmf-dxfcal 2026-10-24 00:55:21 +02:00
Adriano c554fcfda7 seed fiera: commento sulle password corretto (#85)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 00:55:20 +02:00
Adriano 08e46e335b Merge branch 'lavoro/daitarn-tmf-calibdensa' into lavoro/daitarn-tmf-dxfcal 2026-10-24 00:49:52 +02:00
Adriano 13dbc8f13b Merge branch 'lavoro/daitarn-tmf-dxfcal-frontend' into lavoro/daitarn-tmf-dxfcal 2026-10-23 23:52:00 +02:00
Adriano 8925968f34 Merge branch 'lavoro/daitarn-tmf-dxfcal-backend' into lavoro/daitarn-tmf-dxfcal 2026-10-23 23:52:00 +02:00
Adriano 2959b79fa2 Merge branch 'lavoro/daitarn-tmf-dxfcal-worker' into lavoro/daitarn-tmf-dxfcal 2026-10-23 23:52:00 +02:00
Adriano 990b5d0db8 Seed fiera: utenti, stazione, ricette e storico misure (#85)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-23 23:51:00 +02:00
Adriano 1bfcfaa5f2 Ricetta DXF da camera: pannello editor, stato calibrazione stazioni, proxy camera (#83)
Rotte maker: elenco stazioni con camera, grab per stazione, stato calibrazione
dall'acquisitore (C1), inoltro a B3. Pagina stazioni: stato e link «Calibra».
Test con api_client e requests finti; traduzioni IT/EN.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:51:11 +02:00
Adriano ad6ce5d11d Backend: ricetta DXF compare da camera e calibrazione letta dall'acquisitore (#83)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:44:15 +02:00
13 changed files with 2207 additions and 18 deletions
+458
View File
@@ -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 "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())
+103 -14
View File
@@ -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
+183 -2
View File
@@ -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
+194
View File
@@ -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": ("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]
+170 -1
View File
@@ -4,7 +4,9 @@ import math
import requests as http_requests
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
from functools import wraps
from flask import Blueprint, Response, abort, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _
from blueprints.auth import login_required, role_required
@@ -483,6 +485,173 @@ def api_upload_vision_recipe(task_id: int):
return jsonify(resp), 200
# ============================================================================
# RICETTA DXF DA CAMERA (#83): stazioni, camera, calibrazione, creazione ricetta
# ============================================================================
def maker_or_admin_required(f):
"""Maker dell'editor oppure admin della pagina stazioni (#83: stesse rotte camera)."""
@wraps(f)
def decorated(*args, **kwargs):
user = session.get("user", {})
if not (user.get("is_admin") or "Maker" in user.get("roles", [])):
abort(403)
return f(*args, **kwargs)
return decorated
def _stations_for_maker() -> list[dict]:
"""Stazioni visibili al chiamante.
GET /api/stations e' solo admin: un Maker non admin ripiega sulla stazione
corrente (by-code, aperta a tutti), cosi' l'editor funziona comunque.
"""
resp = api_client.get("/api/stations")
if isinstance(resp, list):
return resp
if Config.STATION_CODE:
one = api_client.get_station_by_code(Config.STATION_CODE)
if isinstance(one, dict) and not one.get("error"):
return [one]
return []
def _camera_of_station(station_id: int) -> tuple[str, str] | None:
"""(camera_url, camera_source) della stazione con questo id, None se senza camera."""
for st in _stations_for_maker():
if st.get("id") == station_id:
if st.get("camera_url") and st.get("camera_source"):
return str(st["camera_url"]), str(st["camera_source"])
return None
return None
@maker_bp.route("/api/stations", methods=["GET"])
@login_required
@maker_or_admin_required
def api_list_stations_for_maker():
"""Elenco stazioni con camera per il pannello «Crea ricetta da DXF»."""
out = []
for st in _stations_for_maker():
if st.get("camera_url") and st.get("camera_source"):
out.append({
"id": st.get("id"), "code": st.get("code"), "name": st.get("name"),
"camera_url": st.get("camera_url"),
})
return jsonify(out), 200
# Copia minima della logica di measure.api_vision_grab, ma per una stazione scelta per id
@maker_bp.route("/api/stations/<int:station_id>/camera/grab", methods=["GET", "POST"])
@login_required
@maker_or_admin_required
def api_station_camera_grab(station_id: int):
"""Proxy: un frame dalla camera della stazione, risposta PNG."""
camera = _camera_of_station(station_id)
if camera is None:
return jsonify({
"error": True,
"detail": _("Camera non configurata per questa stazione"),
}), 409
camera_url, camera_source = camera
try:
resp = http_requests.post(
f"{camera_url.rstrip('/')}/camera/grab",
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
timeout=15,
)
except http_requests.RequestException as exc:
return jsonify({
"error": True,
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
}), 502
if resp.status_code != 200:
detail = ""
try:
body = resp.json()
detail = body.get("detail", "") if isinstance(body, dict) else ""
except ValueError:
detail = (resp.text or "")[:200]
return jsonify({
"error": True,
"detail": _("Acquisizione non riuscita (%(status)s): %(detail)s",
status=resp.status_code, detail=detail),
}), 502
return Response(resp.content, content_type="image/png")
@maker_bp.route("/api/stations/<int:station_id>/camera/calibration", methods=["GET"])
@login_required
@maker_or_admin_required
def api_station_camera_calibration(station_id: int):
"""Proxy: calibrazione della camera letta dall'acquisitore (C1).
Risponde sempre 200 con `state`: calibrated | uncalibrated | unreachable,
cosi' la UI mostra lo stato senza trattare «non calibrata» come errore.
409 solo se la stazione non ha camera.
"""
camera = _camera_of_station(station_id)
if camera is None:
return jsonify({
"error": True,
"detail": _("Camera non configurata per questa stazione"),
}), 409
camera_url, camera_source = camera
base = camera_url.rstrip("/")
link = f"{base}/calibrazione" # pagina di calibrazione dell'acquisitore (C4)
try:
resp = http_requests.get(
f"{base}/camera/calibration",
params={"source": camera_source},
timeout=10,
)
except http_requests.RequestException as exc:
return jsonify({"state": "unreachable", "detail": str(exc), "calibration_url": link}), 200
if resp.status_code == 404:
return jsonify({"state": "uncalibrated", "calibration_url": link}), 200
if resp.status_code != 200:
return jsonify({
"state": "unreachable", "calibration_url": link,
"detail": _("Risposta %(status)s dall'acquisitore", status=resp.status_code),
}), 200
try:
calibration = resp.json()
except ValueError:
return jsonify({"state": "unreachable", "calibration_url": link,
"detail": _("Risposta non valida dall'acquisitore")}), 200
return jsonify({"state": "calibrated", "calibration": calibration, "calibration_url": link}), 200
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/dxf-compare", methods=["POST"])
@login_required
@role_required("Maker")
def api_dxf_compare_recipe(task_id: int):
"""Proxy: crea (o prova, save=false) la ricetta DXF compare da camera (B3)."""
image = request.files.get("image")
dxf = request.files.get("dxf")
if image is None or not image.filename or dxf is None or not dxf.filename:
return jsonify({"error": True, "detail": _("Servono l'immagine e il file DXF")}), 400
# Solo i campi del contratto B3; i vuoti non si inoltrano (il backend ha i default)
data = {}
for key in ("station_id", "px_per_mm", "band_mm", "min_points_pct", "polarity", "save"):
value = request.form.get(key)
if value not in (None, ""):
data[key] = value
resp = api_client.post(
f"/api/vision/tasks/{task_id}/recipe/dxf-compare",
data=data,
files={
"image": (image.filename, image.read(), image.mimetype or "application/octet-stream"),
"dxf": (dxf.filename, dxf.read(), dxf.mimetype or "application/octet-stream"),
},
)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
@login_required
def api_get_file(file_path: str):
@@ -0,0 +1,181 @@
// «Crea ricetta da DXF» dell'editor task (#83): da un'immagine della camera e un DXF
// genera la ricetta dxf_compare (POST /maker/api/vision/tasks/<id>/recipe/dxf-compare).
// «Prova» (save=false) mostra overlay e quattro valori, «Salva ricetta» (save=true)
// la scrive e avvisa il componente visionRecipe con l'evento 'dxf-recipe-saved'.
// Uso: x-data="dxfRecipeWizard(task.id, csrfFn, msgs)". Dipende da Alpine.js e station_calibration.js.
function dxfRecipeWizard(taskId, csrf, msgs) {
// Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found)
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
const VALUE_KEYS = ['dxf.points_in_pct', 'dxf.distance_avg', 'dxf.distance_min', 'dxf.distance_max'];
return {
taskId: taskId,
msgs: msgs || {},
open: false,
stations: [],
stationId: '',
calState: '', // '' | calibrated | uncalibrated | unreachable
calLabel: '',
calUrl: '',
imageFile: null, // immagine caricata o acquisita
imageName: '',
imageSrc: '',
dxfFile: null,
bandMm: 0.1,
minPointsPct: 95,
dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce)
busy: false,
grabbing: false,
error: '',
notice: '',
result: null, // corpo B3 {result, saved, recipe_dir, outputs}
async toggle() {
this.open = !this.open;
if (this.open && !this.stations.length) await this.loadStations();
},
async loadStations() {
try {
const resp = await fetch('/maker/api/stations');
if (resp.ok) this.stations = await resp.json();
} catch (e) { console.error('dxfRecipeWizard stations', e); }
// Una sola stazione con camera: preselezionata
if (this.stations.length === 1) {
this.stationId = String(this.stations[0].id);
await this.onStation();
}
},
// Cambio stazione: rilegge lo stato di calibrazione dall'acquisitore
async onStation() {
this.calState = ''; this.calLabel = ''; this.calUrl = '';
this.result = null;
if (!this.stationId) return;
const r = await fetchStationCalibration(this.stationId);
this.calState = r.state || 'unreachable';
this.calUrl = r.calibration_url || '';
if (this.calState === 'calibrated') this.calLabel = formatCalibration(r.calibration || {});
},
get calibrated() { return this.calState === 'calibrated'; },
get canRun() {
return !!(this.stationId && this.calibrated && this.imageFile && this.dxfFile) && !this.busy;
},
_setImage(file) {
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
this.imageFile = file;
this.imageName = file.name || '';
this.imageSrc = URL.createObjectURL(file);
this.result = null;
},
onImage(ev) {
const f = ev.target.files && ev.target.files[0];
if (f) this._setImage(f);
},
onDxf(ev) { this.dxfFile = (ev.target.files && ev.target.files[0]) || null; this.result = null; },
// Frame dalla camera della stazione scelta
async grab() {
if (!this.stationId || this.grabbing) return;
this.grabbing = true;
this.error = '';
try {
const resp = await fetch(`/maker/api/stations/${this.stationId}/camera/grab`, {
method: 'POST', headers: { 'X-CSRFToken': this._csrf() },
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
this.error = body.detail || this.msgs.requestFailed || 'Error';
} else {
const blob = await resp.blob();
this._setImage(new File([blob], 'camera.png', { type: blob.type || 'image/png' }));
}
} catch (e) {
this.error = this.msgs.requestFailed || String(e);
}
this.grabbing = false;
},
_csrf() { return typeof csrf === 'function' ? csrf() : csrf; },
// Il 422 porta `detail` (stringa o lista) oppure `errors`
_errorText(body) {
if (Array.isArray(body.errors) && body.errors.length) return body.errors.map(String).join('; ');
const d = body.detail;
if (Array.isArray(d)) return d.map(x => (typeof x === 'string' ? x : (x.msg || JSON.stringify(x)))).join('; ');
if (d && typeof d === 'object') return JSON.stringify(d);
return d || this.msgs.requestFailed || 'Error';
},
async run(save) {
if (!this.canRun) return;
this.busy = true;
this.error = '';
this.notice = '';
try {
const fd = new FormData();
fd.append('image', this.imageFile, this.imageFile.name || 'capture.png');
fd.append('dxf', this.dxfFile, this.dxfFile.name);
fd.append('station_id', this.stationId);
fd.append('band_mm', String(this.bandMm));
fd.append('min_points_pct', String(this.minPointsPct));
fd.append('polarity', this.dark ? 'dark' : 'light');
fd.append('save', save ? 'true' : 'false');
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/dxf-compare`, {
method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, body: fd,
});
const body = await resp.json().catch(() => ({}));
if (!resp.ok || body.error) {
this.error = this._errorText(body);
} else {
this.result = body;
this.$nextTick(() => this.drawOverlay());
if (save && body.saved) {
this.notice = this.msgs.saved || 'Saved';
// Il componente visionRecipe ricarica cartella, file e uscite delle quote
this.$dispatch('dxf-recipe-saved', { taskId: this.taskId });
}
}
} catch (e) {
this.error = this.msgs.requestFailed || String(e);
}
this.busy = false;
},
// Le quattro uscite del confronto, nell'ordine fisso
get values() {
const out = (this.result && this.result.result && this.result.result.outputs) || {};
return VALUE_KEYS.map(k => ({ key: k, value: out[k] }));
},
fmt(v) {
return (typeof v === 'number') ? v.toLocaleString(document.documentElement.lang || 'it',
{ maximumFractionDigits: 3 }) : '-';
},
// Come vision_execute.js: canvas della dimensione dell'immagine, coordinate in pixel immagine
drawOverlay() {
const img = this.$refs.img, cv = this.$refs.overlay;
if (!img || !cv || !img.naturalWidth) return;
cv.width = img.naturalWidth;
cv.height = img.naturalHeight;
const g = cv.getContext('2d');
g.clearRect(0, 0, cv.width, cv.height);
g.lineWidth = Math.max(2, cv.width / 400);
g.lineJoin = 'round';
const shapes = (this.result && this.result.result && this.result.result.overlay) || [];
for (const item of shapes) {
const rows = item.rows || [], cols = item.cols || [];
if (!rows.length || rows.length !== cols.length) continue;
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
g.beginPath();
g.moveTo(cols[0], rows[0]);
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
g.stroke();
}
},
};
}
@@ -0,0 +1,50 @@
// Stato di calibrazione della camera di una stazione (#83).
// La calibrazione vive nell'acquisitore (camera_service): qui si legge solo lo stato
// tramite il proxy Flask e si offre il link alla sua pagina «Calibra».
// Uso: x-data="stationCalibrationCell(station, msgs)" (msgs: stringhe tradotte). Dipende da Alpine.js.
// Formatta px/mm e data nella lingua della pagina (es. «20,03 px/mm · 23/10/2026»)
function formatCalibration(cal) {
const lang = document.documentElement.lang || 'it';
let text = new Intl.NumberFormat(lang, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
.format(Number(cal.px_per_mm)) + ' px/mm';
if (cal.calibrated_at) {
const d = new Date(cal.calibrated_at);
if (!isNaN(d)) text += ' · ' + d.toLocaleDateString(lang);
}
return text;
}
// Legge lo stato dal proxy: ritorna {state, calibration, calibration_url, detail}
async function fetchStationCalibration(stationId) {
try {
const resp = await fetch(`/maker/api/stations/${stationId}/camera/calibration`);
const body = await resp.json().catch(() => ({}));
if (!resp.ok) return { state: 'unreachable', detail: body.detail || '' };
return body;
} catch (e) {
return { state: 'unreachable', detail: String(e) };
}
}
function stationCalibrationCell(station, msgs) {
return {
hasCamera: !!(station.camera_url && station.camera_source),
cameraUrl: station.camera_url || '',
state: '', // '' = in lettura | calibrated | uncalibrated | unreachable
label: '',
calibrationUrl: '',
msgs: msgs || {},
async init() {
if (!this.hasCamera) return;
const r = await fetchStationCalibration(station.id);
this.state = r.state || 'unreachable';
// Link «Calibra»: dal proxy, altrimenti costruito dall'indirizzo della camera
this.calibrationUrl = r.calibration_url || (this.cameraUrl.replace(/\/+$/, '') + '/calibrazione');
if (this.state === 'calibrated') this.label = formatCalibration(r.calibration || {});
else if (this.state === 'uncalibrated') this.label = this.msgs.uncalibrated || 'not calibrated';
else this.label = this.msgs.unreachable || 'camera service unreachable';
},
};
}
@@ -50,6 +50,7 @@
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Nome') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Postazione') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden lg:table-cell">{{ _('Ricette collegate') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Calibrazione') }}</th>
<th class="text-center px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Stato') }}</th>
<th class="text-right px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Azioni') }}</th>
</tr>
@@ -81,6 +82,25 @@
</div>
</template>
</td>
{# Calibrazione (#83): stato letto dall'acquisitore della camera, link alla sua pagina «Calibra» #}
<td class="px-4 py-3 hidden md:table-cell" @click.stop
x-data="stationCalibrationCell(station, {{ {'uncalibrated': _('non calibrata'), 'unreachable': _('acquisitore non raggiungibile')}|tojson_attr }})">
<template x-if="!hasCamera">
<span class="text-xs italic text-[var(--text-secondary)]">{{ _('Nessuna camera') }}</span>
</template>
<template x-if="hasCamera">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span :class="state === 'calibrated' ? 'text-emerald-700 dark:text-emerald-300'
: (state === 'uncalibrated' || state === 'unreachable') ? 'text-amber-700 dark:text-amber-300'
: 'text-[var(--text-secondary)]'">
<span x-show="state === 'calibrated'">{{ _('Calibrazione') }}: </span><span x-text="state ? label : '...'"></span>
</span>
<a :href="calibrationUrl" target="_blank" rel="noopener"
x-show="calibrationUrl"
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
</div>
</template>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
:class="station.active
@@ -459,6 +479,7 @@
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
<script>
function stationManagement(initialStations, initialRecipes) {
return {
@@ -550,7 +550,8 @@
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})">
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})"
@dxf-recipe-saved="init()">{# #83: dopo «Salva ricetta» da DXF si rilegge la ricetta #}
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
{{ _('Ricetta visione') }}
</p>
@@ -593,6 +594,93 @@
<ul x-show="errors.length" class="mt-3 text-xs text-red-500 list-disc pl-5">
<template x-for="(e, i) in errors" :key="i"><li x-text="e"></li></template>
</ul>
<!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== -->
<template x-if="task.task_type === 'dxf_compare'">
<div class="mt-4 pt-3 border-t border-[var(--border-color)]"
x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata')}|tojson_attr }})">
<button type="button" class="btn btn-secondary text-xs" @click="toggle()">
{{ _('Crea ricetta da DXF') }}
</button>
<div x-show="open" x-cloak class="mt-3 space-y-3">
<!-- Stazione -->
<div>
<label class="tmf-label text-xs">{{ _('Stazione') }}</label>
<select class="tmf-input text-xs" x-model="stationId" @change="onStation()">
<option value="">{{ _('Scegli la stazione') }}</option>
<template x-for="st in stations" :key="st.id">
<option :value="String(st.id)" x-text="st.code + ' - ' + st.name"></option>
</template>
</select>
<p x-show="!stations.length" class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Nessuna stazione con camera configurata') }}</p>
<p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600">
{{ _('Calibrazione') }}: <span x-text="calLabel"></span>
</p>
<p x-show="stationId && calState && !calibrated" class="mt-1 text-xs text-amber-600">
<span x-text="calState === 'uncalibrated' ? {{ _('Stazione non calibrata: calibrala prima di creare la ricetta.')|tojson_attr }} : {{ _('Acquisitore non raggiungibile: impossibile leggere la calibrazione.')|tojson_attr }}"></span>
<a :href="calUrl" target="_blank" rel="noopener" x-show="calUrl"
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
<span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span>
</p>
</div>
<!-- Immagine: camera o file -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="tmf-label text-xs">{{ _('Immagine del pezzo') }}</label>
<div class="flex flex-wrap items-center gap-2">
<button type="button" class="btn btn-secondary text-xs" @click="grab()"
:disabled="!stationId || grabbing">{{ _('Acquisisci dalla camera') }}</button>
<label class="btn btn-secondary text-xs cursor-pointer">
{{ _('Carica immagine') }}
<input type="file" accept="image/*" class="hidden" @change="onImage($event)">
</label>
</div>
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="imageName"></p>
</div>
<div>
<label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label>
<input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)">
</div>
</div>
<!-- Pochi parametri -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="tmf-label text-xs">{{ _('Banda ± mm') }}</label>
<input type="number" step="0.01" min="0.001" class="tmf-input text-xs" x-model.number="bandMm">
</div>
<div>
<label class="tmf-label text-xs">{{ _('Soglia punti in banda %%') }}</label>
<input type="number" step="1" min="1" max="100" class="tmf-input text-xs" x-model.number="minPointsPct">
</div>
</div>
<label class="flex items-center gap-2 text-xs">
<input type="checkbox" x-model="dark">
{{ _('Pezzo scuro su fondo chiaro (controluce)') }}
</label>
<!-- Azioni -->
<div class="flex justify-end gap-2">
<button type="button" class="btn btn-secondary text-xs" @click="run(false)"
:disabled="!canRun">{{ _('Prova') }}</button>
<button type="button" class="btn btn-primary text-xs" @click="run(true)"
:disabled="!canRun || !result">{{ _('Salva ricetta') }}</button>
</div>
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
<p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p>
<!-- Anteprima con overlay e quattro valori -->
<div x-show="imageSrc" class="relative">
<img x-ref="img" :src="imageSrc" alt="" class="block w-full rounded border border-[var(--border-color)]"
@load="drawOverlay()">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
</div>
<table x-show="result" class="tmf-table text-xs">
<tbody>
<template x-for="v in values" :key="v.key">
<tr><td class="font-mono" x-text="v.key"></td><td class="text-right" x-text="fmt(v.value)"></td></tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
</div>
</template>
@@ -1245,6 +1333,8 @@
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
<script src="{{ url_for('static', filename='js/dxf_recipe_wizard.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
@@ -0,0 +1,232 @@
"""gitea:Adriano/TieMeasureFlow#83 - ricetta DXF compare da camera: rotte Flask nuove.
api_client e requests sono finti: backend (B3) e acquisitore (C1) rispondono
come da contratto del piano.
"""
import io
from unittest.mock import MagicMock, patch
import requests
from tests.conftest import api_get_router
STATION = {
"id": 4, "code": "ST-4", "name": "Banco",
"camera_url": "http://acq:8765/", "camera_source": "camera:folder",
}
NO_CAMERA = {"id": 5, "code": "ST-5", "name": "Senza", "camera_url": None, "camera_source": None}
PNG = b"\x89PNG\r\n\x1a\n fake"
CAL = {
"source": "camera:folder", "px_per_mm": 20.03, "mm_per_px": 0.0499, "rms_px": 0.2,
"n_corners": 54, "cols": 9, "rows": 6, "square_mm": 5.0,
"image_width": 2448, "image_height": 2048, "scale_spread_pct": 0.3,
"calibrated_at": "2026-10-23T10:00:00",
}
def _fake(status=200, content=PNG, json_body=None):
resp = MagicMock(status_code=status, content=content, text=content.decode("latin-1"))
if json_body is None:
resp.json.side_effect = ValueError("no json")
else:
resp.json.return_value = json_body
return resp
def _stations(mock_api_client, stations=(STATION, NO_CAMERA)):
mock_api_client.get.side_effect = api_get_router({"/api/stations": list(stations)})
class TestStationList:
def test_only_stations_with_camera(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp = logged_in_client.get("/maker/api/stations")
assert resp.status_code == 200
assert [s["id"] for s in resp.get_json()] == [4]
def test_non_admin_list_falls_back_to_current_station(
self, logged_in_client, mock_api_client, monkeypatch):
# /api/stations e' solo admin: il Maker vede la stazione corrente (by-code)
monkeypatch.setattr("blueprints.maker.Config.STATION_CODE", "ST-4")
mock_api_client.get.side_effect = api_get_router(
{"/api/stations": {"error": True, "status_code": 403, "detail": "no"}})
mock_api_client.get_station_by_code.return_value = STATION
resp = logged_in_client.get("/maker/api/stations")
assert [s["id"] for s in resp.get_json()] == [4]
mock_api_client.get_station_by_code.assert_called_once_with("ST-4")
def test_requires_maker_or_admin(self, logged_in_client, mock_api_client):
with logged_in_client.session_transaction() as sess:
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"], "is_admin": False}
assert logged_in_client.get("/maker/api/stations").status_code == 403
class TestStationCameraGrab:
def test_png_pass_through(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
assert resp.status_code == 200
assert resp.mimetype == "image/png"
assert resp.data == PNG
args, kwargs = post.call_args
assert args[0] == "http://acq:8765/camera/grab" # slash finale tolto
assert kwargs["json"] == {"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000}
def test_get_also_works(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.post", return_value=_fake()):
assert logged_in_client.get("/maker/api/stations/4/camera/grab").status_code == 200
def test_station_without_camera_is_409(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.post") as post:
resp = logged_in_client.post("/maker/api/stations/5/camera/grab")
assert resp.status_code == 409
post.assert_not_called()
def test_unknown_station_is_409(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
assert logged_in_client.post("/maker/api/stations/99/camera/grab").status_code == 409
def test_service_down_is_502(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.post",
side_effect=requests.ConnectionError("refused")):
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
assert resp.status_code == 502
assert resp.get_json()["error"]
def test_service_503_is_502_with_detail(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
fake = _fake(503, b"{}", {"detail": "camera occupata"})
with patch("blueprints.maker.http_requests.post", return_value=fake):
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
assert resp.status_code == 502
assert "camera occupata" in resp.get_json()["detail"]
class TestStationCameraCalibration:
def _get(self, client, fake=None, exc=None):
with patch("blueprints.maker.http_requests.get", return_value=fake,
side_effect=exc) as get:
resp = client.get("/maker/api/stations/4/camera/calibration")
return resp, get
def test_calibrated(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp, get = self._get(logged_in_client, _fake(200, b"{}", CAL))
body = resp.get_json()
assert resp.status_code == 200
assert body["state"] == "calibrated"
assert body["calibration"]["px_per_mm"] == 20.03
assert body["calibration_url"] == "http://acq:8765/calibrazione"
args, kwargs = get.call_args
assert args[0] == "http://acq:8765/camera/calibration"
assert kwargs["params"] == {"source": "camera:folder"}
def test_404_is_uncalibrated(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp, _ = self._get(logged_in_client, _fake(404, b"{}", {"detail": "no"}))
assert resp.status_code == 200
assert resp.get_json()["state"] == "uncalibrated"
assert resp.get_json()["calibration_url"] == "http://acq:8765/calibrazione"
def test_unreachable(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp, _ = self._get(logged_in_client, exc=requests.ConnectionError("down"))
assert resp.status_code == 200
assert resp.get_json()["state"] == "unreachable"
def test_server_error_is_unreachable(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp, _ = self._get(logged_in_client, _fake(500, b"boom"))
assert resp.get_json()["state"] == "unreachable"
def test_station_without_camera_is_409(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.get") as get:
resp = logged_in_client.get("/maker/api/stations/5/camera/calibration")
assert resp.status_code == 409
get.assert_not_called()
class TestDxfCompareProxy:
URL = "/maker/api/vision/tasks/7/recipe/dxf-compare"
OK = {
"result": {"outputs": {"dxf.points_in_pct": 99.1}, "overlay": [], "outcome": "pass"},
"saved": False, "recipe_dir": None, "outputs": None,
}
def _post(self, client, **form):
data = {"image": (io.BytesIO(PNG), "p.png"), "dxf": (io.BytesIO(b"0\nEOF"), "d.dxf"), **form}
return client.post(self.URL, data=data, content_type="multipart/form-data")
def test_forwards_files_and_fields(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = self.OK
resp = self._post(logged_in_client, station_id="4", band_mm="0.1",
min_points_pct="95", polarity="dark", save="false", px_per_mm="")
assert resp.status_code == 200
assert resp.get_json()["result"]["outputs"]["dxf.points_in_pct"] == 99.1
args, kwargs = mock_api_client.post.call_args
assert args[0] == "/api/vision/tasks/7/recipe/dxf-compare"
# campi del contratto B3; px_per_mm vuoto non si inoltra
assert kwargs["data"] == {
"station_id": "4", "band_mm": "0.1", "min_points_pct": "95",
"polarity": "dark", "save": "false",
}
assert set(kwargs["files"]) == {"image", "dxf"}
assert kwargs["files"]["image"][1] == PNG
def test_save_true_forwarded(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {**self.OK, "saved": True, "recipe_dir": "vision/recipes/7/x"}
resp = self._post(logged_in_client, save="true")
assert resp.get_json()["saved"] is True
assert mock_api_client.post.call_args.kwargs["data"]["save"] == "true"
def test_missing_files_is_400(self, logged_in_client, mock_api_client):
resp = logged_in_client.post(self.URL, data={"station_id": "4"},
content_type="multipart/form-data")
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_backend_422_propagates(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {
"error": True, "status_code": 422, "detail": "stazione non calibrata"}
resp = self._post(logged_in_client)
assert resp.status_code == 422
assert resp.get_json()["detail"] == "stazione non calibrata"
def test_requires_maker_role(self, logged_in_client, mock_api_client):
with logged_in_client.session_transaction() as sess:
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"]}
assert self._post(logged_in_client).status_code == 403
mock_api_client.post.assert_not_called()
class TestPages:
def test_stations_page_has_calibration_cell(self, logged_in_client):
with patch("blueprints.admin.api_client") as admin_api:
admin_api.get.side_effect = api_get_router({"/api/stations": [STATION], "/api/recipes": []})
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
assert "js/station_calibration.js" in html
assert "stationCalibrationCell(" in html
# niente piu' pannello di calibrazione nella pagina: solo il link all'acquisitore
assert "calibration-panel" not in html
def _editor(self, client, mock_api_client, task_type):
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
"/api/recipes/3/tasks": [{
"id": 11, "title": "T", "task_type": task_type, "subtasks": [],
"file_path": None, "order_index": 1,
}],
})
return client.get("/maker/recipes/3/tasks").get_data(as_text=True)
def test_editor_has_dxf_wizard(self, logged_in_client, mock_api_client):
html = self._editor(logged_in_client, mock_api_client, "dxf_compare")
assert "js/dxf_recipe_wizard.js" in html
assert "dxfRecipeWizard(" in html
assert "@dxf-recipe-saved" in html
assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html
@@ -3359,3 +3359,96 @@ msgstr "Camera service unreachable: %(detail)s"
#, python-format
msgid "Acquisizione non riuscita (%(status)s): %(detail)s"
msgstr "Capture failed (%(status)s): %(detail)s"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
#, python-format
msgid "Risposta %(status)s dall'acquisitore"
msgstr "Response %(status)s from the camera service"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Risposta non valida dall'acquisitore"
msgstr "Invalid response from the camera service"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Servono l'immagine e il file DXF"
msgstr "Both the image and the DXF file are required"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Calibrazione"
msgstr "Calibration"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "non calibrata"
msgstr "not calibrated"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "acquisitore non raggiungibile"
msgstr "camera service unreachable"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Nessuna camera"
msgstr "No camera"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Calibra"
msgstr "Calibrate"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Ricetta salvata"
msgstr "Recipe saved"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Crea ricetta da DXF"
msgstr "Create recipe from DXF"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Scegli la stazione"
msgstr "Choose the station"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Nessuna stazione con camera configurata"
msgstr "No station with a camera configured"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Stazione non calibrata: calibrala prima di creare la ricetta."
msgstr "Station not calibrated: calibrate it before creating the recipe."
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Acquisitore non raggiungibile: impossibile leggere la calibrazione."
msgstr "Camera service unreachable: cannot read the calibration."
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "oppure vedi la pagina Stazioni"
msgstr "or see the Stations page"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Immagine del pezzo"
msgstr "Part image"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Carica immagine"
msgstr "Upload image"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Disegno (.dxf)"
msgstr "Drawing (.dxf)"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Banda ± mm"
msgstr "Band ± mm"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Soglia punti in banda %%"
msgstr "Points-in-band threshold %%"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Pezzo scuro su fondo chiaro (controluce)"
msgstr "Dark part on light background (backlight)"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Prova"
msgstr "Try"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Salva ricetta"
msgstr "Save recipe"
@@ -3347,3 +3347,96 @@ msgstr "Servizio camera non raggiungibile: %(detail)s"
#, python-format
msgid "Acquisizione non riuscita (%(status)s): %(detail)s"
msgstr "Acquisizione non riuscita (%(status)s): %(detail)s"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
#, python-format
msgid "Risposta %(status)s dall'acquisitore"
msgstr "Risposta %(status)s dall'acquisitore"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Risposta non valida dall'acquisitore"
msgstr "Risposta non valida dall'acquisitore"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Servono l'immagine e il file DXF"
msgstr "Servono l'immagine e il file DXF"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Calibrazione"
msgstr "Calibrazione"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "non calibrata"
msgstr "non calibrata"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "acquisitore non raggiungibile"
msgstr "acquisitore non raggiungibile"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Nessuna camera"
msgstr "Nessuna camera"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Calibra"
msgstr "Calibra"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Ricetta salvata"
msgstr "Ricetta salvata"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Crea ricetta da DXF"
msgstr "Crea ricetta da DXF"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Scegli la stazione"
msgstr "Scegli la stazione"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Nessuna stazione con camera configurata"
msgstr "Nessuna stazione con camera configurata"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Stazione non calibrata: calibrala prima di creare la ricetta."
msgstr "Stazione non calibrata: calibrala prima di creare la ricetta."
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Acquisitore non raggiungibile: impossibile leggere la calibrazione."
msgstr "Acquisitore non raggiungibile: impossibile leggere la calibrazione."
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "oppure vedi la pagina Stazioni"
msgstr "oppure vedi la pagina Stazioni"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Immagine del pezzo"
msgstr "Immagine del pezzo"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Carica immagine"
msgstr "Carica immagine"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Disegno (.dxf)"
msgstr "Disegno (.dxf)"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Banda ± mm"
msgstr "Banda ± mm"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Soglia punti in banda %%"
msgstr "Soglia punti in banda %%"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Pezzo scuro su fondo chiaro (controluce)"
msgstr "Pezzo scuro su fondo chiaro (controluce)"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Prova"
msgstr "Prova"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Salva ricetta"
msgstr "Salva ricetta"