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