Compare commits

..

15 Commits

Author SHA1 Message Date
Adriano 1bfcfaa5f2 Ricetta DXF da camera: pannello editor, stato calibrazione stazioni, proxy camera (#83)
Rotte maker: elenco stazioni con camera, grab per stazione, stato calibrazione
dall'acquisitore (C1), inoltro a B3. Pagina stazioni: stato e link «Calibra».
Test con api_client e requests finti; traduzioni IT/EN.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:51:11 +02:00
Adriano ae2353f7f8 Pacchetto Windows: registra gli strumenti senza entry point e controlla il catalogo nel build (#82)
rilascio-windows / setup-windows (push) Successful in 32m30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMYrdz6uBbuwbaQ9MqDmMc
2026-10-19 23:03:48 +02:00
Adriano d652891c1c Merge lavoro/daitarn-tmf-81 (TieMeasureFlow#81)
rilascio-windows / setup-windows (push) Successful in 34m11s
2026-10-17 14:25:56 +02:00
Adriano 0b22643d69 Merge lavoro/daitarn-tmf-80 (TieMeasureFlow#80) 2026-10-17 14:25:56 +02:00
Adriano 15e31e2b8a Merge lavoro/daitarn-tmf-79 (TieMeasureFlow#79) 2026-10-17 14:25:55 +02:00
Adriano f8f5f248ff setup: init-db e reset-db stampano head in un thread (TieMeasureFlow#80)
_stamp_head esegue env.py di alembic, che fa asyncio.run() e dentro il loop
di FastAPI dava RuntimeError (500). Ora gira con asyncio.to_thread. Nuovo
test_setup_stamp.py senza monkeypatch di _stamp_head, su file SQLite.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 14:25:24 +02:00
Adriano f33415df6a Test frontend: niente piu' dipendenza dall'ordine sulla stazione (TieMeasureFlow#79)
test_measure.py non ricarica piu' config/blueprints.measure (monkeypatch.setattr,
annullato al teardown); TestTaskComplete e test_recipe_rules_ui.py fissano la
stazione esplicitamente con una fixture autouse.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 14:13:15 +02:00
Adriano 62b5e412ca static del frontend sempre LF (.gitattributes): impronte di VERSIONS.md e pacchetto Windows identici (TieMeasureFlow#81)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:10:39 +02:00
Adriano 0e92959022 Merge piano C: setup Windows di TieMeasureFlow (TieMeasureFlow#76)
rilascio-windows / setup-windows (push) Successful in 32m46s
2026-10-17 12:39:35 +02:00
Adriano f23e87e585 Merge piano B: report PDF su Windows offline (TieMeasureFlow#78) 2026-10-17 12:39:35 +02:00
Adriano 114e4a10ae Merge piano A: backend su SQLite (TieMeasureFlow#77) 2026-10-17 12:39:34 +02:00
Adriano 0a68a9995e Pacchetto Windows: LEGGIMI dei report PDF (DLL, variabili, flag Nuitka, licenze) (TieMeasureFlow#78)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:18:21 +02:00
Adriano 8431aa7095 Report PDF su Windows: MathJax locale per Kaleido (niente CDN offline) e test con WeasyPrint/Edge veri (TieMeasureFlow#78)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:16:29 +02:00
Adriano 23012fdfbb Pacchetto Windows: prendi-gtk.ps1 scarica le DLL Pango/GTK pinnate con sha256 (TieMeasureFlow#78)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:10:11 +02:00
Adriano 242add047b Backend su file SQLite locale: DB_URL, PRAGMA FK/WAL, UPLOAD_DIR assoluto (TieMeasureFlow#77)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:07:45 +02:00
26 changed files with 1891 additions and 41 deletions
+3
View File
@@ -0,0 +1,3 @@
# TieMeasureFlow#81: static del frontend sempre con LF, anche con core.autocrlf=true su Windows:
# le impronte sha256 di static/vendor/VERSIONS.md (test_offline.py) e il pacchetto Windows le vogliono identiche.
src/frontend/flask_app/static/** text=auto eol=lf
+3
View File
@@ -73,3 +73,6 @@ docker-compose.override.yml
# Subagent-driven development scratch: ledger, briefs, review packages
.superpowers/
# Generato da packaging/windows/build.ps1 (TieMeasureFlow#82): moduli-strumento dagli entry point
packaging/windows/_strumenti.py
+1
View File
@@ -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.
- ⚠️ `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`.
- ⚠️ 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.
- ⚠️ `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.
+59
View File
@@ -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]`.
Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove
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.
@@ -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
la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni.
⚠️ 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
import argparse
import ctypes
import importlib
import json
import logging
import os
@@ -264,6 +273,50 @@ def _prepara_log(dati: Path | None = None) -> None:
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
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("--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("--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)
_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)
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):
return 1
@@ -449,6 +507,7 @@ def main(argv=None) -> int:
_prepara_log(dati) # alembic ha riconfigurato il logging
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)
if not args.senza_browser:
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
+37 -2
View File
@@ -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
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.
⚠️ 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`):
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))"
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=$_" })
# Nessun --include-distribution-metadata per vs-*: nulla li legge e Nuitka rifiuta i metadati di una
# distribuzione il cui pacchetto (visionsuite.camera, namespace) non e' di primo livello.
# Nessun --include-distribution-metadata per vs-*: Nuitka rifiuta i metadati di una distribuzione il cui
# 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 ' ')"
# 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
$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'
New-Item -ItemType Directory -Force $out | Out-Null
$nuitka = @(
@@ -201,6 +225,17 @@ $sporchi = Get-ChildItem $sorgente -Recurse -Include *.py, *.pyc -File |
Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' }
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 }
$dist = Join-Path $radice 'dist'
+3
View File
@@ -0,0 +1,3 @@
# Le DLL scaricate da prendi-gtk.ps1 non si committano: solo lo script (TieMeasureFlow#78).
gtk/
cache/
+149
View File
@@ -0,0 +1,149 @@
# Report PDF nel pacchetto Windows
Questa cartella contiene quello che serve perché i report PDF di TieMeasureFlow
(`src/backend/services/report_service.py`: Jinja2, Plotly con Kaleido 1.2.0, WeasyPrint 68.1)
funzionino su Windows, dentro il pacchetto, **senza Internet**. Riferimento: TieMeasureFlow#78
(parte del pacchetto Windows, madre #76).
## Come funziona
Un report SPC passa da due componenti nativi che su Windows non arrivano con `pip`:
1. **WeasyPrint** disegna il PDF e carica a runtime le DLL di Pango/GTK
(`libgobject-2.0-0`, `libpango-1.0-0`, `libharfbuzz-0`, `libharfbuzz-subset-0`,
`libfontconfig-1`, `libpangoft2-1.0-0` e le loro dipendenze).
2. **Kaleido 1.x** trasforma i grafici Plotly in SVG pilotando un browser Chromium già installato
(non porta più Chromium con sé). Su ogni Windows 10/11 c'è Edge.
### Le DLL: `prendi-gtk.ps1`
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File packaging\windows\pdf\prendi-gtk.ps1 -Destinazione <cartella>
```
- Scarica da `repo.msys2.org` (ucrt64) 21 pacchetti **pinnati con sha256** (nome file e hash stanno nello
script), verifica l'hash, estrae solo le 27 DLL necessarie, `etc\fonts` di fontconfig e le licenze.
- Idempotente: se la cartella è già pronta (file `.gtk-pin` uguale ai pin dello script) esce subito.
- Se un hash non coincide fallisce e non lascia mezza cartella.
- I pacchetti scaricati stanno in `%LOCALAPPDATA%\Tielogic\tmf-gtk-cache` (`-Cache` per cambiarla).
- Serve Internet **solo alla build**. Estrae `.zst` con `tar.exe` di Windows (verificato: bsdtar 3.8.9 con
zstd); se `tar.exe` non sa di zstd ripiega su `uv run --with zstandard`.
- Gira da utente non admin con PowerShell 5.1.
- ⚠️ `repo.msys2.org` tiene solo le versioni recenti: se un file pinnato sparisce (404) vanno aggiornati nome
e sha256 dal db `https://repo.msys2.org/mingw/ucrt64/ucrt64.db` e rifatto il test. Conservare la cache
(Wasabi o NAS) evita la corsa contro il repository.
Il piano C copia `<cartella>` in `P\gtk\` (P = cartella programma).
### Variabili d'ambiente (le imposta `avvio.py`, piano C)
| Variabile | Valore | Perché |
|---|---|---|
| `WEASYPRINT_DLL_DIRECTORIES` | `<P>\gtk` | WeasyPrint chiama `os.add_dll_directory` su ogni cartella (separatore `;`) **prima** di caricare le DLL. Va impostata prima di importare `weasyprint` (lo fa `report_service` all'import). |
| `BROWSER_PATH` | `msedge.exe` o `chrome.exe` | Choreographer (sotto Kaleido) la legge da sé e ha la precedenza su ogni ricerca (verificato: un percorso inesistente dà `FileNotFoundError`). Senza la variabile Kaleido cerca Edge/Chrome da solo (registro e percorsi tipici). Nessun codice in `report_service.py` per questo. |
Fontconfig non richiede variabili: trova `etc\fonts` accanto alle DLL (verificato). Se manca `etc\fonts`
WeasyPrint stampa "Fontconfig error: Cannot load default config file". I font dei report vengono da
`C:\Windows\Fonts`.
### MathJax: la modifica a `report_service.py`
Kaleido 1.x inserisce nella pagina di render uno `<script>` MathJax da `cdnjs.cloudflare.com` anche quando i
grafici non hanno formule. Misurato su daitarn con Edge: con rete che scarta i pacchetti ogni grafico aspetta
~19 s (due grafici per report SPC); con rete che rifiuta subito ~2 s. `report_service.py` imposta
`pio.defaults.mathjax` su un file `.js` vuoto in `%TEMP%` (`tmf-mathjax-vuoto.js`): nessuna richiesta di rete,
report SPC in ~7 s anche a rete bloccata. Se un giorno i grafici useranno LaTeX, qui serve un MathJax locale.
`plotly.min.js` invece è già locale (dentro il pacchetto `plotly`).
## Flag Nuitka e dati da includere (per `build.ps1`, piano C)
⚠️ Stato: ricavati leggendo i sorgenti installati (versioni del `uv.lock`); **non provati con una build
Nuitka**: la verifica è la prima build di C. I dati e i metadati sono quelli che il codice legge davvero da disco.
Dati dei pacchetti (file non `.py` letti a runtime):
```text
--include-package-data=weasyprint # css/html5_ua.css, html5_ph.css, html5_ua_form.css, pdf/sRGB2014.icc
--include-package-data=pyphen # dictionaries/*.dic (84 file, sillabazione)
--include-package-data=kaleido # vendor/kaleido_scopes.js (pagina di render)
--include-package-data=plotly # package_data/plotly.min.js (Kaleido lo cerca come file accanto a plotly/__init__), templates/*.json, validators/_validators.json
--include-package-data=tzdata # zoneinfo: Windows non ha il database IANA (vedi sotto)
--include-data-dir=src/backend/templates=src/backend/templates # Jinja2: report_service legge i .html dal disco
```
`choreographer/resources/last_known_good_chrome.json` serve solo al comando che scarica Chrome: non serve.
Metadati delle distribuzioni (`importlib.metadata.version` al momento dell'import): senza, `import plotly` e
Kaleido falliscono con `PackageNotFoundError`:
```text
--include-distribution-metadata=plotly
--include-distribution-metadata=kaleido
```
Import dinamici che l'analisi statica di Nuitka può perdere:
```text
--include-package=fontTools # ttLib/ttFont.py importa le tabelle con __import__("fontTools.ttLib.tables." + tag)
--include-package=plotly # _plotly_utils/importers.py: importlib.import_module lazy dei moduli graph_objs
--include-package=_plotly_utils
--include-module=_cffi_backend # WeasyPrint usa cffi in modalita' ABI (dlopen), serve il modulo C di cffi
--include-package=weasyprint
--include-package=kaleido
--include-package=choreographer
```
Altre note per C:
- WeasyPrint salta `os.add_dll_directory` se `sys.frozen` esiste (`weasyprint/text/ffi.py`). Nuitka di norma
non lo imposta (PyInstaller sì): se in `TieMeasureFlow.exe` le DLL non si trovassero, chiamare
`os.add_dll_directory(<P>\gtk)` da `avvio.py` prima dell'import.
- Le DLL **non** vanno compilate né incluse da Nuitka: restano file in `P\gtk\`.
- Il controllo "nessun `.py`" di `build.ps1` non deve scandire `gtk\` (non ci sono `.py`, ma è meglio
saperlo) né `licenze\`.
- **`tzdata` manca da `pyproject.toml`**: su Windows `ZoneInfo("Europe/Rome")` (usato da
`src/backend/utils/tempo.py`, quindi da ogni report) dà `ZoneInfoNotFoundError`. Va aggiunto
`tzdata; sys_platform == 'win32'` agli extra server e client (o al gruppo `windows`) e il frontend
(`src/frontend/flask_app/app.py` usa `ZoneInfo`) ne ha bisogno allo stesso modo.
- `pdfplumber` è già nell'extra `server` (il test lo usa per leggere i PDF).
## Licenze delle DLL
Le DLL sono binari MSYS2/MinGW (ucrt64) non modificati, collegati dinamicamente. `prendi-gtk.ps1` mette in
`<cartella>\licenze\` i testi di licenza di ogni pacchetto più un `<pacchetto>.txt` con nome del file e licenza.
**L'installer deve includere `gtk\licenze\`.**
| Libreria | Licenza |
|---|---|
| Pango, GLib/GObject/GIO, fribidi, libthai, libdatrie, graphite2, libintl (gettext-runtime), libiconv | LGPL 2.1 o successive |
| FreeType | FTL oppure GPL 2+ (si sceglie FTL) |
| HarfBuzz, expat, libffi, brotli, fontconfig | MIT / permissiva |
| libpng, zlib-ng, bzip2, pcre2 | permissive (libpng, Zlib, bzip2, BSD-3) |
| libgcc, libstdc++ | GPL 3 con GCC Runtime Library Exception (ridistribuzione libera) |
| libwinpthread | MIT e BSD-3 |
Per le librerie LGPL, chi riceve il pacchetto può sostituire le DLL (sono file separati in `gtk\`); i sorgenti
sono pubblici su MSYS2 (`https://packages.msys2.org/`), e l'elenco esatto delle versioni è nello script.
## Test
`src/backend/tests/test_report_windows.py` (saltato fuori da Windows, senza DLL, senza Edge/Chrome o senza
`tzdata`): in un sottoprocesso con WeasyPrint **vero** (il `conftest.py` altrimenti lo sostituisce con un
mock) genera su SQLite in memoria il report SPC (carta di controllo e istogramma) e quello a tabella, e
controlla con `pdfplumber` byte `%PDF-`, almeno una pagina, titoli dei grafici e segni vettoriali.
```powershell
# DLL (una volta) e test
powershell -NoProfile -ExecutionPolicy Bypass -File packaging\windows\pdf\prendi-gtk.ps1 -Destinazione packaging\windows\pdf\gtk
uv run --python 3.13 --extra server --extra dev pytest src/backend/tests/test_report_windows.py -q
```
`packaging/windows/pdf/gtk/` e `cache/` sono in `.gitignore`: le DLL non si committano.
### Prova a rete bloccata
`HTTP_PROXY`/`HTTPS_PROXY` non bastano: Edge e Chrome usano il **proxy di sistema** di Windows. Per provare davvero
il PC senza Internet: impostare temporaneamente il proxy di sistema (Impostazioni, Rete, Proxy) su un indirizzo
morto (`127.0.0.1:9` rifiuta subito; `10.255.255.1:8080` scarta i pacchetti, il caso peggiore), generare i
report e **ripristinare** il proxy. Esito del 2026-10-17 con proxy che scarta i pacchetti: report SPC e report a
tabella completi in ~7 s.
+143
View File
@@ -0,0 +1,143 @@
<#
prendi-gtk.ps1 - prepara le DLL di Pango/GTK che WeasyPrint 68.1 vuole su Windows.
COSA FA: scarica da repo.msys2.org (ucrt64) un elenco FISSO di pacchetti, ne verifica lo sha256,
estrae solo le DLL che servono a WeasyPrint (+ etc\fonts di fontconfig e le licenze) in -Destinazione.
COME SI USA: powershell -NoProfile -ExecutionPolicy Bypass -File prendi-gtk.ps1 -Destinazione <cartella>
Opzionali: -Cache <cartella> (pacchetti scaricati, default %LOCALAPPDATA%\Tielogic\tmf-gtk-cache),
-Forza (rifa' anche se la cartella e' gia' pronta).
DA COSA DIPENDE: Windows 10/11 con tar.exe (bsdtar con zstd) oppure uv + Python (modulo zstandard);
Internet solo al momento della build, mai a runtime. PowerShell 5.1 va bene, non serve admin.
IDEMPOTENTE: se la cartella ha gia' le DLL e il file .gtk-pin coincide con questi pin, esce senza scaricare.
FALLISCE se lo sha256 di un pacchetto non coincide (nessun file parziale resta in -Destinazione).
TRAPPOLA: repo.msys2.org tiene solo le versioni recenti. Se un file pinnato sparisce (404) serve aggiornare
i pin qui sotto (nome file + sha256 dal db https://repo.msys2.org/mingw/ucrt64/ucrt64.db) e riprovare i test.
La -Cache conserva i pacchetti gia' scaricati: copiarla su Wasabi/NAS evita il problema.
#>
param(
[Parameter(Mandatory = $true)][string]$Destinazione,
[string]$Cache = (Join-Path $env:LOCALAPPDATA 'Tielogic\tmf-gtk-cache'),
[switch]$Forza
)
$ErrorActionPreference = 'Stop'
# PS 5.1 usa TLS 1.0 di default: repo.msys2.org vuole 1.2 (cosa/perche')
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$Base = 'https://repo.msys2.org/mingw/ucrt64/'
# Pin: pacchetto, file, sha256 (dal db ucrt64 di MSYS2 del 2026-10-17), licenza dichiarata.
$Pacchetti = @(
@{ N='brotli'; F='mingw-w64-ucrt-x86_64-brotli-1.2.0-1-any.pkg.tar.zst'; H='9cc89665496ea504751476eafba15c71c554aaf3004babc2004f7c07ffd60514'; L='MIT' }
@{ N='bzip2'; F='mingw-w64-ucrt-x86_64-bzip2-1.0.8-4-any.pkg.tar.zst'; H='f03a2174034ddd2d96cecd34f617c5f8e2ef86c812b8d2bb3b8875257f2c8bfa'; L='bzip2 (BSD)' }
@{ N='expat'; F='mingw-w64-ucrt-x86_64-expat-2.9.0-1-any.pkg.tar.zst'; H='f52364dc874009316e1ee1fe46ee89784594b80d10cbcc1cc856945d575308e0'; L='MIT' }
@{ N='fontconfig'; F='mingw-w64-ucrt-x86_64-fontconfig-2.18.3-1-any.pkg.tar.zst'; H='d0b364f3ee37e3b6f6c136688ce8fa909bb6d8a69df7e08de1aa40870d9e1d60'; L='fontconfig (MIT-like)' }
@{ N='freetype'; F='mingw-w64-ucrt-x86_64-freetype-2.15.0-1-any.pkg.tar.zst'; H='a9a691f71d1a553099c54ca666e611abfb2b4ff08dae24f2a5af72e22a7e5feb'; L='GPL-2.0+ OR FTL' }
@{ N='fribidi'; F='mingw-w64-ucrt-x86_64-fribidi-1.0.17-1-any.pkg.tar.zst'; H='3581ee03cf83a9bb0e98bf688c4f532a29553500e317532d90746de295b0f8a8'; L='LGPL-2.1+' }
@{ N='gettext-runtime'; F='mingw-w64-ucrt-x86_64-gettext-runtime-1.0-1-any.pkg.tar.zst'; H='ba693dda4ac375af76ce481ff3a6e7481286546cc7dc6d56c7021dae34084157'; L='LGPL-2.1+ (libintl)' }
@{ N='glib2'; F='mingw-w64-ucrt-x86_64-glib2-2.90.1-1-any.pkg.tar.zst'; H='9a7826349279bcca0c935b01880d812be9de9609aef2205be68fd7a6128c1695'; L='LGPL-2.1+' }
@{ N='graphite2'; F='mingw-w64-ucrt-x86_64-graphite2-1.3.15-1-any.pkg.tar.zst'; H='34ca1f88bff10ef182a6b1c8e5622d5029f13b05748e361af13b552b422ea849'; L='LGPL-2.1+' }
@{ N='harfbuzz'; F='mingw-w64-ucrt-x86_64-harfbuzz-14.6.0-1-any.pkg.tar.zst'; H='8781d486c864bf91f482768cc5bd7267e49331636583d9f855af3974e6378e69'; L='MIT' }
@{ N='libdatrie'; F='mingw-w64-ucrt-x86_64-libdatrie-0.2.14-1-any.pkg.tar.zst'; H='ea92777801ad325278193a362967540752ae37035016df2f6bcee2d89928a195'; L='LGPL' }
@{ N='libffi'; F='mingw-w64-ucrt-x86_64-libffi-3.8.0-1-any.pkg.tar.zst'; H='99ad12f4ecfa00a889ef9c5c0188592368a5bac109d0c508ce4e04f29ea95a76'; L='MIT' }
@{ N='libgcc'; F='mingw-w64-ucrt-x86_64-libgcc-16.2.0-6-any.pkg.tar.zst'; H='80b2497f019977dd9528b1abc8621b1328a0115ab1f724047ccbb56bf323f11d'; L='GPL-3.0+ con GCC runtime exception' }
@{ N='libiconv'; F='mingw-w64-ucrt-x86_64-libiconv-1.19-1-any.pkg.tar.zst'; H='9a500f38c2b91808741c62fae746b3e9110b33a1ecf5c30fa0c66dbedddf7e16'; L='LGPL-2.1+' }
@{ N='libpng'; F='mingw-w64-ucrt-x86_64-libpng-1.6.59-1-any.pkg.tar.zst'; H='7f8448e06eceba8f8ce130390addea49f767bdf8f6b7e7eede8ca6d595e3d3a8'; L='libpng (permissiva)' }
@{ N='libstdc++'; F='mingw-w64-ucrt-x86_64-libstdc++-16.2.0-6-any.pkg.tar.zst'; H='33c5e58a85a538cf843a8672991cce591f0231d66da3b66a57629712d64be17f'; L='GPL-3.0+ con GCC runtime exception' }
@{ N='libthai'; F='mingw-w64-ucrt-x86_64-libthai-0.1.30-1-any.pkg.tar.zst'; H='c90ebbca3e7b4c34f4b3ffa501ba8d00cdaf6340cff7db3c46d79d5e01ef207c'; L='LGPL-2.1+' }
@{ N='libwinpthread'; F='mingw-w64-ucrt-x86_64-libwinpthread-14.0.0.r426.g4564ee4b5-2-any.pkg.tar.zst'; H='a02f5e09d666d3be657c78c86fe67c975d04fe33b5211cd5ff1b23a91c118524'; L='MIT AND BSD-3-Clause' }
@{ N='pcre2'; F='mingw-w64-ucrt-x86_64-pcre2-10.49-1-any.pkg.tar.zst'; H='63172179db724ac71e2439a7981134c1bcab8049bdddfd349b32b8f76ed52d1e'; L='BSD-3-Clause' }
@{ N='pango'; F='mingw-w64-ucrt-x86_64-pango-1.58.2-1-any.pkg.tar.zst'; H='8d86e4a018ff139b010bf357fa1c9f9030fa32ee195c54ed7e89d8ea701851c8'; L='LGPL-2.1' }
@{ N='zlib-ng-compat'; F='mingw-w64-ucrt-x86_64-zlib-ng-compat-2.3.3-2-any.pkg.tar.zst'; H='b3ca6b437fb154a0fa26652002cdda591498ee4a0e08d91cc653c3702d090f25'; L='Zlib' }
)
# DLL che WeasyPrint carica (6 radici) piu' la loro chiusura di dipendenze (calcolata con pefile il 2026-10-17).
$Dll = @(
'libbrotlicommon.dll','libbrotlidec.dll','libbz2-1.dll','libdatrie-1.dll','libexpat-1.dll','libffi-8.dll',
'libfontconfig-1.dll','libfreetype-6.dll','libfribidi-0.dll','libgcc_s_seh-1.dll','libgio-2.0-0.dll',
'libglib-2.0-0.dll','libgmodule-2.0-0.dll','libgobject-2.0-0.dll','libgraphite2.dll','libharfbuzz-0.dll',
'libharfbuzz-subset-0.dll','libiconv-2.dll','libintl-8.dll','libpango-1.0-0.dll','libpangoft2-1.0-0.dll',
'libpcre2-8-0.dll','libpng16-16.dll','libstdc++-6.dll','libthai-0.dll','libwinpthread-1.dll','zlib1.dll'
)
function Scrivi([string]$m) { Write-Host ("[prendi-gtk] " + $m) }
# Impronta dei pin: se cambia, la cartella gia' pronta non e' piu' valida.
$pinTesto = (($Pacchetti | ForEach-Object { $_.F + ':' + $_.H }) -join "`n") + "`n" + ($Dll -join ',')
$sha = [Security.Cryptography.SHA256]::Create()
$impronta = -join ($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($pinTesto)) | ForEach-Object { $_.ToString('x2') })
$marker = Join-Path $Destinazione '.gtk-pin'
$dllMancanti = @($Dll | Where-Object { -not (Test-Path (Join-Path $Destinazione $_)) })
if (-not $Forza -and (Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $impronta) -and $dllMancanti.Count -eq 0) {
Scrivi "gia' pronto in $Destinazione (pin $($impronta.Substring(0,12)))"
exit 0
}
# --- estrazione .zst: tar.exe di Windows se sa di zstd, altrimenti Python zstandard via uv ---
$tar = Join-Path $env:SystemRoot 'System32\tar.exe'
$tarZstd = $false
if (Test-Path $tar) { $tarZstd = ((& $tar --version) -join ' ') -match 'zstd' }
function Estrai([string]$pkg, [string]$dest) {
if ($tarZstd) {
& $tar -xf $pkg -C $dest
if ($LASTEXITCODE -ne 0) { throw "tar.exe ha fallito su $pkg" }
} else {
$py = "import sys,tarfile,zstandard`nd=zstandard.ZstdDecompressor()`nwith open(sys.argv[1],'rb') as f, d.stream_reader(f) as r:`n tarfile.open(fileobj=r,mode='r|').extractall(sys.argv[2])"
$tmpPy = Join-Path ([IO.Path]::GetTempPath()) ('estrai-zst-' + [guid]::NewGuid().ToString('N') + '.py')
Set-Content -Path $tmpPy -Value $py -Encoding ASCII
try {
& uv run --quiet --with zstandard python -I $tmpPy $pkg $dest
if ($LASTEXITCODE -ne 0) { throw "estrazione zstd via uv fallita su $pkg" }
}
finally { Remove-Item $tmpPy -ErrorAction SilentlyContinue }
}
}
New-Item -ItemType Directory -Force -Path $Cache | Out-Null
# Lavoro in una cartella temporanea, poi sposto: un errore a meta' non lascia mezza cartella in -Destinazione.
$tmp = Join-Path ([IO.Path]::GetTempPath()) ('tmf-gtk-' + [guid]::NewGuid().ToString('N'))
$stage = Join-Path $tmp 'stage'
New-Item -ItemType Directory -Force -Path $stage | Out-Null
try {
foreach ($p in $Pacchetti) {
$file = Join-Path $Cache $p.F
$ok = $false
if (Test-Path $file) {
$ok = ((Get-FileHash $file -Algorithm SHA256).Hash.ToLower() -eq $p.H)
if (-not $ok) { Scrivi "cache corrotta, riscarico $($p.F)"; Remove-Item $file -Force }
}
if (-not $ok) {
Scrivi "scarico $($p.F)"
$parz = $file + '.parz'
# il '+' di libstdc++ va codificato nell'URL
Invoke-WebRequest -UseBasicParsing -Uri ($Base + [Uri]::EscapeDataString($p.F)) -OutFile $parz
$h = (Get-FileHash $parz -Algorithm SHA256).Hash.ToLower()
if ($h -ne $p.H) { Remove-Item $parz -Force; throw "sha256 diverso per $($p.F): atteso $($p.H), trovato $h" }
Move-Item $parz $file -Force
}
$ex = Join-Path $tmp $p.N
New-Item -ItemType Directory -Force -Path $ex | Out-Null
Estrai $file $ex
$bin = Join-Path $ex 'ucrt64\bin'
if (Test-Path $bin) { Get-ChildItem $bin -Filter *.dll | Copy-Item -Destination $stage -Force }
# fontconfig: senza etc\fonts WeasyPrint non trova i font (errore "Cannot load default config file")
if ($p.N -eq 'fontconfig') { Copy-Item (Join-Path $ex 'ucrt64\etc\fonts') (Join-Path $stage 'etc\fonts') -Recurse -Force }
# licenze: le DLL LGPL si ridistribuiscono con il testo della licenza
New-Item -ItemType Directory -Force -Path (Join-Path $stage 'licenze') | Out-Null
$lic = Join-Path $ex 'ucrt64\share\licenses'
if (Test-Path $lic) { Copy-Item $lic (Join-Path $stage ('licenze\' + $p.N)) -Recurse -Force }
Set-Content -Path (Join-Path $stage ('licenze\' + $p.N + '.txt')) -Value ($p.F + "`nlicenza: " + $p.L + "`nsorgente: https://packages.msys2.org/") -Encoding UTF8
}
# Tengo solo le DLL in whitelist: le altre DLL dei pacchetti non servono.
Get-ChildItem $stage -Filter *.dll | Where-Object { $Dll -notcontains $_.Name } | Remove-Item -Force
$mancanti = @($Dll | Where-Object { -not (Test-Path (Join-Path $stage $_)) })
if ($mancanti.Count -gt 0) { throw "DLL attese non trovate: $($mancanti -join ', ')" }
Set-Content -Path (Join-Path $stage '.gtk-pin') -Value $impronta -Encoding ASCII
if (Test-Path $Destinazione) { Get-ChildItem $Destinazione -Force | Remove-Item -Recurse -Force }
New-Item -ItemType Directory -Force -Path $Destinazione | Out-Null
Get-ChildItem $stage -Force | Move-Item -Destination $Destinazione -Force
Scrivi "pronto: $($Dll.Count) DLL in $Destinazione"
}
finally {
Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
}
+76
View File
@@ -13,6 +13,8 @@ import importlib.util
import json
import os
import sqlite3
import sys
import types
from datetime import date, timedelta
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"])
assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1"
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)]
+7 -2
View File
@@ -3,6 +3,7 @@
Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404.
NOT protected by API Key auth (standalone access).
"""
import asyncio
import hmac
import random
from datetime import datetime, timedelta
@@ -312,7 +313,9 @@ async def init_db(body: PasswordBody):
await _require_not_initialised()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
_stamp_head()
# #80: env.py di alembic fa asyncio.run(), vietato dentro il loop di FastAPI;
# in un thread non c'e' un loop attivo e funziona.
await asyncio.to_thread(_stamp_head)
return {"status": "ok", "message": "Database tables created successfully"}
@@ -748,6 +751,8 @@ async def reset_db(body: ResetDbBody):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
_stamp_head()
# #80: env.py di alembic fa asyncio.run(), vietato dentro il loop di FastAPI;
# in un thread non c'e' un loop attivo e funziona.
await asyncio.to_thread(_stamp_head)
return {"status": "ok", "message": "Database reset successfully"}
+17 -2
View File
@@ -27,6 +27,10 @@ class Settings(BaseSettings):
db_name: str = "tiemeasureflow"
db_user: str = "tmflow"
db_password: str = _DEFAULT_DB_PASSWORD
# Full SQLAlchemy async URL (env DB_URL). When set it wins over the
# DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASSWORD parts. Used by the Windows
# package: sqlite+aiosqlite:///C:/ProgramData/.../tmf.db (forward slashes).
db_url: str | None = None
# Presentation time zone (DB is UTC, #42)
display_timezone: str = "Europe/Rome"
@@ -78,7 +82,9 @@ class Settings(BaseSettings):
@property
def database_url(self) -> str:
"""Async MySQL connection string."""
"""Async connection string: DB_URL if set, else the MySQL one."""
if self.db_url:
return self.db_url
return (
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
f"@{self.db_host}:{self.db_port}/{self.db_name}"
@@ -97,7 +103,12 @@ class Settings(BaseSettings):
(mounted as a Docker volume), not inside the backend tree.
"""
# Path(__file__) = src/backend/config.py → parents[2] = project root
return Path(__file__).resolve().parents[2] / self.upload_dir
# UPLOAD_DIR may be absolute (Windows package: C:/ProgramData/...):
# in that case it is used as is instead of being joined to the root.
configured = Path(self.upload_dir)
if configured.is_absolute():
return configured
return Path(__file__).resolve().parents[2] / configured
# Always resolve .env against the project root regardless of cwd
# (pydantic-settings would otherwise treat the path as cwd-relative).
@@ -122,6 +133,10 @@ class Settings(BaseSettings):
"SERVER_SECRET_KEY is still the committed default value; "
"set it before running with APP_ENV=" + self.app_env
)
# A SQLite file has no DB password: the default-password guard only
# makes sense for the MySQL connection built from the DB_* parts.
if self.db_url and self.db_url.startswith("sqlite"):
return self
if self.db_password == _DEFAULT_DB_PASSWORD:
raise RuntimeError(
"DB_PASSWORD is still the committed default value; "
+25 -8
View File
@@ -1,6 +1,7 @@
"""Async SQLAlchemy database engine and session management."""
from collections.abc import AsyncGenerator
from sqlalchemy import event
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
@@ -10,14 +11,30 @@ from sqlalchemy.orm import DeclarativeBase
from src.backend.config import settings
# Create async engine
engine = create_async_engine(
settings.database_url,
echo=False,
pool_size=10,
max_overflow=20,
pool_recycle=3600,
)
_IS_SQLITE = settings.database_url.startswith("sqlite")
# Create async engine. MySQL gets a connection pool; SQLite (local file of the
# Windows package) does not accept pool_size/max_overflow/pool_recycle.
if _IS_SQLITE:
engine = create_async_engine(settings.database_url, echo=False)
@event.listens_for(engine.sync_engine, "connect")
def _sqlite_pragmas(dbapi_connection, _connection_record) -> None:
"""Per-connection PRAGMAs: SQLite ignores FKs unless asked, and WAL +
busy_timeout let the web, vision and backend threads share the file."""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
else:
engine = create_async_engine(
settings.database_url,
echo=False,
pool_size=10,
max_overflow=20,
pool_recycle=3600,
)
# Session factory
async_session_factory = async_sessionmaker(
+18
View File
@@ -1,6 +1,7 @@
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
import asyncio
import base64
import tempfile
from datetime import datetime
from src.backend.utils.tempo import to_local, utcnow
from pathlib import Path
@@ -25,6 +26,23 @@ from src.backend.services.spc_service import (
compute_summary,
)
# Kaleido 1.x aggiunge alla pagina di render uno <script> MathJax dal CDN (cdnjs) anche se i grafici
# non usano LaTeX: senza Internet (stand, PC di produzione) ogni grafico aspetta il timeout di rete
# (~19 s con rete che scarta i pacchetti, TieMeasureFlow#78). I grafici qui sotto non hanno formule:
# si passa a Kaleido un file locale vuoto al posto del CDN. Il file sta nella cartella temporanea.
def _mathjax_vuoto() -> str:
"""Percorso di un .js vuoto da usare come MathJax: nessuna richiesta di rete per i grafici."""
path = Path(tempfile.gettempdir()) / "tmf-mathjax-vuoto.js"
if not path.exists():
path.write_text("/* MathJax non usato: grafici senza LaTeX */", encoding="utf-8")
return str(path)
try:
pio.defaults.mathjax = _mathjax_vuoto()
except (AttributeError, OSError): # plotly vecchio senza pio.defaults o temp non scrivibile: resta il CDN
pass
# Jinja2 environment for report templates
_templates_dir = Path(__file__).parent.parent / "templates" / "reports"
_jinja_env = Environment(
+164
View File
@@ -0,0 +1,164 @@
"""Report PDF veri su Windows (WeasyPrint + DLL GTK + Kaleido con Edge/Chrome), offline.
Pacchetto Windows (TieMeasureFlow#78): senza Internet i report devono uscire lo stesso.
Il test gira solo su Windows, con le DLL di packaging/windows/pdf/prendi-gtk.ps1 e un browser
Chromium (Edge o Chrome) installati; altrimenti viene saltato.
Il conftest dei test sostituisce WeasyPrint con un mock: qui serve quello vero, quindi la
generazione gira in un sottoprocesso che importa weasyprint PRIMA del conftest (il mock scatta
solo se weasyprint non e' gia' in sys.modules).
Come preparare le DLL: powershell -File packaging/windows/pdf/prendi-gtk.ps1 -Destinazione packaging/windows/pdf/gtk
(oppure WEASYPRINT_DLL_DIRECTORIES gia' impostata su una cartella con le DLL).
"""
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[3]
GTK_DEFAULT = REPO / "packaging" / "windows" / "pdf" / "gtk"
# Browser Chromium cercati in ordine (Edge e' presente su ogni Windows 10/11).
_BROWSER_CANDIDATI = [
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
]
def _cartella_gtk() -> Path | None:
"""Cartella DLL: WEASYPRINT_DLL_DIRECTORIES (primo elemento) o quella di prendi-gtk.ps1."""
env = os.environ.get("WEASYPRINT_DLL_DIRECTORIES", "").split(";")[0]
for c in (Path(env) if env else None, GTK_DEFAULT):
if c and (c / "libpango-1.0-0.dll").exists():
return c
return None
def _browser() -> str | None:
env = os.environ.get("BROWSER_PATH")
if env and Path(env).exists():
return env
return next((b for b in _BROWSER_CANDIDATI if Path(b).exists()), None)
def _tz_ok() -> bool:
"""Windows non ha il database IANA: serve il pacchetto tzdata (to_local usa ZoneInfo)."""
try:
from zoneinfo import ZoneInfo
ZoneInfo("Europe/Rome")
return True
except Exception:
return False
pytestmark = [
pytest.mark.skipif(sys.platform != "win32", reason="solo Windows"),
pytest.mark.skipif(_cartella_gtk() is None, reason="DLL GTK assenti: lancia prendi-gtk.ps1"),
pytest.mark.skipif(_browser() is None, reason="nessun Edge/Chrome installato"),
# Senza tzdata i report falliscono su Windows (ZoneInfoNotFoundError): il pacchetto deve dichiararlo.
pytest.mark.skipif(not _tz_ok(), reason="manca tzdata: aggiungere 'tzdata; sys_platform == \"win32\"' a pyproject"),
]
# Sottoprocesso: dati veri nel DB SQLite in memoria del conftest, poi i due report reali.
_WORKER = r"""
import asyncio, json, sys
sys.path.insert(0, sys.argv[1])
import weasyprint # VERO, prima del conftest (che altrimenti lo sostituisce con un mock)
assert not type(weasyprint.HTML).__module__.startswith("unittest"), "WeasyPrint mockato"
from datetime import datetime, timedelta
from src.backend.tests.conftest import Base, TestSessionFactory, test_engine, _create_user, create_test_recipe
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.task import RecipeSubtask
from src.backend.services import report_service
from sqlalchemy import select
VALORI = [10.02, 9.98, 10.05, 10.11, 9.94, 10.0, 10.07, 9.96, 10.03, 10.09, 9.99, 10.04, 10.38, 10.01, 9.97]
async def main():
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with TestSessionFactory() as db:
user = await _create_user(db, "metrologo", roles=["Metrologist"])
recipe = await create_test_recipe(db, user.id)
version = recipe.versions[0]
sub = (await db.execute(select(RecipeSubtask))).scalars().first()
t0 = datetime.utcnow() - timedelta(hours=1)
for i, v in enumerate(VALORI):
pf = "warning" if v > 10.3 else "pass"
db.add(Measurement(subtask_id=sub.id, version_id=version.id, measured_by=user.id,
value=v, pass_fail=pf, measured_at=t0 + timedelta(minutes=i)))
await db.commit()
spc = await report_service.generate_spc_report(db, recipe.id, sub.id)
mis = await report_service.generate_measurement_report(db, recipe.id)
open(sys.argv[2] + "/spc.pdf", "wb").write(spc)
open(sys.argv[2] + "/misure.pdf", "wb").write(mis)
# MathJax locale: senza, Kaleido chiede lo script al CDN e senza Internet ogni grafico attende ~19 s.
from pathlib import Path
mj = report_service.pio.defaults.mathjax
print(json.dumps({"ok": True, "mathjax_locale": bool(mj) and Path(mj).is_file()}))
asyncio.run(main())
"""
@pytest.fixture(scope="module")
def pdf_generati(tmp_path_factory):
"""Genera i due PDF in un sottoprocesso con proxy finto: se serve Internet, fallisce."""
out = tmp_path_factory.mktemp("report_win")
env = dict(os.environ)
env.update(
WEASYPRINT_DLL_DIRECTORIES=str(_cartella_gtk()),
BROWSER_PATH=_browser(),
# Proxy morto per la parte Python (Chrome/Edge usano invece il proxy di sistema: vedi LEGGIMI.md).
HTTP_PROXY="http://127.0.0.1:9", HTTPS_PROXY="http://127.0.0.1:9",
PYTHONIOENCODING="utf-8",
)
# Il sottoprocesso non deve leggere il .env di sviluppo ne' un DB reale.
env.pop("DB_URL", None)
res = subprocess.run(
[sys.executable, "-W", "ignore", "-c", _WORKER, str(REPO), str(out)],
cwd=REPO, env=env, capture_output=True, text=True, timeout=180,
)
assert res.returncode == 0, f"sottoprocesso fallito:\n{res.stdout}\n{res.stderr}"
esito = json.loads(res.stdout.strip().splitlines()[-1])
assert esito["ok"] is True
assert esito["mathjax_locale"], "Kaleido userebbe MathJax dal CDN: niente rete = report lenti"
return out
def _pdf(path: Path):
import pdfplumber
return pdfplumber.open(path)
def test_spc_ha_carta_di_controllo_e_istogramma(pdf_generati):
"""Il report SPC e' un PDF vero con una pagina almeno e i due grafici disegnati."""
path = pdf_generati / "spc.pdf"
assert path.read_bytes()[:5] == b"%PDF-"
with _pdf(path) as pdf:
assert len(pdf.pages) >= 1
testo = "\n".join(p.extract_text() or "" for p in pdf.pages)
# I titoli stanno dentro l'SVG di Plotly: se ci sono, Kaleido ha reso e WeasyPrint ha disegnato.
assert "Control Chart" in testo
assert "Histogram" in testo
# Grafico presente = segni vettoriali (tracce, assi, barre) oltre al testo.
segni = sum(len(p.curves) + len(p.lines) + len(p.rects) for p in pdf.pages)
assert segni > 40, f"troppo pochi segni vettoriali ({segni}): grafico assente?"
def test_report_misure_e_pdf_vero(pdf_generati):
"""Anche il report a tabella (solo WeasyPrint, niente Kaleido) esce con le righe misurate."""
path = pdf_generati / "misure.pdf"
assert path.read_bytes()[:5] == b"%PDF-"
with _pdf(path) as pdf:
assert len(pdf.pages) >= 1
testo = "\n".join(p.extract_text() or "" for p in pdf.pages)
assert "10.3800" in testo # il valore anomalo e' in tabella
+81
View File
@@ -0,0 +1,81 @@
"""init-db / reset-db con _stamp_head REALE su un file SQLite (TieMeasureFlow#80).
_stamp_head esegue migrations/env.py, che chiama asyncio.run(): dentro il loop
gia' attivo di FastAPI dava RuntimeError -> 500. test_setup.py e
test_station_seed.py sostituiscono _stamp_head con un no-op e non lo vedevano:
qui NON si fa monkeypatch di _stamp_head.
"""
import logging.config
import sqlite3
import pytest
from alembic.config import Config
from alembic.script import ScriptDirectory
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from src.backend.config import settings
from src.backend.main import app
SETUP_PWD = "test-setup-password"
@pytest.fixture
def file_db(tmp_path, monkeypatch):
"""Punta settings, engine e session factory del router setup a un file SQLite."""
import src.backend.api.routers.setup as setup_mod
db_file = tmp_path / "t.db"
url = f"sqlite+aiosqlite:///{db_file.as_posix()}"
# env.py ricava sqlalchemy.url da settings.database_url (property su db_url)
monkeypatch.setattr(settings, "db_url", url)
monkeypatch.setattr(settings, "setup_password", SETUP_PWD)
file_engine = create_async_engine(url, echo=False)
monkeypatch.setattr(setup_mod, "engine", file_engine)
monkeypatch.setattr(
setup_mod, "async_session_factory",
async_sessionmaker(file_engine, expire_on_commit=False),
)
# env.py chiama fileConfig, che disattiverebbe i logger degli altri test
monkeypatch.setattr(logging.config, "fileConfig", lambda *a, **k: None)
return db_file, setup_mod, file_engine
def _head() -> str:
from src.backend.api.routers.setup import _alembic_ini
cfg = Config(str(_alembic_ini))
return ScriptDirectory.from_config(cfg).get_current_head()
def _versions(db_file) -> list[str]:
con = sqlite3.connect(db_file)
try:
return [r[0] for r in con.execute("SELECT version_num FROM alembic_version")]
finally:
con.close()
@pytest.fixture
async def http():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac:
yield ac
@pytest.mark.asyncio
async def test_init_db_stamps_head(file_db, http):
db_file, _, file_engine = file_db
resp = await http.post("/api/setup/init-db", json={"password": SETUP_PWD})
assert resp.status_code == 200, resp.text
assert _versions(db_file) == [_head()]
await file_engine.dispose()
@pytest.mark.asyncio
async def test_reset_db_stamps_head(file_db, http):
db_file, _, file_engine = file_db
resp = await http.post("/api/setup/init-db", json={"password": SETUP_PWD})
assert resp.status_code == 200, resp.text
resp = await http.post("/api/setup/reset-db", json={"password": SETUP_PWD, "confirm": True})
assert resp.status_code == 200, resp.text
assert _versions(db_file) == [_head()]
await file_engine.dispose()
+144
View File
@@ -0,0 +1,144 @@
"""Backend su un file SQLite locale (pacchetto Windows, TieMeasureFlow#77).
Il modulo `database` costruisce l'engine all'import leggendo l'ambiente, quindi
i test che riguardano DB_URL girano in un sottoprocesso con DB_URL/UPLOAD_DIR
impostati: e' lo stesso percorso di avvio.py nel pacchetto, senza toccare
l'engine in memoria usato dal resto della suite.
"""
import json
import os
import subprocess
import sys
import textwrap
from pathlib import Path
from src.backend.config import Settings
_ROOT = Path(__file__).resolve().parents[3]
# Script eseguito nel sottoprocesso: upgrade head da DB vuoto, poi app reale
# (health, login con utente creato via ORM), PRAGMA e vincolo FK.
_SCRIPT = textwrap.dedent(
"""
import asyncio, json, sys
from pathlib import Path
from unittest.mock import MagicMock
# WeasyPrint senza GTK: stesso mock di conftest.py (le DLL sono affare del piano B)
sys.modules.setdefault("weasyprint", MagicMock())
from alembic import command
from alembic.config import Config
from src.backend.config import settings
ini = Path(r"{ini}")
command.upgrade(Config(str(ini)), "head")
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select, text
from sqlalchemy.exc import IntegrityError
from src.backend.database import async_session_factory, engine
from src.backend.main import app
from src.backend.models.orm.user import User
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.services.auth_service import hash_password
async def main():
out = {{}}
async with async_session_factory() as s:
s.add(User(username="fiera", password_hash=hash_password("Pass12345"),
display_name="Fiera", roles=["Maker"], is_admin=True))
await s.commit()
async with async_session_factory() as s:
out["utente"] = (await s.execute(select(User.username))).scalars().all()
out["fk"] = (await s.execute(text("PRAGMA foreign_keys"))).scalar()
out["journal"] = (await s.execute(text("PRAGMA journal_mode"))).scalar()
out["busy"] = (await s.execute(text("PRAGMA busy_timeout"))).scalar()
# FK attive: una versione con recipe_id inesistente deve essere rifiutata
try:
async with async_session_factory() as s:
s.add(RecipeVersion(recipe_id=99999, version_number=1, created_by=1))
await s.commit()
out["fk_violation"] = False
except IntegrityError:
out["fk_violation"] = True
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
out["health"] = (await c.get("/api/health")).status_code
r = await c.post("/api/auth/login", json={{"username": "fiera", "password": "Pass12345"}})
out["login"] = r.status_code
out["upload_path"] = str(settings.upload_path)
out["database_url"] = settings.database_url
await engine.dispose()
print("RISULTATO=" + json.dumps(out))
asyncio.run(main())
"""
)
def _run(tmp_path: Path, extra_env: dict[str, str]) -> dict:
ini = _ROOT / "src" / "backend" / "migrations" / "alembic.ini"
env = {k: v for k, v in os.environ.items() if k not in ("DB_URL", "UPLOAD_DIR")}
env.update(extra_env)
env["PYTHONPATH"] = str(_ROOT)
env["PYTHONIOENCODING"] = "utf-8"
proc = subprocess.run(
[sys.executable, "-c", _SCRIPT.format(ini=str(ini))],
cwd=str(_ROOT), env=env, capture_output=True, text=True, timeout=180,
)
assert proc.returncode == 0, proc.stderr[-3000:]
line = [l for l in proc.stdout.splitlines() if l.startswith("RISULTATO=")][-1]
return json.loads(line[len("RISULTATO="):])
def test_file_sqlite_upgrade_app_pragma_e_upload_assoluto(tmp_path):
db = tmp_path / "tmf.db"
uploads = tmp_path / "uploads"
# barre "/" nell'URL come nel pacchetto Windows (D:/... non va in escape)
url = f"sqlite+aiosqlite:///{db.as_posix()}"
out = _run(tmp_path, {"DB_URL": url, "UPLOAD_DIR": str(uploads)})
assert out["database_url"] == url
assert db.exists() # il file e' stato creato da upgrade head
assert out["utente"] == ["fiera"]
assert out["health"] == 200
assert out["login"] == 200
assert out["fk"] == 1 # PRAGMA foreign_keys attivo
assert out["fk_violation"] is True
assert out["journal"] == "wal"
assert out["busy"] == 5000
# UPLOAD_DIR assoluto rispettato (non accodato alla radice del progetto)
assert Path(out["upload_path"]) == uploads
def test_database_url_senza_db_url_resta_mysql(monkeypatch):
monkeypatch.delenv("DB_URL", raising=False)
s = Settings(_env_file=None, db_url=None)
assert s.database_url.startswith("mysql+asyncmy://")
def test_db_url_vince_sulle_parti_db(monkeypatch):
s = Settings(_env_file=None, db_url="sqlite+aiosqlite:///C:/x/tmf.db", db_host="altro")
assert s.database_url == "sqlite+aiosqlite:///C:/x/tmf.db"
def test_produzione_con_sqlite_non_richiede_db_password():
# niente RuntimeError: con SQLite la password DB di default non conta
Settings(
_env_file=None, app_env="production", server_secret_key="x" * 40,
db_url="sqlite+aiosqlite:///C:/x/tmf.db",
)
def test_produzione_mysql_rifiuta_ancora_password_di_default():
import pytest
with pytest.raises(Exception, match="DB_PASSWORD"):
Settings(_env_file=None, app_env="production", server_secret_key="x" * 40)
def test_upload_dir_relativo_resta_sotto_la_radice():
s = Settings(_env_file=None, upload_dir="uploads")
assert s.upload_path == _ROOT / "uploads"
def test_upload_dir_assoluto(tmp_path):
s = Settings(_env_file=None, upload_dir=str(tmp_path / "u"))
assert s.upload_path == tmp_path / "u"
+170 -1
View File
@@ -4,7 +4,9 @@ import math
import requests as http_requests
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
from functools import wraps
from flask import Blueprint, Response, abort, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _
from blueprints.auth import login_required, role_required
@@ -483,6 +485,173 @@ def api_upload_vision_recipe(task_id: int):
return jsonify(resp), 200
# ============================================================================
# RICETTA DXF DA CAMERA (#83): stazioni, camera, calibrazione, creazione ricetta
# ============================================================================
def maker_or_admin_required(f):
"""Maker dell'editor oppure admin della pagina stazioni (#83: stesse rotte camera)."""
@wraps(f)
def decorated(*args, **kwargs):
user = session.get("user", {})
if not (user.get("is_admin") or "Maker" in user.get("roles", [])):
abort(403)
return f(*args, **kwargs)
return decorated
def _stations_for_maker() -> list[dict]:
"""Stazioni visibili al chiamante.
GET /api/stations e' solo admin: un Maker non admin ripiega sulla stazione
corrente (by-code, aperta a tutti), cosi' l'editor funziona comunque.
"""
resp = api_client.get("/api/stations")
if isinstance(resp, list):
return resp
if Config.STATION_CODE:
one = api_client.get_station_by_code(Config.STATION_CODE)
if isinstance(one, dict) and not one.get("error"):
return [one]
return []
def _camera_of_station(station_id: int) -> tuple[str, str] | None:
"""(camera_url, camera_source) della stazione con questo id, None se senza camera."""
for st in _stations_for_maker():
if st.get("id") == station_id:
if st.get("camera_url") and st.get("camera_source"):
return str(st["camera_url"]), str(st["camera_source"])
return None
return None
@maker_bp.route("/api/stations", methods=["GET"])
@login_required
@maker_or_admin_required
def api_list_stations_for_maker():
"""Elenco stazioni con camera per il pannello «Crea ricetta da DXF»."""
out = []
for st in _stations_for_maker():
if st.get("camera_url") and st.get("camera_source"):
out.append({
"id": st.get("id"), "code": st.get("code"), "name": st.get("name"),
"camera_url": st.get("camera_url"),
})
return jsonify(out), 200
# Copia minima della logica di measure.api_vision_grab, ma per una stazione scelta per id
@maker_bp.route("/api/stations/<int:station_id>/camera/grab", methods=["GET", "POST"])
@login_required
@maker_or_admin_required
def api_station_camera_grab(station_id: int):
"""Proxy: un frame dalla camera della stazione, risposta PNG."""
camera = _camera_of_station(station_id)
if camera is None:
return jsonify({
"error": True,
"detail": _("Camera non configurata per questa stazione"),
}), 409
camera_url, camera_source = camera
try:
resp = http_requests.post(
f"{camera_url.rstrip('/')}/camera/grab",
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
timeout=15,
)
except http_requests.RequestException as exc:
return jsonify({
"error": True,
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
}), 502
if resp.status_code != 200:
detail = ""
try:
body = resp.json()
detail = body.get("detail", "") if isinstance(body, dict) else ""
except ValueError:
detail = (resp.text or "")[:200]
return jsonify({
"error": True,
"detail": _("Acquisizione non riuscita (%(status)s): %(detail)s",
status=resp.status_code, detail=detail),
}), 502
return Response(resp.content, content_type="image/png")
@maker_bp.route("/api/stations/<int:station_id>/camera/calibration", methods=["GET"])
@login_required
@maker_or_admin_required
def api_station_camera_calibration(station_id: int):
"""Proxy: calibrazione della camera letta dall'acquisitore (C1).
Risponde sempre 200 con `state`: calibrated | uncalibrated | unreachable,
cosi' la UI mostra lo stato senza trattare «non calibrata» come errore.
409 solo se la stazione non ha camera.
"""
camera = _camera_of_station(station_id)
if camera is None:
return jsonify({
"error": True,
"detail": _("Camera non configurata per questa stazione"),
}), 409
camera_url, camera_source = camera
base = camera_url.rstrip("/")
link = f"{base}/calibrazione" # pagina di calibrazione dell'acquisitore (C4)
try:
resp = http_requests.get(
f"{base}/camera/calibration",
params={"source": camera_source},
timeout=10,
)
except http_requests.RequestException as exc:
return jsonify({"state": "unreachable", "detail": str(exc), "calibration_url": link}), 200
if resp.status_code == 404:
return jsonify({"state": "uncalibrated", "calibration_url": link}), 200
if resp.status_code != 200:
return jsonify({
"state": "unreachable", "calibration_url": link,
"detail": _("Risposta %(status)s dall'acquisitore", status=resp.status_code),
}), 200
try:
calibration = resp.json()
except ValueError:
return jsonify({"state": "unreachable", "calibration_url": link,
"detail": _("Risposta non valida dall'acquisitore")}), 200
return jsonify({"state": "calibrated", "calibration": calibration, "calibration_url": link}), 200
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/dxf-compare", methods=["POST"])
@login_required
@role_required("Maker")
def api_dxf_compare_recipe(task_id: int):
"""Proxy: crea (o prova, save=false) la ricetta DXF compare da camera (B3)."""
image = request.files.get("image")
dxf = request.files.get("dxf")
if image is None or not image.filename or dxf is None or not dxf.filename:
return jsonify({"error": True, "detail": _("Servono l'immagine e il file DXF")}), 400
# Solo i campi del contratto B3; i vuoti non si inoltrano (il backend ha i default)
data = {}
for key in ("station_id", "px_per_mm", "band_mm", "min_points_pct", "polarity", "save"):
value = request.form.get(key)
if value not in (None, ""):
data[key] = value
resp = api_client.post(
f"/api/vision/tasks/{task_id}/recipe/dxf-compare",
data=data,
files={
"image": (image.filename, image.read(), image.mimetype or "application/octet-stream"),
"dxf": (dxf.filename, dxf.read(), dxf.mimetype or "application/octet-stream"),
},
)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
@login_required
def api_get_file(file_path: str):
@@ -0,0 +1,181 @@
// «Crea ricetta da DXF» dell'editor task (#83): da un'immagine della camera e un DXF
// genera la ricetta dxf_compare (POST /maker/api/vision/tasks/<id>/recipe/dxf-compare).
// «Prova» (save=false) mostra overlay e quattro valori, «Salva ricetta» (save=true)
// la scrive e avvisa il componente visionRecipe con l'evento 'dxf-recipe-saved'.
// Uso: x-data="dxfRecipeWizard(task.id, csrfFn, msgs)". Dipende da Alpine.js e station_calibration.js.
function dxfRecipeWizard(taskId, csrf, msgs) {
// Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found)
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
const VALUE_KEYS = ['dxf.points_in_pct', 'dxf.distance_avg', 'dxf.distance_min', 'dxf.distance_max'];
return {
taskId: taskId,
msgs: msgs || {},
open: false,
stations: [],
stationId: '',
calState: '', // '' | calibrated | uncalibrated | unreachable
calLabel: '',
calUrl: '',
imageFile: null, // immagine caricata o acquisita
imageName: '',
imageSrc: '',
dxfFile: null,
bandMm: 0.1,
minPointsPct: 95,
dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce)
busy: false,
grabbing: false,
error: '',
notice: '',
result: null, // corpo B3 {result, saved, recipe_dir, outputs}
async toggle() {
this.open = !this.open;
if (this.open && !this.stations.length) await this.loadStations();
},
async loadStations() {
try {
const resp = await fetch('/maker/api/stations');
if (resp.ok) this.stations = await resp.json();
} catch (e) { console.error('dxfRecipeWizard stations', e); }
// Una sola stazione con camera: preselezionata
if (this.stations.length === 1) {
this.stationId = String(this.stations[0].id);
await this.onStation();
}
},
// Cambio stazione: rilegge lo stato di calibrazione dall'acquisitore
async onStation() {
this.calState = ''; this.calLabel = ''; this.calUrl = '';
this.result = null;
if (!this.stationId) return;
const r = await fetchStationCalibration(this.stationId);
this.calState = r.state || 'unreachable';
this.calUrl = r.calibration_url || '';
if (this.calState === 'calibrated') this.calLabel = formatCalibration(r.calibration || {});
},
get calibrated() { return this.calState === 'calibrated'; },
get canRun() {
return !!(this.stationId && this.calibrated && this.imageFile && this.dxfFile) && !this.busy;
},
_setImage(file) {
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
this.imageFile = file;
this.imageName = file.name || '';
this.imageSrc = URL.createObjectURL(file);
this.result = null;
},
onImage(ev) {
const f = ev.target.files && ev.target.files[0];
if (f) this._setImage(f);
},
onDxf(ev) { this.dxfFile = (ev.target.files && ev.target.files[0]) || null; this.result = null; },
// Frame dalla camera della stazione scelta
async grab() {
if (!this.stationId || this.grabbing) return;
this.grabbing = true;
this.error = '';
try {
const resp = await fetch(`/maker/api/stations/${this.stationId}/camera/grab`, {
method: 'POST', headers: { 'X-CSRFToken': this._csrf() },
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
this.error = body.detail || this.msgs.requestFailed || 'Error';
} else {
const blob = await resp.blob();
this._setImage(new File([blob], 'camera.png', { type: blob.type || 'image/png' }));
}
} catch (e) {
this.error = this.msgs.requestFailed || String(e);
}
this.grabbing = false;
},
_csrf() { return typeof csrf === 'function' ? csrf() : csrf; },
// Il 422 porta `detail` (stringa o lista) oppure `errors`
_errorText(body) {
if (Array.isArray(body.errors) && body.errors.length) return body.errors.map(String).join('; ');
const d = body.detail;
if (Array.isArray(d)) return d.map(x => (typeof x === 'string' ? x : (x.msg || JSON.stringify(x)))).join('; ');
if (d && typeof d === 'object') return JSON.stringify(d);
return d || this.msgs.requestFailed || 'Error';
},
async run(save) {
if (!this.canRun) return;
this.busy = true;
this.error = '';
this.notice = '';
try {
const fd = new FormData();
fd.append('image', this.imageFile, this.imageFile.name || 'capture.png');
fd.append('dxf', this.dxfFile, this.dxfFile.name);
fd.append('station_id', this.stationId);
fd.append('band_mm', String(this.bandMm));
fd.append('min_points_pct', String(this.minPointsPct));
fd.append('polarity', this.dark ? 'dark' : 'light');
fd.append('save', save ? 'true' : 'false');
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/dxf-compare`, {
method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, body: fd,
});
const body = await resp.json().catch(() => ({}));
if (!resp.ok || body.error) {
this.error = this._errorText(body);
} else {
this.result = body;
this.$nextTick(() => this.drawOverlay());
if (save && body.saved) {
this.notice = this.msgs.saved || 'Saved';
// Il componente visionRecipe ricarica cartella, file e uscite delle quote
this.$dispatch('dxf-recipe-saved', { taskId: this.taskId });
}
}
} catch (e) {
this.error = this.msgs.requestFailed || String(e);
}
this.busy = false;
},
// Le quattro uscite del confronto, nell'ordine fisso
get values() {
const out = (this.result && this.result.result && this.result.result.outputs) || {};
return VALUE_KEYS.map(k => ({ key: k, value: out[k] }));
},
fmt(v) {
return (typeof v === 'number') ? v.toLocaleString(document.documentElement.lang || 'it',
{ maximumFractionDigits: 3 }) : '-';
},
// Come vision_execute.js: canvas della dimensione dell'immagine, coordinate in pixel immagine
drawOverlay() {
const img = this.$refs.img, cv = this.$refs.overlay;
if (!img || !cv || !img.naturalWidth) return;
cv.width = img.naturalWidth;
cv.height = img.naturalHeight;
const g = cv.getContext('2d');
g.clearRect(0, 0, cv.width, cv.height);
g.lineWidth = Math.max(2, cv.width / 400);
g.lineJoin = 'round';
const shapes = (this.result && this.result.result && this.result.result.overlay) || [];
for (const item of shapes) {
const rows = item.rows || [], cols = item.cols || [];
if (!rows.length || rows.length !== cols.length) continue;
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
g.beginPath();
g.moveTo(cols[0], rows[0]);
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
g.stroke();
}
},
};
}
@@ -0,0 +1,50 @@
// Stato di calibrazione della camera di una stazione (#83).
// La calibrazione vive nell'acquisitore (camera_service): qui si legge solo lo stato
// tramite il proxy Flask e si offre il link alla sua pagina «Calibra».
// Uso: x-data="stationCalibrationCell(station, msgs)" (msgs: stringhe tradotte). Dipende da Alpine.js.
// Formatta px/mm e data nella lingua della pagina (es. «20,03 px/mm · 23/10/2026»)
function formatCalibration(cal) {
const lang = document.documentElement.lang || 'it';
let text = new Intl.NumberFormat(lang, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
.format(Number(cal.px_per_mm)) + ' px/mm';
if (cal.calibrated_at) {
const d = new Date(cal.calibrated_at);
if (!isNaN(d)) text += ' · ' + d.toLocaleDateString(lang);
}
return text;
}
// Legge lo stato dal proxy: ritorna {state, calibration, calibration_url, detail}
async function fetchStationCalibration(stationId) {
try {
const resp = await fetch(`/maker/api/stations/${stationId}/camera/calibration`);
const body = await resp.json().catch(() => ({}));
if (!resp.ok) return { state: 'unreachable', detail: body.detail || '' };
return body;
} catch (e) {
return { state: 'unreachable', detail: String(e) };
}
}
function stationCalibrationCell(station, msgs) {
return {
hasCamera: !!(station.camera_url && station.camera_source),
cameraUrl: station.camera_url || '',
state: '', // '' = in lettura | calibrated | uncalibrated | unreachable
label: '',
calibrationUrl: '',
msgs: msgs || {},
async init() {
if (!this.hasCamera) return;
const r = await fetchStationCalibration(station.id);
this.state = r.state || 'unreachable';
// Link «Calibra»: dal proxy, altrimenti costruito dall'indirizzo della camera
this.calibrationUrl = r.calibration_url || (this.cameraUrl.replace(/\/+$/, '') + '/calibrazione');
if (this.state === 'calibrated') this.label = formatCalibration(r.calibration || {});
else if (this.state === 'uncalibrated') this.label = this.msgs.uncalibrated || 'not calibrated';
else this.label = this.msgs.unreachable || 'camera service unreachable';
},
};
}
@@ -50,6 +50,7 @@
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Nome') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Postazione') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden lg:table-cell">{{ _('Ricette collegate') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Calibrazione') }}</th>
<th class="text-center px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Stato') }}</th>
<th class="text-right px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Azioni') }}</th>
</tr>
@@ -81,6 +82,25 @@
</div>
</template>
</td>
{# Calibrazione (#83): stato letto dall'acquisitore della camera, link alla sua pagina «Calibra» #}
<td class="px-4 py-3 hidden md:table-cell" @click.stop
x-data="stationCalibrationCell(station, {{ {'uncalibrated': _('non calibrata'), 'unreachable': _('acquisitore non raggiungibile')}|tojson_attr }})">
<template x-if="!hasCamera">
<span class="text-xs italic text-[var(--text-secondary)]">{{ _('Nessuna camera') }}</span>
</template>
<template x-if="hasCamera">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span :class="state === 'calibrated' ? 'text-emerald-700 dark:text-emerald-300'
: (state === 'uncalibrated' || state === 'unreachable') ? 'text-amber-700 dark:text-amber-300'
: 'text-[var(--text-secondary)]'">
<span x-show="state === 'calibrated'">{{ _('Calibrazione') }}: </span><span x-text="state ? label : '...'"></span>
</span>
<a :href="calibrationUrl" target="_blank" rel="noopener"
x-show="calibrationUrl"
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
</div>
</template>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
:class="station.active
@@ -459,6 +479,7 @@
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
<script>
function stationManagement(initialStations, initialRecipes) {
return {
@@ -550,7 +550,8 @@
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})">
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})"
@dxf-recipe-saved="init()">{# #83: dopo «Salva ricetta» da DXF si rilegge la ricetta #}
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
{{ _('Ricetta visione') }}
</p>
@@ -593,6 +594,93 @@
<ul x-show="errors.length" class="mt-3 text-xs text-red-500 list-disc pl-5">
<template x-for="(e, i) in errors" :key="i"><li x-text="e"></li></template>
</ul>
<!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== -->
<template x-if="task.task_type === 'dxf_compare'">
<div class="mt-4 pt-3 border-t border-[var(--border-color)]"
x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata')}|tojson_attr }})">
<button type="button" class="btn btn-secondary text-xs" @click="toggle()">
{{ _('Crea ricetta da DXF') }}
</button>
<div x-show="open" x-cloak class="mt-3 space-y-3">
<!-- Stazione -->
<div>
<label class="tmf-label text-xs">{{ _('Stazione') }}</label>
<select class="tmf-input text-xs" x-model="stationId" @change="onStation()">
<option value="">{{ _('Scegli la stazione') }}</option>
<template x-for="st in stations" :key="st.id">
<option :value="String(st.id)" x-text="st.code + ' - ' + st.name"></option>
</template>
</select>
<p x-show="!stations.length" class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Nessuna stazione con camera configurata') }}</p>
<p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600">
{{ _('Calibrazione') }}: <span x-text="calLabel"></span>
</p>
<p x-show="stationId && calState && !calibrated" class="mt-1 text-xs text-amber-600">
<span x-text="calState === 'uncalibrated' ? {{ _('Stazione non calibrata: calibrala prima di creare la ricetta.')|tojson_attr }} : {{ _('Acquisitore non raggiungibile: impossibile leggere la calibrazione.')|tojson_attr }}"></span>
<a :href="calUrl" target="_blank" rel="noopener" x-show="calUrl"
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
<span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span>
</p>
</div>
<!-- Immagine: camera o file -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="tmf-label text-xs">{{ _('Immagine del pezzo') }}</label>
<div class="flex flex-wrap items-center gap-2">
<button type="button" class="btn btn-secondary text-xs" @click="grab()"
:disabled="!stationId || grabbing">{{ _('Acquisisci dalla camera') }}</button>
<label class="btn btn-secondary text-xs cursor-pointer">
{{ _('Carica immagine') }}
<input type="file" accept="image/*" class="hidden" @change="onImage($event)">
</label>
</div>
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="imageName"></p>
</div>
<div>
<label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label>
<input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)">
</div>
</div>
<!-- Pochi parametri -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="tmf-label text-xs">{{ _('Banda ± mm') }}</label>
<input type="number" step="0.01" min="0.001" class="tmf-input text-xs" x-model.number="bandMm">
</div>
<div>
<label class="tmf-label text-xs">{{ _('Soglia punti in banda %%') }}</label>
<input type="number" step="1" min="1" max="100" class="tmf-input text-xs" x-model.number="minPointsPct">
</div>
</div>
<label class="flex items-center gap-2 text-xs">
<input type="checkbox" x-model="dark">
{{ _('Pezzo scuro su fondo chiaro (controluce)') }}
</label>
<!-- Azioni -->
<div class="flex justify-end gap-2">
<button type="button" class="btn btn-secondary text-xs" @click="run(false)"
:disabled="!canRun">{{ _('Prova') }}</button>
<button type="button" class="btn btn-primary text-xs" @click="run(true)"
:disabled="!canRun || !result">{{ _('Salva ricetta') }}</button>
</div>
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
<p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p>
<!-- Anteprima con overlay e quattro valori -->
<div x-show="imageSrc" class="relative">
<img x-ref="img" :src="imageSrc" alt="" class="block w-full rounded border border-[var(--border-color)]"
@load="drawOverlay()">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
</div>
<table x-show="result" class="tmf-table text-xs">
<tbody>
<template x-for="v in values" :key="v.key">
<tr><td class="font-mono" x-text="v.key"></td><td class="text-right" x-text="fmt(v.value)"></td></tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
</div>
</template>
@@ -1245,6 +1333,8 @@
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
<script src="{{ url_for('static', filename='js/dxf_recipe_wizard.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
@@ -0,0 +1,232 @@
"""gitea:Adriano/TieMeasureFlow#83 - ricetta DXF compare da camera: rotte Flask nuove.
api_client e requests sono finti: backend (B3) e acquisitore (C1) rispondono
come da contratto del piano.
"""
import io
from unittest.mock import MagicMock, patch
import requests
from tests.conftest import api_get_router
STATION = {
"id": 4, "code": "ST-4", "name": "Banco",
"camera_url": "http://acq:8765/", "camera_source": "camera:folder",
}
NO_CAMERA = {"id": 5, "code": "ST-5", "name": "Senza", "camera_url": None, "camera_source": None}
PNG = b"\x89PNG\r\n\x1a\n fake"
CAL = {
"source": "camera:folder", "px_per_mm": 20.03, "mm_per_px": 0.0499, "rms_px": 0.2,
"n_corners": 54, "cols": 9, "rows": 6, "square_mm": 5.0,
"image_width": 2448, "image_height": 2048, "scale_spread_pct": 0.3,
"calibrated_at": "2026-10-23T10:00:00",
}
def _fake(status=200, content=PNG, json_body=None):
resp = MagicMock(status_code=status, content=content, text=content.decode("latin-1"))
if json_body is None:
resp.json.side_effect = ValueError("no json")
else:
resp.json.return_value = json_body
return resp
def _stations(mock_api_client, stations=(STATION, NO_CAMERA)):
mock_api_client.get.side_effect = api_get_router({"/api/stations": list(stations)})
class TestStationList:
def test_only_stations_with_camera(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp = logged_in_client.get("/maker/api/stations")
assert resp.status_code == 200
assert [s["id"] for s in resp.get_json()] == [4]
def test_non_admin_list_falls_back_to_current_station(
self, logged_in_client, mock_api_client, monkeypatch):
# /api/stations e' solo admin: il Maker vede la stazione corrente (by-code)
monkeypatch.setattr("blueprints.maker.Config.STATION_CODE", "ST-4")
mock_api_client.get.side_effect = api_get_router(
{"/api/stations": {"error": True, "status_code": 403, "detail": "no"}})
mock_api_client.get_station_by_code.return_value = STATION
resp = logged_in_client.get("/maker/api/stations")
assert [s["id"] for s in resp.get_json()] == [4]
mock_api_client.get_station_by_code.assert_called_once_with("ST-4")
def test_requires_maker_or_admin(self, logged_in_client, mock_api_client):
with logged_in_client.session_transaction() as sess:
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"], "is_admin": False}
assert logged_in_client.get("/maker/api/stations").status_code == 403
class TestStationCameraGrab:
def test_png_pass_through(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
assert resp.status_code == 200
assert resp.mimetype == "image/png"
assert resp.data == PNG
args, kwargs = post.call_args
assert args[0] == "http://acq:8765/camera/grab" # slash finale tolto
assert kwargs["json"] == {"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000}
def test_get_also_works(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.post", return_value=_fake()):
assert logged_in_client.get("/maker/api/stations/4/camera/grab").status_code == 200
def test_station_without_camera_is_409(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.post") as post:
resp = logged_in_client.post("/maker/api/stations/5/camera/grab")
assert resp.status_code == 409
post.assert_not_called()
def test_unknown_station_is_409(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
assert logged_in_client.post("/maker/api/stations/99/camera/grab").status_code == 409
def test_service_down_is_502(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.post",
side_effect=requests.ConnectionError("refused")):
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
assert resp.status_code == 502
assert resp.get_json()["error"]
def test_service_503_is_502_with_detail(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
fake = _fake(503, b"{}", {"detail": "camera occupata"})
with patch("blueprints.maker.http_requests.post", return_value=fake):
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
assert resp.status_code == 502
assert "camera occupata" in resp.get_json()["detail"]
class TestStationCameraCalibration:
def _get(self, client, fake=None, exc=None):
with patch("blueprints.maker.http_requests.get", return_value=fake,
side_effect=exc) as get:
resp = client.get("/maker/api/stations/4/camera/calibration")
return resp, get
def test_calibrated(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp, get = self._get(logged_in_client, _fake(200, b"{}", CAL))
body = resp.get_json()
assert resp.status_code == 200
assert body["state"] == "calibrated"
assert body["calibration"]["px_per_mm"] == 20.03
assert body["calibration_url"] == "http://acq:8765/calibrazione"
args, kwargs = get.call_args
assert args[0] == "http://acq:8765/camera/calibration"
assert kwargs["params"] == {"source": "camera:folder"}
def test_404_is_uncalibrated(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp, _ = self._get(logged_in_client, _fake(404, b"{}", {"detail": "no"}))
assert resp.status_code == 200
assert resp.get_json()["state"] == "uncalibrated"
assert resp.get_json()["calibration_url"] == "http://acq:8765/calibrazione"
def test_unreachable(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp, _ = self._get(logged_in_client, exc=requests.ConnectionError("down"))
assert resp.status_code == 200
assert resp.get_json()["state"] == "unreachable"
def test_server_error_is_unreachable(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp, _ = self._get(logged_in_client, _fake(500, b"boom"))
assert resp.get_json()["state"] == "unreachable"
def test_station_without_camera_is_409(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.get") as get:
resp = logged_in_client.get("/maker/api/stations/5/camera/calibration")
assert resp.status_code == 409
get.assert_not_called()
class TestDxfCompareProxy:
URL = "/maker/api/vision/tasks/7/recipe/dxf-compare"
OK = {
"result": {"outputs": {"dxf.points_in_pct": 99.1}, "overlay": [], "outcome": "pass"},
"saved": False, "recipe_dir": None, "outputs": None,
}
def _post(self, client, **form):
data = {"image": (io.BytesIO(PNG), "p.png"), "dxf": (io.BytesIO(b"0\nEOF"), "d.dxf"), **form}
return client.post(self.URL, data=data, content_type="multipart/form-data")
def test_forwards_files_and_fields(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = self.OK
resp = self._post(logged_in_client, station_id="4", band_mm="0.1",
min_points_pct="95", polarity="dark", save="false", px_per_mm="")
assert resp.status_code == 200
assert resp.get_json()["result"]["outputs"]["dxf.points_in_pct"] == 99.1
args, kwargs = mock_api_client.post.call_args
assert args[0] == "/api/vision/tasks/7/recipe/dxf-compare"
# campi del contratto B3; px_per_mm vuoto non si inoltra
assert kwargs["data"] == {
"station_id": "4", "band_mm": "0.1", "min_points_pct": "95",
"polarity": "dark", "save": "false",
}
assert set(kwargs["files"]) == {"image", "dxf"}
assert kwargs["files"]["image"][1] == PNG
def test_save_true_forwarded(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {**self.OK, "saved": True, "recipe_dir": "vision/recipes/7/x"}
resp = self._post(logged_in_client, save="true")
assert resp.get_json()["saved"] is True
assert mock_api_client.post.call_args.kwargs["data"]["save"] == "true"
def test_missing_files_is_400(self, logged_in_client, mock_api_client):
resp = logged_in_client.post(self.URL, data={"station_id": "4"},
content_type="multipart/form-data")
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_backend_422_propagates(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {
"error": True, "status_code": 422, "detail": "stazione non calibrata"}
resp = self._post(logged_in_client)
assert resp.status_code == 422
assert resp.get_json()["detail"] == "stazione non calibrata"
def test_requires_maker_role(self, logged_in_client, mock_api_client):
with logged_in_client.session_transaction() as sess:
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"]}
assert self._post(logged_in_client).status_code == 403
mock_api_client.post.assert_not_called()
class TestPages:
def test_stations_page_has_calibration_cell(self, logged_in_client):
with patch("blueprints.admin.api_client") as admin_api:
admin_api.get.side_effect = api_get_router({"/api/stations": [STATION], "/api/recipes": []})
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
assert "js/station_calibration.js" in html
assert "stationCalibrationCell(" in html
# niente piu' pannello di calibrazione nella pagina: solo il link all'acquisitore
assert "calibration-panel" not in html
def _editor(self, client, mock_api_client, task_type):
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
"/api/recipes/3/tasks": [{
"id": 11, "title": "T", "task_type": task_type, "subtasks": [],
"file_path": None, "order_index": 1,
}],
})
return client.get("/maker/recipes/3/tasks").get_data(as_text=True)
def test_editor_has_dxf_wizard(self, logged_in_client, mock_api_client):
html = self._editor(logged_in_client, mock_api_client, "dxf_compare")
assert "js/dxf_recipe_wizard.js" in html
assert "dxfRecipeWizard(" in html
assert "@dxf-recipe-saved" in html
assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html
+16 -18
View File
@@ -12,12 +12,9 @@ class TestSelectRecipe:
def test_select_recipe_renders(self, logged_in_client, mock_api_client, monkeypatch):
"""Recipe selection page renders for MeasurementTec role."""
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import config
import importlib
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
# Patch the class attribute instead of reload()ing config/blueprints.measure:
# monkeypatch undoes it at teardown, so no ST-TEST leaks into later tests (#79).
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = [
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
@@ -47,12 +44,9 @@ class TestSelectRecipe:
import re
from html.parser import HTMLParser
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import config
import importlib
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
# Patch the class attribute instead of reload()ing config/blueprints.measure:
# monkeypatch undoes it at teardown, so no ST-TEST leaks into later tests (#79).
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = []
@@ -79,12 +73,9 @@ class TestSelectRecipe:
self, logged_in_client, mock_api_client, monkeypatch
):
"""#10: a lot number like ``L'23`` must not break the page."""
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import config
import importlib
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
# Patch the class attribute instead of reload()ing config/blueprints.measure:
# monkeypatch undoes it at teardown, so no ST-TEST leaks into later tests (#79).
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = []
@@ -393,6 +384,13 @@ class TestStartSequence:
class TestTaskComplete:
"""GET /measure/complete/<recipe_id> tests."""
@pytest.fixture(autouse=True)
def _station(self, monkeypatch):
# The side_effect lists below include the /production-runs/current reply,
# which is only requested when a station is configured: pin it explicitly
# instead of relying on whatever an earlier test left behind (#79).
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
def test_task_complete_renders(self, logged_in_client, mock_api_client):
"""The summary carries the measurement clock, so it has to render with it."""
mock_api_client.get.side_effect = [
@@ -6,6 +6,17 @@ a start button that will not start, a description that reads the way it was
written.
"""
import pytest
@pytest.fixture(autouse=True)
def _no_station(monkeypatch):
# These tests assume no station is configured (e.g. _task_list feeds the mock a
# fixed number of replies, with no /production-runs/current among them); pin it
# so a STATION_CODE left behind by an earlier test cannot change the call count (#79).
# Tests that need a station set it themselves, after this fixture has run.
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", None)
# ---------------------------------------------------------------------------
# Point 11 - line breaks and bold, and nothing else
@@ -197,13 +208,9 @@ def test_avvia_is_untouched_when_nothing_is_required(
def test_selection_screen_carries_the_traceability_rules(
logged_in_client, mock_api_client, monkeypatch,
):
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import importlib
import config
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
# Patch instead of reload()ing config/blueprints.measure: the patch is undone at
# teardown, a reload would leave ST-TEST behind for later tests (#79).
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = [
{
@@ -3359,3 +3359,96 @@ msgstr "Camera service unreachable: %(detail)s"
#, python-format
msgid "Acquisizione non riuscita (%(status)s): %(detail)s"
msgstr "Capture failed (%(status)s): %(detail)s"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
#, python-format
msgid "Risposta %(status)s dall'acquisitore"
msgstr "Response %(status)s from the camera service"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Risposta non valida dall'acquisitore"
msgstr "Invalid response from the camera service"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Servono l'immagine e il file DXF"
msgstr "Both the image and the DXF file are required"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Calibrazione"
msgstr "Calibration"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "non calibrata"
msgstr "not calibrated"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "acquisitore non raggiungibile"
msgstr "camera service unreachable"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Nessuna camera"
msgstr "No camera"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Calibra"
msgstr "Calibrate"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Ricetta salvata"
msgstr "Recipe saved"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Crea ricetta da DXF"
msgstr "Create recipe from DXF"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Scegli la stazione"
msgstr "Choose the station"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Nessuna stazione con camera configurata"
msgstr "No station with a camera configured"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Stazione non calibrata: calibrala prima di creare la ricetta."
msgstr "Station not calibrated: calibrate it before creating the recipe."
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Acquisitore non raggiungibile: impossibile leggere la calibrazione."
msgstr "Camera service unreachable: cannot read the calibration."
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "oppure vedi la pagina Stazioni"
msgstr "or see the Stations page"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Immagine del pezzo"
msgstr "Part image"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Carica immagine"
msgstr "Upload image"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Disegno (.dxf)"
msgstr "Drawing (.dxf)"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Banda ± mm"
msgstr "Band ± mm"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Soglia punti in banda %%"
msgstr "Points-in-band threshold %%"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Pezzo scuro su fondo chiaro (controluce)"
msgstr "Dark part on light background (backlight)"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Prova"
msgstr "Try"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Salva ricetta"
msgstr "Save recipe"
@@ -3347,3 +3347,96 @@ msgstr "Servizio camera non raggiungibile: %(detail)s"
#, python-format
msgid "Acquisizione non riuscita (%(status)s): %(detail)s"
msgstr "Acquisizione non riuscita (%(status)s): %(detail)s"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
#, python-format
msgid "Risposta %(status)s dall'acquisitore"
msgstr "Risposta %(status)s dall'acquisitore"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Risposta non valida dall'acquisitore"
msgstr "Risposta non valida dall'acquisitore"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Servono l'immagine e il file DXF"
msgstr "Servono l'immagine e il file DXF"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Calibrazione"
msgstr "Calibrazione"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "non calibrata"
msgstr "non calibrata"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "acquisitore non raggiungibile"
msgstr "acquisitore non raggiungibile"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Nessuna camera"
msgstr "Nessuna camera"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Calibra"
msgstr "Calibra"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Ricetta salvata"
msgstr "Ricetta salvata"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Crea ricetta da DXF"
msgstr "Crea ricetta da DXF"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Scegli la stazione"
msgstr "Scegli la stazione"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Nessuna stazione con camera configurata"
msgstr "Nessuna stazione con camera configurata"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Stazione non calibrata: calibrala prima di creare la ricetta."
msgstr "Stazione non calibrata: calibrala prima di creare la ricetta."
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Acquisitore non raggiungibile: impossibile leggere la calibrazione."
msgstr "Acquisitore non raggiungibile: impossibile leggere la calibrazione."
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "oppure vedi la pagina Stazioni"
msgstr "oppure vedi la pagina Stazioni"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Immagine del pezzo"
msgstr "Immagine del pezzo"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Carica immagine"
msgstr "Carica immagine"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Disegno (.dxf)"
msgstr "Disegno (.dxf)"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Banda ± mm"
msgstr "Banda ± mm"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Soglia punti in banda %%"
msgstr "Soglia punti in banda %%"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Pezzo scuro su fondo chiaro (controluce)"
msgstr "Pezzo scuro su fondo chiaro (controluce)"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Prova"
msgstr "Prova"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Salva ricetta"
msgstr "Salva ricetta"