Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bfcfaa5f2 | |||
| ae2353f7f8 |
@@ -73,3 +73,6 @@ docker-compose.override.yml
|
|||||||
|
|
||||||
# Subagent-driven development scratch: ledger, briefs, review packages
|
# Subagent-driven development scratch: ledger, briefs, review packages
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
# Generato da packaging/windows/build.ps1 (TieMeasureFlow#82): moduli-strumento dagli entry point
|
||||||
|
packaging/windows/_strumenti.py
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
|
|||||||
- ⚠️ Alembic carica `env.py` e `versions\` dal disco, quindi Nuitka non può compilarli: `build.ps1` li copia come soli `.pyc` (`compileall -b`) e `avvio.py` esegue `upgrade head` con `sourceless=true`. È l'unica eccezione al «nessun `.py`/`.pyc`»: il controllo finale di `build.ps1` ammette `.pyc` solo sotto `src\backend\migrations`. `env.py` chiama `fileConfig`, che azzera i gestori del log: `avvio.py` li ricrea dopo le migrazioni.
|
- ⚠️ Alembic carica `env.py` e `versions\` dal disco, quindi Nuitka non può compilarli: `build.ps1` li copia come soli `.pyc` (`compileall -b`) e `avvio.py` esegue `upgrade head` con `sourceless=true`. È l'unica eccezione al «nessun `.py`/`.pyc`»: il controllo finale di `build.ps1` ammette `.pyc` solo sotto `src\backend\migrations`. `env.py` chiama `fileConfig`, che azzera i gestori del log: `avvio.py` li ricrea dopo le migrazioni.
|
||||||
- ⚠️ `numba` è escluso dal pacchetto (`--nofollow-import-to=numba`): le funzioni compilate da Nuitka non hanno bytecode e numba non può farne il JIT. Core e pm2d hanno il ripiego in Python puro (`HAS_NUMBA=False`): funziona ma il matching è più lento. Da misurare sul TC bench.
|
- ⚠️ `numba` è escluso dal pacchetto (`--nofollow-import-to=numba`): le funzioni compilate da Nuitka non hanno bytecode e numba non può farne il JIT. Core e pm2d hanno il ripiego in Python puro (`HAS_NUMBA=False`): funziona ma il matching è più lento. Da misurare sul TC bench.
|
||||||
- ⚠️ L'exe è senza console: `sys.stdout`/`sys.stderr` possono essere `None` (`avvio.py` li sostituisce con il device nullo), uvicorn gira con `log_config=None` e il log va su file (`...\log\tiemeasureflow.log`). I server uvicorn girano in thread (nessun signal handler) e waitress si crea con `create_server`.
|
- ⚠️ L'exe è senza console: `sys.stdout`/`sys.stderr` possono essere `None` (`avvio.py` li sostituisce con il device nullo), uvicorn gira con `log_config=None` e il log va su file (`...\log\tiemeasureflow.log`). I server uvicorn girano in thread (nessun signal handler) e waitress si crea con `create_server`.
|
||||||
|
- ⚠️ Strumenti di visione (TieMeasureFlow#82): nel binario non ci sono i metadati di `vs-tools`, quindi `load_tools()` (entry point `visionsuite.task.tools`) non trova nulla e ogni ricetta fallisce con tipo sconosciuto. `build.ps1` genera `packaging/windows/_strumenti.py` (`MODULI`, gitignorato, letto dal venv del build), `avvio.py` `registra_strumenti()` lo importa prima dei server, e prima di Inno Setup `build.ps1` lancia l'exe con `--controlla-strumenti` (senza licenza né server; JSON in `...\log\controllo-strumenti.json`, exit 3 se il catalogo è vuoto o un modulo non si importa).
|
||||||
- ⚠️ `visionsuite` è un namespace condiviso da più distribuzioni: `build.ps1` elenca i sottopacchetti dagli `__path__` e i metadati con `--include-distribution-metadata`. Un nuovo pacchetto di Core si prende da solo se è in `[tool.uv.sources]` e nel gruppo.
|
- ⚠️ `visionsuite` è un namespace condiviso da più distribuzioni: `build.ps1` elenca i sottopacchetti dagli `__path__` e i metadati con `--include-distribution-metadata`. Un nuovo pacchetto di Core si prende da solo se è in `[tool.uv.sources]` e nel gruppo.
|
||||||
- ⚠️ `tailwind.css` non è nel repo: `build.ps1` lo genera con `npx tailwindcss@3.4.19` (stessa versione di `Dockerfile.frontend`); serve Internet solo a chi costruisce. Il setup finale non ha bisogno di Internet.
|
- ⚠️ `tailwind.css` non è nel repo: `build.ps1` lo genera con `npx tailwindcss@3.4.19` (stessa versione di `Dockerfile.frontend`); serve Internet solo a chi costruisce. Il setup finale non ha bisogno di Internet.
|
||||||
- ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe.
|
- ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe.
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ Come si usa: e' l'exe compilato con Nuitka (build.ps1); da sorgente, dalla radic
|
|||||||
con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`.
|
con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`.
|
||||||
Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove
|
Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove
|
||||||
automatiche; NON salta il controllo di licenza) e `--senza-browser`.
|
automatiche; NON salta il controllo di licenza) e `--senza-browser`.
|
||||||
|
`--controlla-strumenti` (TieMeasureFlow#82): NON richiede la licenza, non avvia server, migrazioni
|
||||||
|
ne' database; registra gli strumenti, scrive su stdout e in
|
||||||
|
%PROGRAMDATA%\\Tielogic\\TieMeasureFlow\\log\\controllo-strumenti.json un JSON
|
||||||
|
{strumenti, kind, falliti, unavailable} ed esce con 0 (strumenti > 0 e nessun fallito) o 3.
|
||||||
|
La usa build.ps1 prima di Inno Setup.
|
||||||
|
|
||||||
Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic.
|
Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic.
|
||||||
|
|
||||||
@@ -25,11 +30,15 @@ Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, wai
|
|||||||
⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono
|
⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono
|
||||||
la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni.
|
la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni.
|
||||||
⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss).
|
⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss).
|
||||||
|
⚠️ Nel binario Nuitka mancano i metadati di vs-tools, quindi `load_tools()` (che cerca gli
|
||||||
|
strumenti come entry point) non ne trova nessuno: `build.ps1` genera `_strumenti.py`
|
||||||
|
(MODULI, dagli entry point del venv) e `registra_strumenti()` li importa per nome (#82).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import importlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -264,6 +273,50 @@ def _prepara_log(dati: Path | None = None) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- strumenti di visione
|
||||||
|
|
||||||
|
def registra_strumenti() -> list[str]:
|
||||||
|
"""Registra gli strumenti di Core; torna i moduli che non si sono importati (#82).
|
||||||
|
|
||||||
|
Nel pacchetto Nuitka gli entry point non esistono: `load_tools()` non trova nulla e
|
||||||
|
l'elenco dei moduli arriva da `_strumenti.py`, generato da build.ps1. Da sorgente
|
||||||
|
`_strumenti` non c'e' e bastano gli entry point. Il worker di visione gira in un thread
|
||||||
|
di questo processo: registrare una volta sola qui vale per tutti."""
|
||||||
|
from visionsuite.task.registry import load_tools
|
||||||
|
load_tools()
|
||||||
|
try:
|
||||||
|
from _strumenti import MODULI # generato da build.ps1, accanto ad avvio.py
|
||||||
|
except ImportError:
|
||||||
|
MODULI = () # da sorgente: gli entry point di load_tools() bastano
|
||||||
|
falliti: list[str] = []
|
||||||
|
for modulo in MODULI:
|
||||||
|
try:
|
||||||
|
importlib.import_module(modulo) # eseguire il modulo registra i suoi @tool
|
||||||
|
except Exception: # un modulo rotto non ferma l'app: si logga e si raccoglie
|
||||||
|
log.error("strumenti %s non importati", modulo, exc_info=True)
|
||||||
|
falliti.append(modulo)
|
||||||
|
return falliti
|
||||||
|
|
||||||
|
|
||||||
|
def controlla_strumenti() -> int:
|
||||||
|
"""Senza licenza, server ne' DB: scrive il JSON del catalogo strumenti. Exit 0 se completo, 3 altrimenti."""
|
||||||
|
from visionsuite.task.graph import registry
|
||||||
|
from visionsuite.task.registry import unavailable
|
||||||
|
falliti = registra_strumenti()
|
||||||
|
kind = sorted(registry())
|
||||||
|
esito = {"strumenti": len(kind), "kind": kind, "falliti": falliti, "unavailable": unavailable()}
|
||||||
|
riga = json.dumps(esito, ensure_ascii=False)
|
||||||
|
if sys.stdout is not None:
|
||||||
|
print(riga)
|
||||||
|
try:
|
||||||
|
f = cartella_dati() / "log" / "controllo-strumenti.json"
|
||||||
|
f.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
f.write_text(riga + "\n", encoding="utf-8") # l'exe e' senza console: il file e' l'unica uscita sicura
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("controllo-strumenti.json non scritto: %s", e)
|
||||||
|
return 0 if kind and not falliti else 3
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- rete
|
# ---------------------------------------------------------------- rete
|
||||||
|
|
||||||
def _porta_aperta(porta: int) -> bool:
|
def _porta_aperta(porta: int) -> bool:
|
||||||
@@ -402,6 +455,8 @@ def main(argv=None) -> int:
|
|||||||
p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)")
|
p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)")
|
||||||
p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)")
|
p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)")
|
||||||
p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)")
|
p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)")
|
||||||
|
p.add_argument("--controlla-strumenti", action="store_true",
|
||||||
|
help="registra gli strumenti, scrive il JSON ed esce (senza licenza ne' server; usata da build.ps1)")
|
||||||
args = p.parse_args(argv)
|
args = p.parse_args(argv)
|
||||||
_prepara_log()
|
_prepara_log()
|
||||||
|
|
||||||
@@ -415,6 +470,9 @@ def main(argv=None) -> int:
|
|||||||
messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre)
|
messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if args.controlla_strumenti:
|
||||||
|
return controlla_strumenti() # prima della licenza: la build non ha una licenza
|
||||||
|
|
||||||
if not controlla_licenza(args.senza_finestre):
|
if not controlla_licenza(args.senza_finestre):
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@@ -449,6 +507,7 @@ def main(argv=None) -> int:
|
|||||||
_prepara_log(dati) # alembic ha riconfigurato il logging
|
_prepara_log(dati) # alembic ha riconfigurato il logging
|
||||||
log.info("migrazioni applicate (primo avvio: %s)", primo_avvio)
|
log.info("migrazioni applicate (primo avvio: %s)", primo_avvio)
|
||||||
|
|
||||||
|
registra_strumenti() # prima del server: il worker di visione legge registry() gia' popolato (#82)
|
||||||
thread = avvia_server(args.host, dati)
|
thread = avvia_server(args.host, dati)
|
||||||
if not args.senza_browser:
|
if not args.senza_browser:
|
||||||
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
|
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ segue da solo, quindi i sottopacchetti si elencano uno per uno dagli __path__ in
|
|||||||
⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba
|
⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba
|
||||||
non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False):
|
non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False):
|
||||||
funziona ma e' piu' lento nel matching.
|
funziona ma e' piu' lento nel matching.
|
||||||
|
⚠️ Gli strumenti di vs-tools si registrano solo come entry point (visionsuite.task.tools) e i
|
||||||
|
metadati di vs-tools non stanno nel binario (TieMeasureFlow#82): il build genera
|
||||||
|
packaging\windows\_strumenti.py (MODULI, gitignorato) che avvio.py importa, e prima di
|
||||||
|
Inno Setup esegue l'exe con --controlla-strumenti: se il catalogo e' vuoto il build fallisce.
|
||||||
⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`):
|
⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`):
|
||||||
si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo.
|
si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo.
|
||||||
#>
|
#>
|
||||||
@@ -85,8 +89,9 @@ try {
|
|||||||
$pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))"
|
$pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))"
|
||||||
if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" }
|
if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" }
|
||||||
$includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" })
|
$includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" })
|
||||||
# Nessun --include-distribution-metadata per vs-*: nulla li legge e Nuitka rifiuta i metadati di una
|
# Nessun --include-distribution-metadata per vs-*: Nuitka rifiuta i metadati di una distribuzione il cui
|
||||||
# distribuzione il cui pacchetto (visionsuite.camera, namespace) non e' di primo livello.
|
# pacchetto (visionsuite.camera, namespace) non e' di primo livello. Gli entry point di vs-tools, che
|
||||||
|
# load_tools() cercherebbe nei metadati, si rimpiazzano con _strumenti.py (sotto, TieMeasureFlow#82).
|
||||||
Write-Host "Pacchetti visionsuite: $($includi -join ' ')"
|
Write-Host "Pacchetti visionsuite: $($includi -join ' ')"
|
||||||
|
|
||||||
# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..."
|
# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..."
|
||||||
@@ -103,6 +108,25 @@ if (Test-Path $leggimi) {
|
|||||||
# Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH
|
# Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH
|
||||||
$env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';')
|
$env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';')
|
||||||
|
|
||||||
|
# TieMeasureFlow#82: i moduli-strumento dagli entry point del venv del build (nel binario non ci sono i
|
||||||
|
# metadati di vs-tools, quindi load_tools() non li troverebbe). Si usa $py, non `uv run`.
|
||||||
|
$strumenti = Join-Path $radice 'packaging\windows\_strumenti.py'
|
||||||
|
$genera = @'
|
||||||
|
import sys
|
||||||
|
from importlib.metadata import entry_points
|
||||||
|
moduli = sorted({ep.value for ep in entry_points(group="visionsuite.task.tools")})
|
||||||
|
if not moduli:
|
||||||
|
sys.exit(1)
|
||||||
|
righe = ["# Generato da build.ps1 (TieMeasureFlow#82): non modificare, non committare.", "MODULI = ("]
|
||||||
|
righe += [f" {m!r}," for m in moduli] + [")"]
|
||||||
|
with open(sys.argv[1], "w", encoding="utf-8", newline="") as f:
|
||||||
|
print(*righe, sep=chr(10), file=f)
|
||||||
|
print(len(moduli))
|
||||||
|
'@
|
||||||
|
$nModuli = $genera | & $py - $strumenti
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Entry point visionsuite.task.tools non trovati nel venv: nessuno strumento da includere" }
|
||||||
|
Write-Host "Moduli-strumento ($nModuli) in $strumenti"
|
||||||
|
|
||||||
$out = Join-Path $radice 'build\nuitka'
|
$out = Join-Path $radice 'build\nuitka'
|
||||||
New-Item -ItemType Directory -Force $out | Out-Null
|
New-Item -ItemType Directory -Force $out | Out-Null
|
||||||
$nuitka = @(
|
$nuitka = @(
|
||||||
@@ -201,6 +225,17 @@ $sporchi = Get-ChildItem $sorgente -Recurse -Include *.py, *.pyc -File |
|
|||||||
Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' }
|
Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' }
|
||||||
if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" }
|
if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" }
|
||||||
|
|
||||||
|
# TieMeasureFlow#82: l'exe deve vedere gli strumenti (--controlla-strumenti non vuole licenza ne' apre server/DB)
|
||||||
|
$exe = Join-Path $sorgente 'TieMeasureFlow.exe'
|
||||||
|
$jsonControllo = Join-Path $env:PROGRAMDATA 'Tielogic\TieMeasureFlow\log\controllo-strumenti.json'
|
||||||
|
Remove-Item $jsonControllo -ErrorAction SilentlyContinue # un JSON vecchio non deve far passare il controllo
|
||||||
|
# l'exe e' GUI-subsystem: con & non si aspetta, serve Start-Process -Wait
|
||||||
|
$proc = Start-Process -FilePath $exe -ArgumentList '--controlla-strumenti' -Wait -PassThru
|
||||||
|
$esito = if (Test-Path $jsonControllo) { Get-Content $jsonControllo -Raw } else { '(controllo-strumenti.json non scritto)' }
|
||||||
|
if ($proc.ExitCode -ne 0) { throw "Controllo strumenti fallito (codice $($proc.ExitCode)): $esito" }
|
||||||
|
$nStrumenti = ($esito | ConvertFrom-Json).strumenti
|
||||||
|
Write-Host "Controllo strumenti: $nStrumenti tipi registrati nell'exe"
|
||||||
|
|
||||||
if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return }
|
if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return }
|
||||||
|
|
||||||
$dist = Join-Path $radice 'dist'
|
$dist = Join-Path $radice 'dist'
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import importlib.util
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -195,3 +197,77 @@ def test_chiavi_extra_di_config_env_passano_ma_non_sovrascrivono_il_contratto(av
|
|||||||
avvio.main(["--senza-finestre", "--senza-browser"])
|
avvio.main(["--senza-finestre", "--senza-browser"])
|
||||||
assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1"
|
assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1"
|
||||||
assert os.environ["APP_ENV"] == "production" # le variabili fisse vincono
|
assert os.environ["APP_ENV"] == "production" # le variabili fisse vincono
|
||||||
|
|
||||||
|
|
||||||
|
# --- TieMeasureFlow#82: strumenti nel pacchetto (nel binario non ci sono gli entry point) ---
|
||||||
|
|
||||||
|
def _modulo_strumenti(monkeypatch, moduli):
|
||||||
|
# finto _strumenti.py generato da build.ps1
|
||||||
|
finto = types.ModuleType("_strumenti")
|
||||||
|
finto.MODULI = tuple(moduli)
|
||||||
|
monkeypatch.setitem(sys.modules, "_strumenti", finto)
|
||||||
|
|
||||||
|
|
||||||
|
def test_registra_strumenti_da_sorgente_senza_modulo_generato(avvio, monkeypatch):
|
||||||
|
# _strumenti.py esiste solo dopo build.ps1: se manca (sorgente) bastano gli entry point
|
||||||
|
monkeypatch.setitem(sys.modules, "_strumenti", None) # None in sys.modules = ImportError all'import
|
||||||
|
from visionsuite.task.graph import registry
|
||||||
|
assert avvio.registra_strumenti() == []
|
||||||
|
assert len(registry()) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_registra_strumenti_modulo_inesistente_non_solleva(avvio, monkeypatch):
|
||||||
|
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image", "visionsuite.tools.non_esiste"))
|
||||||
|
assert avvio.registra_strumenti() == ["visionsuite.tools.non_esiste"] # raccolto, non sollevato
|
||||||
|
|
||||||
|
|
||||||
|
def test_registra_strumenti_importa_i_moduli_elencati(avvio, monkeypatch):
|
||||||
|
# senza entry point (come nell'exe) i moduli di MODULI sono l'unica via: li si importa per nome
|
||||||
|
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image",))
|
||||||
|
visti = []
|
||||||
|
monkeypatch.setattr(avvio.importlib, "import_module", lambda nome: visti.append(nome))
|
||||||
|
assert avvio.registra_strumenti() == [] and visti == ["visionsuite.tools.image"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_controlla_strumenti_senza_licenza_scrive_json(avvio, monkeypatch, capsys, tmp_path):
|
||||||
|
monkeypatch.setitem(sys.modules, "_strumenti", None)
|
||||||
|
|
||||||
|
# la licenza non deve essere nemmeno chiesta, e server e migrazioni non devono partire
|
||||||
|
def mai(*a, **k):
|
||||||
|
raise AssertionError("licenza, server o migrazioni chiamati da --controlla-strumenti")
|
||||||
|
|
||||||
|
monkeypatch.setattr(avvio.lic, "verifica", mai)
|
||||||
|
monkeypatch.setattr(avvio.lic, "cerca_licenza", mai)
|
||||||
|
monkeypatch.setattr(avvio, "avvia_server", mai)
|
||||||
|
monkeypatch.setattr(avvio, "esegui_migrazioni", mai)
|
||||||
|
assert avvio.main(["--controlla-strumenti", "--senza-finestre"]) == 0
|
||||||
|
dati = json.loads(capsys.readouterr().out)
|
||||||
|
assert dati["strumenti"] > 0 and dati["strumenti"] == len(dati["kind"])
|
||||||
|
assert dati["falliti"] == []
|
||||||
|
scritto = tmp_path / "Tielogic" / "TieMeasureFlow" / "log" / "controllo-strumenti.json"
|
||||||
|
assert json.loads(scritto.read_text(encoding="utf-8")) == dati
|
||||||
|
assert avvio.aperti == [] and not (tmp_path / "Tielogic" / "TieMeasureFlow" / "tmf.db").exists() # nessun DB
|
||||||
|
|
||||||
|
|
||||||
|
def test_controlla_strumenti_exit_3_se_un_modulo_fallisce(avvio, monkeypatch, capsys):
|
||||||
|
_modulo_strumenti(monkeypatch, ("visionsuite.tools.non_esiste",))
|
||||||
|
assert avvio.main(["--controlla-strumenti"]) == 3
|
||||||
|
assert json.loads(capsys.readouterr().out)["falliti"] == ["visionsuite.tools.non_esiste"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_controlla_strumenti_exit_3_se_il_catalogo_e_vuoto(avvio, monkeypatch, capsys):
|
||||||
|
# niente strumenti registrati (es. entry point assenti e _strumenti vuoto): il controllo deve fallire
|
||||||
|
import visionsuite.task.graph as graph
|
||||||
|
monkeypatch.setattr(avvio, "registra_strumenti", lambda: [])
|
||||||
|
monkeypatch.setattr(graph, "registry", lambda: {})
|
||||||
|
assert avvio.main(["--controlla-strumenti"]) == 3
|
||||||
|
assert json.loads(capsys.readouterr().out)["strumenti"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_avvio_normale_registra_gli_strumenti_prima_del_server(avvio, tmp_path, monkeypatch):
|
||||||
|
# main() chiama registra_strumenti() dopo licenza e migrazioni e prima di avvia_server
|
||||||
|
_scrivi_licenza(tmp_path)
|
||||||
|
monkeypatch.setattr(avvio, "registra_strumenti", lambda: avvio.chiamate.append("strumenti") or [])
|
||||||
|
assert avvio.main(["--senza-finestre", "--senza-browser"]) == 0
|
||||||
|
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||||
|
assert avvio.chiamate == ["migrazioni", "strumenti", ("127.0.0.1", dati)]
|
||||||
|
|||||||
@@ -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