Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad6ce5d11d | |||
| ae2353f7f8 | |||
| d652891c1c | |||
| 0b22643d69 | |||
| 15e31e2b8a | |||
| f8f5f248ff | |||
| f33415df6a | |||
| 62b5e412ca | |||
| 0e92959022 | |||
| f23e87e585 | |||
| 114e4a10ae | |||
| 0a68a9995e | |||
| 8431aa7095 | |||
| 23012fdfbb | |||
| 242add047b |
@@ -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
|
||||||
@@ -73,3 +73,6 @@ docker-compose.override.yml
|
|||||||
|
|
||||||
# Subagent-driven development scratch: ledger, briefs, review packages
|
# Subagent-driven development scratch: ledger, briefs, review packages
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
# Generato da packaging/windows/build.ps1 (TieMeasureFlow#82): moduli-strumento dagli entry point
|
||||||
|
packaging/windows/_strumenti.py
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
|
|||||||
- ⚠️ Alembic carica `env.py` e `versions\` dal disco, quindi Nuitka non può compilarli: `build.ps1` li copia come soli `.pyc` (`compileall -b`) e `avvio.py` esegue `upgrade head` con `sourceless=true`. È l'unica eccezione al «nessun `.py`/`.pyc`»: il controllo finale di `build.ps1` ammette `.pyc` solo sotto `src\backend\migrations`. `env.py` chiama `fileConfig`, che azzera i gestori del log: `avvio.py` li ricrea dopo le migrazioni.
|
- ⚠️ Alembic carica `env.py` e `versions\` dal disco, quindi Nuitka non può compilarli: `build.ps1` li copia come soli `.pyc` (`compileall -b`) e `avvio.py` esegue `upgrade head` con `sourceless=true`. È l'unica eccezione al «nessun `.py`/`.pyc`»: il controllo finale di `build.ps1` ammette `.pyc` solo sotto `src\backend\migrations`. `env.py` chiama `fileConfig`, che azzera i gestori del log: `avvio.py` li ricrea dopo le migrazioni.
|
||||||
- ⚠️ `numba` è escluso dal pacchetto (`--nofollow-import-to=numba`): le funzioni compilate da Nuitka non hanno bytecode e numba non può farne il JIT. Core e pm2d hanno il ripiego in Python puro (`HAS_NUMBA=False`): funziona ma il matching è più lento. Da misurare sul TC bench.
|
- ⚠️ `numba` è escluso dal pacchetto (`--nofollow-import-to=numba`): le funzioni compilate da Nuitka non hanno bytecode e numba non può farne il JIT. Core e pm2d hanno il ripiego in Python puro (`HAS_NUMBA=False`): funziona ma il matching è più lento. Da misurare sul TC bench.
|
||||||
- ⚠️ L'exe è senza console: `sys.stdout`/`sys.stderr` possono essere `None` (`avvio.py` li sostituisce con il device nullo), uvicorn gira con `log_config=None` e il log va su file (`...\log\tiemeasureflow.log`). I server uvicorn girano in thread (nessun signal handler) e waitress si crea con `create_server`.
|
- ⚠️ L'exe è senza console: `sys.stdout`/`sys.stderr` possono essere `None` (`avvio.py` li sostituisce con il device nullo), uvicorn gira con `log_config=None` e il log va su file (`...\log\tiemeasureflow.log`). I server uvicorn girano in thread (nessun signal handler) e waitress si crea con `create_server`.
|
||||||
|
- ⚠️ Strumenti di visione (TieMeasureFlow#82): nel binario non ci sono i metadati di `vs-tools`, quindi `load_tools()` (entry point `visionsuite.task.tools`) non trova nulla e ogni ricetta fallisce con tipo sconosciuto. `build.ps1` genera `packaging/windows/_strumenti.py` (`MODULI`, gitignorato, letto dal venv del build), `avvio.py` `registra_strumenti()` lo importa prima dei server, e prima di Inno Setup `build.ps1` lancia l'exe con `--controlla-strumenti` (senza licenza né server; JSON in `...\log\controllo-strumenti.json`, exit 3 se il catalogo è vuoto o un modulo non si importa).
|
||||||
- ⚠️ `visionsuite` è un namespace condiviso da più distribuzioni: `build.ps1` elenca i sottopacchetti dagli `__path__` e i metadati con `--include-distribution-metadata`. Un nuovo pacchetto di Core si prende da solo se è in `[tool.uv.sources]` e nel gruppo.
|
- ⚠️ `visionsuite` è un namespace condiviso da più distribuzioni: `build.ps1` elenca i sottopacchetti dagli `__path__` e i metadati con `--include-distribution-metadata`. Un nuovo pacchetto di Core si prende da solo se è in `[tool.uv.sources]` e nel gruppo.
|
||||||
- ⚠️ `tailwind.css` non è nel repo: `build.ps1` lo genera con `npx tailwindcss@3.4.19` (stessa versione di `Dockerfile.frontend`); serve Internet solo a chi costruisce. Il setup finale non ha bisogno di Internet.
|
- ⚠️ `tailwind.css` non è nel repo: `build.ps1` lo genera con `npx tailwindcss@3.4.19` (stessa versione di `Dockerfile.frontend`); serve Internet solo a chi costruisce. Il setup finale non ha bisogno di Internet.
|
||||||
- ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe.
|
- ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe.
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ Come si usa: e' l'exe compilato con Nuitka (build.ps1); da sorgente, dalla radic
|
|||||||
con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`.
|
con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`.
|
||||||
Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove
|
Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove
|
||||||
automatiche; NON salta il controllo di licenza) e `--senza-browser`.
|
automatiche; NON salta il controllo di licenza) e `--senza-browser`.
|
||||||
|
`--controlla-strumenti` (TieMeasureFlow#82): NON richiede la licenza, non avvia server, migrazioni
|
||||||
|
ne' database; registra gli strumenti, scrive su stdout e in
|
||||||
|
%PROGRAMDATA%\\Tielogic\\TieMeasureFlow\\log\\controllo-strumenti.json un JSON
|
||||||
|
{strumenti, kind, falliti, unavailable} ed esce con 0 (strumenti > 0 e nessun fallito) o 3.
|
||||||
|
La usa build.ps1 prima di Inno Setup.
|
||||||
|
|
||||||
Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic.
|
Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic.
|
||||||
|
|
||||||
@@ -25,11 +30,15 @@ Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, wai
|
|||||||
⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono
|
⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono
|
||||||
la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni.
|
la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni.
|
||||||
⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss).
|
⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss).
|
||||||
|
⚠️ Nel binario Nuitka mancano i metadati di vs-tools, quindi `load_tools()` (che cerca gli
|
||||||
|
strumenti come entry point) non ne trova nessuno: `build.ps1` genera `_strumenti.py`
|
||||||
|
(MODULI, dagli entry point del venv) e `registra_strumenti()` li importa per nome (#82).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import importlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -264,6 +273,50 @@ def _prepara_log(dati: Path | None = None) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- strumenti di visione
|
||||||
|
|
||||||
|
def registra_strumenti() -> list[str]:
|
||||||
|
"""Registra gli strumenti di Core; torna i moduli che non si sono importati (#82).
|
||||||
|
|
||||||
|
Nel pacchetto Nuitka gli entry point non esistono: `load_tools()` non trova nulla e
|
||||||
|
l'elenco dei moduli arriva da `_strumenti.py`, generato da build.ps1. Da sorgente
|
||||||
|
`_strumenti` non c'e' e bastano gli entry point. Il worker di visione gira in un thread
|
||||||
|
di questo processo: registrare una volta sola qui vale per tutti."""
|
||||||
|
from visionsuite.task.registry import load_tools
|
||||||
|
load_tools()
|
||||||
|
try:
|
||||||
|
from _strumenti import MODULI # generato da build.ps1, accanto ad avvio.py
|
||||||
|
except ImportError:
|
||||||
|
MODULI = () # da sorgente: gli entry point di load_tools() bastano
|
||||||
|
falliti: list[str] = []
|
||||||
|
for modulo in MODULI:
|
||||||
|
try:
|
||||||
|
importlib.import_module(modulo) # eseguire il modulo registra i suoi @tool
|
||||||
|
except Exception: # un modulo rotto non ferma l'app: si logga e si raccoglie
|
||||||
|
log.error("strumenti %s non importati", modulo, exc_info=True)
|
||||||
|
falliti.append(modulo)
|
||||||
|
return falliti
|
||||||
|
|
||||||
|
|
||||||
|
def controlla_strumenti() -> int:
|
||||||
|
"""Senza licenza, server ne' DB: scrive il JSON del catalogo strumenti. Exit 0 se completo, 3 altrimenti."""
|
||||||
|
from visionsuite.task.graph import registry
|
||||||
|
from visionsuite.task.registry import unavailable
|
||||||
|
falliti = registra_strumenti()
|
||||||
|
kind = sorted(registry())
|
||||||
|
esito = {"strumenti": len(kind), "kind": kind, "falliti": falliti, "unavailable": unavailable()}
|
||||||
|
riga = json.dumps(esito, ensure_ascii=False)
|
||||||
|
if sys.stdout is not None:
|
||||||
|
print(riga)
|
||||||
|
try:
|
||||||
|
f = cartella_dati() / "log" / "controllo-strumenti.json"
|
||||||
|
f.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
f.write_text(riga + "\n", encoding="utf-8") # l'exe e' senza console: il file e' l'unica uscita sicura
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("controllo-strumenti.json non scritto: %s", e)
|
||||||
|
return 0 if kind and not falliti else 3
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- rete
|
# ---------------------------------------------------------------- rete
|
||||||
|
|
||||||
def _porta_aperta(porta: int) -> bool:
|
def _porta_aperta(porta: int) -> bool:
|
||||||
@@ -402,6 +455,8 @@ def main(argv=None) -> int:
|
|||||||
p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)")
|
p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)")
|
||||||
p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)")
|
p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)")
|
||||||
p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)")
|
p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)")
|
||||||
|
p.add_argument("--controlla-strumenti", action="store_true",
|
||||||
|
help="registra gli strumenti, scrive il JSON ed esce (senza licenza ne' server; usata da build.ps1)")
|
||||||
args = p.parse_args(argv)
|
args = p.parse_args(argv)
|
||||||
_prepara_log()
|
_prepara_log()
|
||||||
|
|
||||||
@@ -415,6 +470,9 @@ def main(argv=None) -> int:
|
|||||||
messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre)
|
messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if args.controlla_strumenti:
|
||||||
|
return controlla_strumenti() # prima della licenza: la build non ha una licenza
|
||||||
|
|
||||||
if not controlla_licenza(args.senza_finestre):
|
if not controlla_licenza(args.senza_finestre):
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@@ -449,6 +507,7 @@ def main(argv=None) -> int:
|
|||||||
_prepara_log(dati) # alembic ha riconfigurato il logging
|
_prepara_log(dati) # alembic ha riconfigurato il logging
|
||||||
log.info("migrazioni applicate (primo avvio: %s)", primo_avvio)
|
log.info("migrazioni applicate (primo avvio: %s)", primo_avvio)
|
||||||
|
|
||||||
|
registra_strumenti() # prima del server: il worker di visione legge registry() gia' popolato (#82)
|
||||||
thread = avvia_server(args.host, dati)
|
thread = avvia_server(args.host, dati)
|
||||||
if not args.senza_browser:
|
if not args.senza_browser:
|
||||||
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
|
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ segue da solo, quindi i sottopacchetti si elencano uno per uno dagli __path__ in
|
|||||||
⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba
|
⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba
|
||||||
non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False):
|
non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False):
|
||||||
funziona ma e' piu' lento nel matching.
|
funziona ma e' piu' lento nel matching.
|
||||||
|
⚠️ Gli strumenti di vs-tools si registrano solo come entry point (visionsuite.task.tools) e i
|
||||||
|
metadati di vs-tools non stanno nel binario (TieMeasureFlow#82): il build genera
|
||||||
|
packaging\windows\_strumenti.py (MODULI, gitignorato) che avvio.py importa, e prima di
|
||||||
|
Inno Setup esegue l'exe con --controlla-strumenti: se il catalogo e' vuoto il build fallisce.
|
||||||
⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`):
|
⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`):
|
||||||
si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo.
|
si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo.
|
||||||
#>
|
#>
|
||||||
@@ -85,8 +89,9 @@ try {
|
|||||||
$pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))"
|
$pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))"
|
||||||
if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" }
|
if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" }
|
||||||
$includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" })
|
$includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" })
|
||||||
# Nessun --include-distribution-metadata per vs-*: nulla li legge e Nuitka rifiuta i metadati di una
|
# Nessun --include-distribution-metadata per vs-*: Nuitka rifiuta i metadati di una distribuzione il cui
|
||||||
# distribuzione il cui pacchetto (visionsuite.camera, namespace) non e' di primo livello.
|
# pacchetto (visionsuite.camera, namespace) non e' di primo livello. Gli entry point di vs-tools, che
|
||||||
|
# load_tools() cercherebbe nei metadati, si rimpiazzano con _strumenti.py (sotto, TieMeasureFlow#82).
|
||||||
Write-Host "Pacchetti visionsuite: $($includi -join ' ')"
|
Write-Host "Pacchetti visionsuite: $($includi -join ' ')"
|
||||||
|
|
||||||
# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..."
|
# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..."
|
||||||
@@ -103,6 +108,25 @@ if (Test-Path $leggimi) {
|
|||||||
# Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH
|
# Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH
|
||||||
$env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';')
|
$env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';')
|
||||||
|
|
||||||
|
# TieMeasureFlow#82: i moduli-strumento dagli entry point del venv del build (nel binario non ci sono i
|
||||||
|
# metadati di vs-tools, quindi load_tools() non li troverebbe). Si usa $py, non `uv run`.
|
||||||
|
$strumenti = Join-Path $radice 'packaging\windows\_strumenti.py'
|
||||||
|
$genera = @'
|
||||||
|
import sys
|
||||||
|
from importlib.metadata import entry_points
|
||||||
|
moduli = sorted({ep.value for ep in entry_points(group="visionsuite.task.tools")})
|
||||||
|
if not moduli:
|
||||||
|
sys.exit(1)
|
||||||
|
righe = ["# Generato da build.ps1 (TieMeasureFlow#82): non modificare, non committare.", "MODULI = ("]
|
||||||
|
righe += [f" {m!r}," for m in moduli] + [")"]
|
||||||
|
with open(sys.argv[1], "w", encoding="utf-8", newline="") as f:
|
||||||
|
print(*righe, sep=chr(10), file=f)
|
||||||
|
print(len(moduli))
|
||||||
|
'@
|
||||||
|
$nModuli = $genera | & $py - $strumenti
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Entry point visionsuite.task.tools non trovati nel venv: nessuno strumento da includere" }
|
||||||
|
Write-Host "Moduli-strumento ($nModuli) in $strumenti"
|
||||||
|
|
||||||
$out = Join-Path $radice 'build\nuitka'
|
$out = Join-Path $radice 'build\nuitka'
|
||||||
New-Item -ItemType Directory -Force $out | Out-Null
|
New-Item -ItemType Directory -Force $out | Out-Null
|
||||||
$nuitka = @(
|
$nuitka = @(
|
||||||
@@ -201,6 +225,17 @@ $sporchi = Get-ChildItem $sorgente -Recurse -Include *.py, *.pyc -File |
|
|||||||
Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' }
|
Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' }
|
||||||
if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" }
|
if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" }
|
||||||
|
|
||||||
|
# TieMeasureFlow#82: l'exe deve vedere gli strumenti (--controlla-strumenti non vuole licenza ne' apre server/DB)
|
||||||
|
$exe = Join-Path $sorgente 'TieMeasureFlow.exe'
|
||||||
|
$jsonControllo = Join-Path $env:PROGRAMDATA 'Tielogic\TieMeasureFlow\log\controllo-strumenti.json'
|
||||||
|
Remove-Item $jsonControllo -ErrorAction SilentlyContinue # un JSON vecchio non deve far passare il controllo
|
||||||
|
# l'exe e' GUI-subsystem: con & non si aspetta, serve Start-Process -Wait
|
||||||
|
$proc = Start-Process -FilePath $exe -ArgumentList '--controlla-strumenti' -Wait -PassThru
|
||||||
|
$esito = if (Test-Path $jsonControllo) { Get-Content $jsonControllo -Raw } else { '(controllo-strumenti.json non scritto)' }
|
||||||
|
if ($proc.ExitCode -ne 0) { throw "Controllo strumenti fallito (codice $($proc.ExitCode)): $esito" }
|
||||||
|
$nStrumenti = ($esito | ConvertFrom-Json).strumenti
|
||||||
|
Write-Host "Controllo strumenti: $nStrumenti tipi registrati nell'exe"
|
||||||
|
|
||||||
if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return }
|
if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return }
|
||||||
|
|
||||||
$dist = Join-Path $radice 'dist'
|
$dist = Join-Path $radice 'dist'
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Le DLL scaricate da prendi-gtk.ps1 non si committano: solo lo script (TieMeasureFlow#78).
|
||||||
|
gtk/
|
||||||
|
cache/
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import importlib.util
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -195,3 +197,77 @@ def test_chiavi_extra_di_config_env_passano_ma_non_sovrascrivono_il_contratto(av
|
|||||||
avvio.main(["--senza-finestre", "--senza-browser"])
|
avvio.main(["--senza-finestre", "--senza-browser"])
|
||||||
assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1"
|
assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1"
|
||||||
assert os.environ["APP_ENV"] == "production" # le variabili fisse vincono
|
assert os.environ["APP_ENV"] == "production" # le variabili fisse vincono
|
||||||
|
|
||||||
|
|
||||||
|
# --- TieMeasureFlow#82: strumenti nel pacchetto (nel binario non ci sono gli entry point) ---
|
||||||
|
|
||||||
|
def _modulo_strumenti(monkeypatch, moduli):
|
||||||
|
# finto _strumenti.py generato da build.ps1
|
||||||
|
finto = types.ModuleType("_strumenti")
|
||||||
|
finto.MODULI = tuple(moduli)
|
||||||
|
monkeypatch.setitem(sys.modules, "_strumenti", finto)
|
||||||
|
|
||||||
|
|
||||||
|
def test_registra_strumenti_da_sorgente_senza_modulo_generato(avvio, monkeypatch):
|
||||||
|
# _strumenti.py esiste solo dopo build.ps1: se manca (sorgente) bastano gli entry point
|
||||||
|
monkeypatch.setitem(sys.modules, "_strumenti", None) # None in sys.modules = ImportError all'import
|
||||||
|
from visionsuite.task.graph import registry
|
||||||
|
assert avvio.registra_strumenti() == []
|
||||||
|
assert len(registry()) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_registra_strumenti_modulo_inesistente_non_solleva(avvio, monkeypatch):
|
||||||
|
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image", "visionsuite.tools.non_esiste"))
|
||||||
|
assert avvio.registra_strumenti() == ["visionsuite.tools.non_esiste"] # raccolto, non sollevato
|
||||||
|
|
||||||
|
|
||||||
|
def test_registra_strumenti_importa_i_moduli_elencati(avvio, monkeypatch):
|
||||||
|
# senza entry point (come nell'exe) i moduli di MODULI sono l'unica via: li si importa per nome
|
||||||
|
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image",))
|
||||||
|
visti = []
|
||||||
|
monkeypatch.setattr(avvio.importlib, "import_module", lambda nome: visti.append(nome))
|
||||||
|
assert avvio.registra_strumenti() == [] and visti == ["visionsuite.tools.image"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_controlla_strumenti_senza_licenza_scrive_json(avvio, monkeypatch, capsys, tmp_path):
|
||||||
|
monkeypatch.setitem(sys.modules, "_strumenti", None)
|
||||||
|
|
||||||
|
# la licenza non deve essere nemmeno chiesta, e server e migrazioni non devono partire
|
||||||
|
def mai(*a, **k):
|
||||||
|
raise AssertionError("licenza, server o migrazioni chiamati da --controlla-strumenti")
|
||||||
|
|
||||||
|
monkeypatch.setattr(avvio.lic, "verifica", mai)
|
||||||
|
monkeypatch.setattr(avvio.lic, "cerca_licenza", mai)
|
||||||
|
monkeypatch.setattr(avvio, "avvia_server", mai)
|
||||||
|
monkeypatch.setattr(avvio, "esegui_migrazioni", mai)
|
||||||
|
assert avvio.main(["--controlla-strumenti", "--senza-finestre"]) == 0
|
||||||
|
dati = json.loads(capsys.readouterr().out)
|
||||||
|
assert dati["strumenti"] > 0 and dati["strumenti"] == len(dati["kind"])
|
||||||
|
assert dati["falliti"] == []
|
||||||
|
scritto = tmp_path / "Tielogic" / "TieMeasureFlow" / "log" / "controllo-strumenti.json"
|
||||||
|
assert json.loads(scritto.read_text(encoding="utf-8")) == dati
|
||||||
|
assert avvio.aperti == [] and not (tmp_path / "Tielogic" / "TieMeasureFlow" / "tmf.db").exists() # nessun DB
|
||||||
|
|
||||||
|
|
||||||
|
def test_controlla_strumenti_exit_3_se_un_modulo_fallisce(avvio, monkeypatch, capsys):
|
||||||
|
_modulo_strumenti(monkeypatch, ("visionsuite.tools.non_esiste",))
|
||||||
|
assert avvio.main(["--controlla-strumenti"]) == 3
|
||||||
|
assert json.loads(capsys.readouterr().out)["falliti"] == ["visionsuite.tools.non_esiste"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_controlla_strumenti_exit_3_se_il_catalogo_e_vuoto(avvio, monkeypatch, capsys):
|
||||||
|
# niente strumenti registrati (es. entry point assenti e _strumenti vuoto): il controllo deve fallire
|
||||||
|
import visionsuite.task.graph as graph
|
||||||
|
monkeypatch.setattr(avvio, "registra_strumenti", lambda: [])
|
||||||
|
monkeypatch.setattr(graph, "registry", lambda: {})
|
||||||
|
assert avvio.main(["--controlla-strumenti"]) == 3
|
||||||
|
assert json.loads(capsys.readouterr().out)["strumenti"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_avvio_normale_registra_gli_strumenti_prima_del_server(avvio, tmp_path, monkeypatch):
|
||||||
|
# main() chiama registra_strumenti() dopo licenza e migrazioni e prima di avvia_server
|
||||||
|
_scrivi_licenza(tmp_path)
|
||||||
|
monkeypatch.setattr(avvio, "registra_strumenti", lambda: avvio.chiamate.append("strumenti") or [])
|
||||||
|
assert avvio.main(["--senza-finestre", "--senza-browser"]) == 0
|
||||||
|
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||||
|
assert avvio.chiamate == ["migrazioni", "strumenti", ("127.0.0.1", dati)]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404.
|
Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404.
|
||||||
NOT protected by API Key auth (standalone access).
|
NOT protected by API Key auth (standalone access).
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
import hmac
|
import hmac
|
||||||
import random
|
import random
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@@ -312,7 +313,9 @@ async def init_db(body: PasswordBody):
|
|||||||
await _require_not_initialised()
|
await _require_not_initialised()
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_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 tables created successfully"}
|
return {"status": "ok", "message": "Database tables created successfully"}
|
||||||
|
|
||||||
|
|
||||||
@@ -748,6 +751,8 @@ async def reset_db(body: ResetDbBody):
|
|||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.drop_all)
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
await conn.run_sync(Base.metadata.create_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"}
|
return {"status": "ok", "message": "Database reset successfully"}
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ It does not know, and must not know, whether the server or the station computed
|
|||||||
the numbers: that is what lets a station's configuration change without touching
|
the numbers: that is what lets a station's configuration change without touching
|
||||||
the frontend or the recipes.
|
the frontend or the recipes.
|
||||||
"""
|
"""
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
@@ -26,7 +29,7 @@ from src.backend.models.api.vision import (
|
|||||||
from src.backend.models.orm.vision import VisionResult
|
from src.backend.models.orm.vision import VisionResult
|
||||||
from src.backend.models.orm.task import RecipeTask
|
from src.backend.models.orm.task import RecipeTask
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.services import vision_service
|
from src.backend.services import station_service, vision_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/vision", tags=["vision"])
|
router = APIRouter(prefix="/api/vision", tags=["vision"])
|
||||||
|
|
||||||
@@ -185,6 +188,30 @@ def _422(errors: list[str]) -> HTTPException:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _store_validate_assign(
|
||||||
|
task: RecipeTask, graph: dict, placed: dict[str, bytes],
|
||||||
|
) -> tuple[str, dict]:
|
||||||
|
"""Scrive la cartella ricetta, la fa validare dal worker, la assegna al task.
|
||||||
|
|
||||||
|
Condiviso da upload_recipe e dxf-compare con save (#83): stesso
|
||||||
|
comportamento, un solo posto. Non committa. Ritorna (recipe_dir, check).
|
||||||
|
"""
|
||||||
|
# The folder is content-addressed and immutable; writing it before the
|
||||||
|
# validation is harmless (an orphan folder is never referenced) and the
|
||||||
|
# worker needs it on disk to validate meta files.
|
||||||
|
try:
|
||||||
|
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise _422([str(exc)])
|
||||||
|
check = await vision_service.validate_recipe(graph, recipe_dir)
|
||||||
|
if not check["ok"]:
|
||||||
|
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
|
||||||
|
|
||||||
|
task.vision_json = graph
|
||||||
|
task.vision_recipe_dir = recipe_dir
|
||||||
|
return recipe_dir, check
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
|
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
|
||||||
async def upload_recipe(
|
async def upload_recipe(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
@@ -218,19 +245,7 @@ async def upload_recipe(
|
|||||||
if errors:
|
if errors:
|
||||||
raise _422(errors)
|
raise _422(errors)
|
||||||
|
|
||||||
# The folder is content-addressed and immutable; writing it before the
|
recipe_dir, check = await _store_validate_assign(task, graph, placed)
|
||||||
# validation is harmless (an orphan folder is never referenced) and the
|
|
||||||
# worker needs it on disk to validate meta files.
|
|
||||||
try:
|
|
||||||
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise _422([str(exc)])
|
|
||||||
check = await vision_service.validate_recipe(graph, recipe_dir)
|
|
||||||
if not check["ok"]:
|
|
||||||
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
|
|
||||||
|
|
||||||
task.vision_json = graph
|
|
||||||
task.vision_recipe_dir = recipe_dir
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return VisionRecipeUploadResponse(
|
return VisionRecipeUploadResponse(
|
||||||
task_id=task.id,
|
task_id=task.id,
|
||||||
@@ -325,3 +340,77 @@ async def preview(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
task = await _task_or_404(db, task_id)
|
task = await _task_or_404(db, task_id)
|
||||||
return await vision_service.preview(db, task, await _read_image(image))
|
return await vision_service.preview(db, task, await _read_image(image))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/recipe/dxf-compare")
|
||||||
|
async def create_dxf_compare_recipe(
|
||||||
|
task_id: int,
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
dxf: UploadFile = File(...),
|
||||||
|
station_id: int | None = Form(None, gt=0),
|
||||||
|
px_per_mm: float | None = Form(None, gt=0),
|
||||||
|
band_mm: float = Form(0.1, gt=0),
|
||||||
|
min_points_pct: float = Form(95, ge=0, le=100),
|
||||||
|
polarity: Literal["dark", "light"] = Form("dark"),
|
||||||
|
save: bool = Form(False),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> dict:
|
||||||
|
"""Ricetta DXF compare da un'immagine e un DXF, senza Studio (#83).
|
||||||
|
|
||||||
|
La scala e' `px_per_mm`, o quella della stazione (letta dall'acquisitore).
|
||||||
|
Con save=false si vede il risultato e non si scrive nulla; con save=true la
|
||||||
|
ricetta va sul task come con l'upload, e se il task non ha nessuna quota di
|
||||||
|
visione nasce la quota «Punti in tolleranza».
|
||||||
|
"""
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
if task.task_type not in vision_service.VISION_TASK_TYPES:
|
||||||
|
raise _422([f"task type {task.task_type!r} does not run a vision recipe"])
|
||||||
|
|
||||||
|
station = None
|
||||||
|
if station_id is not None:
|
||||||
|
station = await station_service.get_station(db, station_id) # 404 se non esiste
|
||||||
|
if px_per_mm is None:
|
||||||
|
calibration, reason = await vision_service.fetch_station_calibration(station)
|
||||||
|
if calibration is None:
|
||||||
|
# irraggiungibile e non calibrata sono due guasti diversi per chi lo legge
|
||||||
|
raise _422([
|
||||||
|
"acquisitore della stazione non raggiungibile"
|
||||||
|
if reason == vision_service.CAL_UNREACHABLE else "stazione non calibrata"
|
||||||
|
])
|
||||||
|
px_per_mm = float(calibration["px_per_mm"])
|
||||||
|
|
||||||
|
image_bytes = await _read_image(image)
|
||||||
|
dxf_bytes = await dxf.read()
|
||||||
|
if not validate_file_size(len(dxf_bytes)):
|
||||||
|
raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"])
|
||||||
|
|
||||||
|
body = await vision_service.dxf_compare_recipe(
|
||||||
|
image_bytes, dxf_bytes,
|
||||||
|
px_per_mm=px_per_mm, band_mm=band_mm, min_points_pct=min_points_pct,
|
||||||
|
polarity=polarity,
|
||||||
|
worker_url=(station.vision_worker_url or None) if station is not None else None,
|
||||||
|
)
|
||||||
|
# i file stanno nella ricetta salvata, non nella risposta al client
|
||||||
|
files = body.pop("files")
|
||||||
|
response = {"result": body, "saved": False, "recipe_dir": None, "outputs": None}
|
||||||
|
if not save:
|
||||||
|
return response
|
||||||
|
|
||||||
|
uploads: list[tuple[str, bytes]] = []
|
||||||
|
for name, encoded in files.items():
|
||||||
|
try:
|
||||||
|
uploads.append((name, base64.b64decode(encoded, validate=True)))
|
||||||
|
except (binascii.Error, ValueError, TypeError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker's answer was malformed: file {name!r} is not base64",
|
||||||
|
)
|
||||||
|
placed, errors = vision_service.match_recipe_files(body["graph"], uploads)
|
||||||
|
if errors:
|
||||||
|
raise _422(errors)
|
||||||
|
recipe_dir, check = await _store_validate_assign(task, body["graph"], placed)
|
||||||
|
await vision_service.ensure_points_quote(db, task, min_points_pct)
|
||||||
|
await db.commit()
|
||||||
|
response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"])
|
||||||
|
return response
|
||||||
|
|||||||
+17
-2
@@ -27,6 +27,10 @@ class Settings(BaseSettings):
|
|||||||
db_name: str = "tiemeasureflow"
|
db_name: str = "tiemeasureflow"
|
||||||
db_user: str = "tmflow"
|
db_user: str = "tmflow"
|
||||||
db_password: str = _DEFAULT_DB_PASSWORD
|
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)
|
# Presentation time zone (DB is UTC, #42)
|
||||||
display_timezone: str = "Europe/Rome"
|
display_timezone: str = "Europe/Rome"
|
||||||
@@ -78,7 +82,9 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
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 (
|
return (
|
||||||
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
|
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
|
||||||
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
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.
|
(mounted as a Docker volume), not inside the backend tree.
|
||||||
"""
|
"""
|
||||||
# Path(__file__) = src/backend/config.py → parents[2] = project root
|
# 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
|
# Always resolve .env against the project root regardless of cwd
|
||||||
# (pydantic-settings would otherwise treat the path as cwd-relative).
|
# (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; "
|
"SERVER_SECRET_KEY is still the committed default value; "
|
||||||
"set it before running with APP_ENV=" + self.app_env
|
"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:
|
if self.db_password == _DEFAULT_DB_PASSWORD:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"DB_PASSWORD is still the committed default value; "
|
"DB_PASSWORD is still the committed default value; "
|
||||||
|
|||||||
+25
-8
@@ -1,6 +1,7 @@
|
|||||||
"""Async SQLAlchemy database engine and session management."""
|
"""Async SQLAlchemy database engine and session management."""
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlalchemy import event
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncSession,
|
AsyncSession,
|
||||||
async_sessionmaker,
|
async_sessionmaker,
|
||||||
@@ -10,14 +11,30 @@ from sqlalchemy.orm import DeclarativeBase
|
|||||||
|
|
||||||
from src.backend.config import settings
|
from src.backend.config import settings
|
||||||
|
|
||||||
# Create async engine
|
_IS_SQLITE = settings.database_url.startswith("sqlite")
|
||||||
engine = create_async_engine(
|
|
||||||
settings.database_url,
|
# Create async engine. MySQL gets a connection pool; SQLite (local file of the
|
||||||
echo=False,
|
# Windows package) does not accept pool_size/max_overflow/pool_recycle.
|
||||||
pool_size=10,
|
if _IS_SQLITE:
|
||||||
max_overflow=20,
|
engine = create_async_engine(settings.database_url, echo=False)
|
||||||
pool_recycle=3600,
|
|
||||||
)
|
@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
|
# Session factory
|
||||||
async_session_factory = async_sessionmaker(
|
async_session_factory = async_sessionmaker(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
|
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import tempfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from src.backend.utils.tempo import to_local, utcnow
|
from src.backend.utils.tempo import to_local, utcnow
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -25,6 +26,23 @@ from src.backend.services.spc_service import (
|
|||||||
compute_summary,
|
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
|
# Jinja2 environment for report templates
|
||||||
_templates_dir = Path(__file__).parent.parent / "templates" / "reports"
|
_templates_dir = Path(__file__).parent.parent / "templates" / "reports"
|
||||||
_jinja_env = Environment(
|
_jinja_env = Environment(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -24,6 +25,8 @@ from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
|||||||
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||||
from src.backend.services import measurement_service
|
from src.backend.services import measurement_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Worker responses whose status and reason are forwarded as-is rather than
|
# Worker responses whose status and reason are forwarded as-is rather than
|
||||||
# flattened into a generic 502 - see `_call_worker`.
|
# flattened into a generic 502 - see `_call_worker`.
|
||||||
_FORWARDED_WORKER_STATUSES = frozenset({
|
_FORWARDED_WORKER_STATUSES = frozenset({
|
||||||
@@ -140,6 +143,136 @@ async def _call_worker(
|
|||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
async def call_worker_multipart(
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
files: dict,
|
||||||
|
data: dict,
|
||||||
|
worker_url: str | None = None,
|
||||||
|
timeout: float = 120.0,
|
||||||
|
) -> dict:
|
||||||
|
"""POST multipart generico al worker (#83): /recipes/dxf-compare.
|
||||||
|
|
||||||
|
Stessa traduzione degli errori di `_call_worker`: 504 sul timeout, 502 sul
|
||||||
|
trasporto o su una risposta non JSON-oggetto; 422/503/504 del worker si
|
||||||
|
inoltrano cosi' come sono. Worker della stazione o, se manca, il centrale.
|
||||||
|
"""
|
||||||
|
base = (worker_url or settings.vision_worker_url).rstrip("/")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
response = await client.post(f"{base}{path}", files=files, data=data)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
|
detail=f"the vision worker did not answer in time: {exc} ({base})",
|
||||||
|
) from exc
|
||||||
|
except (httpx.TransportError, httpx.HTTPError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker is unreachable: {exc} ({base})",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if response.status_code in _FORWARDED_WORKER_STATUSES:
|
||||||
|
try:
|
||||||
|
detail = response.json().get("detail", response.text)
|
||||||
|
except ValueError:
|
||||||
|
detail = response.text
|
||||||
|
raise HTTPException(status_code=response.status_code, detail=detail)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker refused the job: {response.text}",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker's answer was malformed: not valid JSON ({exc})",
|
||||||
|
) from exc
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="the vision worker's answer was malformed: expected a JSON object",
|
||||||
|
)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
async def dxf_compare_recipe(
|
||||||
|
image_bytes: bytes, dxf_bytes: bytes, *, px_per_mm: float, band_mm: float,
|
||||||
|
min_points_pct: float, polarity: str, worker_url: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""W2: ricetta dxf-compare da immagine + DXF (#83). Sostituita dai test."""
|
||||||
|
body = await call_worker_multipart(
|
||||||
|
"/recipes/dxf-compare",
|
||||||
|
files={
|
||||||
|
"image": ("frame.png", image_bytes, "image/png"),
|
||||||
|
"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream"),
|
||||||
|
},
|
||||||
|
data={
|
||||||
|
"px_per_mm": str(px_per_mm), "band_mm": str(band_mm),
|
||||||
|
"min_points_pct": str(min_points_pct), "polarity": polarity,
|
||||||
|
},
|
||||||
|
worker_url=worker_url,
|
||||||
|
)
|
||||||
|
# forma minima del contratto W2: senza graph e files non c'e' nulla da salvare
|
||||||
|
if not isinstance(body.get("graph"), dict) or not isinstance(body.get("files"), dict):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="the vision worker's answer was malformed: missing graph or files",
|
||||||
|
)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
# Stati di fetch_station_calibration (#83)
|
||||||
|
CAL_OK = "ok"
|
||||||
|
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
|
||||||
|
CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore inutilizzabile)
|
||||||
|
CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
|
||||||
|
"""Legge la calibrazione dall'acquisitore della stazione (C1, #83).
|
||||||
|
|
||||||
|
GET {camera_url}/camera/calibration?source={camera_source}. La calibrazione
|
||||||
|
vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok")
|
||||||
|
oppure (None, motivo) per distinguere «non calibrata» da «irraggiungibile».
|
||||||
|
Un px_per_mm non finito o <= 0 conta come non calibrata.
|
||||||
|
"""
|
||||||
|
if station is None or not station.camera_url or not station.camera_source:
|
||||||
|
return None, CAL_NO_CAMERA
|
||||||
|
url = f"{station.camera_url.rstrip('/')}/camera/calibration"
|
||||||
|
try:
|
||||||
|
# timeout breve: e' una lettura di servizio, non deve bloccare l'execute
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
response = await client.get(url, params={"source": station.camera_source})
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.warning("acquisitore della stazione %s non raggiungibile: %s", station.code, exc)
|
||||||
|
return None, CAL_UNREACHABLE
|
||||||
|
if response.status_code == status.HTTP_404_NOT_FOUND:
|
||||||
|
return None, CAL_NOT_CALIBRATED
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
"acquisitore della stazione %s: HTTP %s", station.code, response.status_code,
|
||||||
|
)
|
||||||
|
return None, CAL_UNREACHABLE
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("acquisitore della stazione %s: risposta non JSON", station.code)
|
||||||
|
return None, CAL_UNREACHABLE
|
||||||
|
px_per_mm = body.get("px_per_mm") if isinstance(body, dict) else None
|
||||||
|
if not (_is_measurable(px_per_mm) and px_per_mm > 0):
|
||||||
|
return None, CAL_NOT_CALIBRATED
|
||||||
|
return body, CAL_OK
|
||||||
|
|
||||||
|
|
||||||
|
async def get_station_calibration(station: Station | None) -> dict | None:
|
||||||
|
"""La calibrazione C1 della stazione, o None in ogni altro caso (#83)."""
|
||||||
|
calibration, _ = await fetch_station_calibration(station)
|
||||||
|
return calibration
|
||||||
|
|
||||||
|
|
||||||
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
|
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
|
||||||
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
|
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
|
||||||
|
|
||||||
@@ -365,11 +498,14 @@ async def execute_task(
|
|||||||
)
|
)
|
||||||
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
||||||
station_url = None
|
station_url = None
|
||||||
|
# Calibrazione letta dall'acquisitore della stazione (#83); None = non c'e'
|
||||||
|
calibration = None
|
||||||
if run is not None:
|
if run is not None:
|
||||||
station = (await db.execute(
|
station = (await db.execute(
|
||||||
select(Station).where(Station.id == run.station_id)
|
select(Station).where(Station.id == run.station_id)
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
station_url = (station.vision_worker_url or None) if station is not None else None
|
station_url = (station.vision_worker_url or None) if station is not None else None
|
||||||
|
calibration = await get_station_calibration(station)
|
||||||
|
|
||||||
if not task.vision_json:
|
if not task.vision_json:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -419,11 +555,15 @@ async def execute_task(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si
|
# La calibrazione viene dall'acquisitore della stazione (#83); se non c'e',
|
||||||
# passa `calibration`, la ricetta porta la sua (#46).
|
# non si passa `calibration` e la ricetta porta la sua (#46).
|
||||||
|
worker_kwargs: dict = {}
|
||||||
|
if calibration is not None:
|
||||||
|
worker_kwargs["calibration"] = {"px_per_mm": float(calibration["px_per_mm"])}
|
||||||
report = await _call_worker(
|
report = await _call_worker(
|
||||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||||
worker_url=station_url, # #45
|
worker_url=station_url, # #45
|
||||||
|
**worker_kwargs,
|
||||||
)
|
)
|
||||||
outputs = report["outputs"]
|
outputs = report["outputs"]
|
||||||
|
|
||||||
@@ -457,6 +597,8 @@ async def execute_task(
|
|||||||
station_id=run.station_id if run is not None else None,
|
station_id=run.station_id if run is not None else None,
|
||||||
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
|
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
|
||||||
graph_snapshot=task.vision_json,
|
graph_snapshot=task.vision_json,
|
||||||
|
# copiata come il grafo (#83): ricalibrare dopo non cambia cio' che e' stato misurato
|
||||||
|
calibration_snapshot=calibration,
|
||||||
duration_ms=report.get("duration_ms"),
|
duration_ms=report.get("duration_ms"),
|
||||||
)
|
)
|
||||||
db.add(result)
|
db.add(result)
|
||||||
@@ -601,3 +743,42 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic
|
|||||||
return await _call_worker(
|
return await _call_worker(
|
||||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_points_quote(
|
||||||
|
db: AsyncSession, task: RecipeTask, min_points_pct: float,
|
||||||
|
) -> bool:
|
||||||
|
"""Crea la quota «Punti in tolleranza» se il task non ha nessuna quota di visione (#83).
|
||||||
|
|
||||||
|
Una sola volta: se esiste gia' una quota con `vision_output`, non si tocca.
|
||||||
|
Se la versione ha gia' misure non si scrive (il copy-on-write sta nel router
|
||||||
|
dei task, qui non si aggira): ritorna False. Ritorna True se l'ha creata.
|
||||||
|
"""
|
||||||
|
from src.backend.services import recipe_service # import locale: evita cicli
|
||||||
|
|
||||||
|
existing = (await db.execute(
|
||||||
|
select(RecipeSubtask.id)
|
||||||
|
.where(RecipeSubtask.task_id == task.id)
|
||||||
|
.where(RecipeSubtask.vision_output.is_not(None))
|
||||||
|
.limit(1)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return False
|
||||||
|
if await recipe_service.version_has_measurements(db, task.version_id):
|
||||||
|
return False
|
||||||
|
markers = (await db.execute(
|
||||||
|
select(RecipeSubtask.marker_number).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().all()
|
||||||
|
db.add(RecipeSubtask(
|
||||||
|
task_id=task.id,
|
||||||
|
marker_number=max(markers, default=0) + 1, # unico per task (uq_task_marker)
|
||||||
|
description="Punti in tolleranza",
|
||||||
|
nominal=100,
|
||||||
|
# limiti assoluti come nel resto delle quote: passa se punti% >= min_points_pct
|
||||||
|
ltl=min_points_pct,
|
||||||
|
utl=100,
|
||||||
|
unit="%",
|
||||||
|
vision_output="dxf.points_in_pct",
|
||||||
|
))
|
||||||
|
await db.flush()
|
||||||
|
return True
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"""#83: ricetta DXF compare da camera e calibrazione letta dall'acquisitore.
|
||||||
|
|
||||||
|
Il worker (W2) e l'acquisitore (C1) non girano nei test: si fingono, come
|
||||||
|
fanno gli altri test di visione. La calibrazione vive nell'acquisitore esterno,
|
||||||
|
TMF la legge via GET {camera_url}/camera/calibration.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask
|
||||||
|
from src.backend.models.orm.vision import VisionResult
|
||||||
|
from src.backend.services import vision_service
|
||||||
|
from src.backend.tests.conftest import auth_headers
|
||||||
|
from src.backend.tests.test_vision_recipe_upload import an_image, make_task
|
||||||
|
from src.backend.tests.test_vision_station_worker import ( # noqa: F401 (fixture worker_calls)
|
||||||
|
_camera_task, _execute, _station_with_run, worker_calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
CAMERA_URL = "http://10.0.0.9:8200"
|
||||||
|
CAL = {
|
||||||
|
"source": "camera:A1", "px_per_mm": 12.5, "mm_per_px": 0.08, "rms_px": 0.2,
|
||||||
|
"n_corners": 40, "cols": 8, "rows": 5, "square_mm": 5.0, "image_width": 640,
|
||||||
|
"image_height": 480, "scale_spread_pct": 0.1, "calibrated_at": "2026-10-23T10:00:00",
|
||||||
|
}
|
||||||
|
GRAPH = {
|
||||||
|
"schema_version": 2, "name": "dxf-compare", "tools": [],
|
||||||
|
"meta": {"dxf": {"disegno": {"path": "disegno.dxf"}}, "calibration": {"path": "calibrazione.json"}},
|
||||||
|
}
|
||||||
|
OUTPUTS = [{"key": "dxf.points_in_pct", "quantity": "ratio", "unit": "%"}]
|
||||||
|
|
||||||
|
|
||||||
|
def _b64(data: bytes) -> str:
|
||||||
|
return base64.b64encode(data).decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def redirect_uploads(monkeypatch, tmp_path):
|
||||||
|
# upload_path = parents[2] / upload_dir: un assoluto sostituisce la base
|
||||||
|
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def w2(monkeypatch):
|
||||||
|
"""Finto worker W2: ricorda le chiamate e risponde col corpo del contratto."""
|
||||||
|
calls: list[dict] = []
|
||||||
|
|
||||||
|
async def fake(image_bytes, dxf_bytes, **kwargs):
|
||||||
|
calls.append({"dxf": dxf_bytes, **kwargs})
|
||||||
|
return {
|
||||||
|
"graph": GRAPH,
|
||||||
|
"files": {
|
||||||
|
"disegno.dxf": _b64(dxf_bytes),
|
||||||
|
"calibrazione.json": _b64(json.dumps({"px_per_mm": kwargs["px_per_mm"]}).encode()),
|
||||||
|
},
|
||||||
|
"outputs": {"dxf.points_in_pct": 99.0},
|
||||||
|
"overlay": [], "outcome": "pass",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", fake)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def validate_ok(monkeypatch):
|
||||||
|
async def fake(graph, recipe_dir):
|
||||||
|
return {"ok": True, "errors": [], "outputs": OUTPUTS}
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def acquisitore(monkeypatch):
|
||||||
|
"""Finto C1: `state['cal']` = dict (200), None (404), 'down' (irraggiungibile)."""
|
||||||
|
state: dict = {"cal": CAL, "urls": []}
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def __init__(self, code, body=None):
|
||||||
|
self.status_code, self._body, self.text = code, body, ""
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get(self, url, params=None):
|
||||||
|
state["urls"].append((url, params))
|
||||||
|
if state["cal"] == "down":
|
||||||
|
raise httpx.ConnectError("giu'")
|
||||||
|
if state["cal"] is None:
|
||||||
|
return _Resp(404)
|
||||||
|
return _Resp(200, state["cal"])
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "AsyncClient", _Client)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
async def _station(db_session, user, *, camera=True, worker_url=None, code="ST-C"):
|
||||||
|
station = Station(
|
||||||
|
code=code, name="Stazione C", active=True, created_by=user.id,
|
||||||
|
vision_worker_url=worker_url,
|
||||||
|
camera_url=CAMERA_URL if camera else None,
|
||||||
|
camera_source="camera:A1" if camera else None,
|
||||||
|
)
|
||||||
|
db_session.add(station)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(station)
|
||||||
|
return station
|
||||||
|
|
||||||
|
|
||||||
|
def _post(client, user, task, *, save=False, **data):
|
||||||
|
return client.post(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe/dxf-compare",
|
||||||
|
headers=auth_headers(user),
|
||||||
|
files={
|
||||||
|
"image": ("p.png", an_image(), "image/png"),
|
||||||
|
"dxf": ("d.dxf", b"0\nEOF", "application/octet-stream"),
|
||||||
|
},
|
||||||
|
data={"save": str(save).lower(), **{k: str(v) for k, v in data.items() if v is not None}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- lettura della calibrazione dall'acquisitore (C1) -------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_calibration_ok(db_session, maker_user, acquisitore):
|
||||||
|
station = await _station(db_session, maker_user)
|
||||||
|
cal, reason = await vision_service.fetch_station_calibration(station)
|
||||||
|
assert (cal, reason) == (CAL, "ok")
|
||||||
|
assert acquisitore["urls"] == [(f"{CAMERA_URL}/camera/calibration", {"source": "camera:A1"})]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_calibration_reasons(db_session, maker_user, acquisitore):
|
||||||
|
station = await _station(db_session, maker_user)
|
||||||
|
acquisitore["cal"] = None
|
||||||
|
assert await vision_service.fetch_station_calibration(station) == (None, "not_calibrated")
|
||||||
|
acquisitore["cal"] = "down"
|
||||||
|
assert await vision_service.fetch_station_calibration(station) == (None, "unreachable")
|
||||||
|
assert await vision_service.get_station_calibration(station) is None
|
||||||
|
acquisitore["cal"] = {**CAL, "px_per_mm": 0} # valore inutilizzabile = non calibrata
|
||||||
|
assert await vision_service.fetch_station_calibration(station) == (None, "not_calibrated")
|
||||||
|
no_camera = await _station(db_session, maker_user, camera=False, code="ST-N")
|
||||||
|
assert await vision_service.fetch_station_calibration(no_camera) == (None, "no_camera")
|
||||||
|
assert await vision_service.fetch_station_calibration(None) == (None, "no_camera")
|
||||||
|
|
||||||
|
|
||||||
|
# --- B3: POST /api/vision/tasks/{id}/recipe/dxf-compare -----------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_preview_writes_nothing(
|
||||||
|
client, db_session, maker_user, w2, acquisitore,
|
||||||
|
):
|
||||||
|
task, subtask = await make_task(db_session, maker_user)
|
||||||
|
station = await _station(db_session, maker_user, worker_url="http://10.0.0.7:8100")
|
||||||
|
|
||||||
|
resp = await _post(client, maker_user, task, station_id=station.id, band_mm=0.2,
|
||||||
|
min_points_pct=90, polarity="light")
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["saved"] is False and body["recipe_dir"] is None and body["outputs"] is None
|
||||||
|
assert "files" not in body["result"] and body["result"]["graph"] == GRAPH
|
||||||
|
# px_per_mm dall'acquisitore, parametri e worker della stazione
|
||||||
|
assert w2[0]["px_per_mm"] == 12.5 and w2[0]["band_mm"] == 0.2
|
||||||
|
assert w2[0]["min_points_pct"] == 90 and w2[0]["polarity"] == "light"
|
||||||
|
assert w2[0]["worker_url"] == "http://10.0.0.7:8100"
|
||||||
|
await db_session.refresh(task)
|
||||||
|
assert task.vision_json is None and task.vision_recipe_dir is None
|
||||||
|
quotes = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id))).scalars().all()
|
||||||
|
assert [q.id for q in quotes] == [subtask.id] # nessuna quota nuova
|
||||||
|
assert not (settings.upload_path / "vision" / "recipes").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_save_stores_recipe_and_quote_once(
|
||||||
|
client, db_session, maker_user, w2, validate_ok,
|
||||||
|
):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
|
||||||
|
resp = await _post(client, maker_user, task, save=True, px_per_mm=10, min_points_pct=95)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["saved"] is True and body["outputs"] == OUTPUTS
|
||||||
|
assert "files" not in body["result"]
|
||||||
|
folder = settings.upload_path / body["recipe_dir"]
|
||||||
|
assert (folder / "disegno.dxf").read_bytes() == b"0\nEOF"
|
||||||
|
assert json.loads((folder / "calibrazione.json").read_text()) == {"px_per_mm": 10.0}
|
||||||
|
await db_session.refresh(task)
|
||||||
|
assert task.vision_json == GRAPH and task.vision_recipe_dir == body["recipe_dir"]
|
||||||
|
|
||||||
|
quotes = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct")
|
||||||
|
.where(RecipeSubtask.task_id == task.id))).scalars().all()
|
||||||
|
assert len(quotes) == 1
|
||||||
|
q = quotes[0]
|
||||||
|
assert q.description == "Punti in tolleranza"
|
||||||
|
assert (float(q.nominal), float(q.ltl), float(q.utl)) == (100.0, 95.0, 100.0)
|
||||||
|
|
||||||
|
# secondo salvataggio: la quota non si duplica
|
||||||
|
task_id = task.id # dopo expire_all l'attributo scaduto non si rilegge in async
|
||||||
|
again = await _post(client, maker_user, task, save=True, px_per_mm=10)
|
||||||
|
assert again.status_code == 200
|
||||||
|
db_session.expire_all()
|
||||||
|
count = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.vision_output.is_not(None))
|
||||||
|
.where(RecipeSubtask.task_id == task_id))).scalars().all()
|
||||||
|
assert len(count) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_keeps_an_existing_vision_quote(
|
||||||
|
client, db_session, maker_user, w2, validate_ok,
|
||||||
|
):
|
||||||
|
task, subtask = await make_task(db_session, maker_user)
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
resp = await _post(client, maker_user, task, save=True, px_per_mm=10)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
quotes = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id))).scalars().all()
|
||||||
|
assert len(quotes) == 1 # niente quota in piu'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_uncalibrated_station_is_422(
|
||||||
|
client, db_session, maker_user, w2, acquisitore,
|
||||||
|
):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
station = await _station(db_session, maker_user)
|
||||||
|
acquisitore["cal"] = None
|
||||||
|
resp = await _post(client, maker_user, task, station_id=station.id)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"] == ["stazione non calibrata"]
|
||||||
|
assert w2 == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_unreachable_acquirer_is_422_with_its_own_message(
|
||||||
|
client, db_session, maker_user, w2, acquisitore,
|
||||||
|
):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
station = await _station(db_session, maker_user)
|
||||||
|
acquisitore["cal"] = "down"
|
||||||
|
resp = await _post(client, maker_user, task, station_id=station.id)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"] == ["acquisitore della stazione non raggiungibile"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_without_station_nor_scale_is_422(client, db_session, maker_user, w2):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
resp = await _post(client, maker_user, task)
|
||||||
|
assert resp.status_code == 422 and resp.json()["detail"] == ["stazione non calibrata"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_needs_maker(client, db_session, maker_user, measurement_tec_user, w2):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
resp = await _post(client, measurement_tec_user, task, px_per_mm=10)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_worker_422_is_forwarded(
|
||||||
|
client, db_session, maker_user, monkeypatch,
|
||||||
|
):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
async def refuse(*a, **k):
|
||||||
|
raise HTTPException(status_code=422, detail="pezzo non trovato")
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", refuse)
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
resp = await _post(client, maker_user, task, px_per_mm=10)
|
||||||
|
assert resp.status_code == 422 and resp.json()["detail"] == "pezzo non trovato"
|
||||||
|
|
||||||
|
|
||||||
|
# --- execute: calibrazione della stazione -------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_passes_station_calibration_and_snapshots_it(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||||
|
):
|
||||||
|
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||||
|
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
|
||||||
|
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
await _execute(client, measurement_tec_user, task, run)
|
||||||
|
|
||||||
|
assert worker_calls[0]["calibration"] == {"px_per_mm": 12.5}
|
||||||
|
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||||
|
assert result.calibration_snapshot == CAL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_without_calibration_is_unchanged(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||||
|
):
|
||||||
|
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||||
|
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
|
||||||
|
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||||
|
await db_session.commit()
|
||||||
|
acquisitore["cal"] = "down" # acquisitore giu': si misura come prima, senza calibrazione
|
||||||
|
|
||||||
|
await _execute(client, measurement_tec_user, task, run)
|
||||||
|
|
||||||
|
assert "calibration" not in worker_calls[0]
|
||||||
|
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||||
|
assert result.calibration_snapshot is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_station_without_camera_never_asks(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||||
|
):
|
||||||
|
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||||
|
_, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
|
||||||
|
|
||||||
|
await _execute(client, measurement_tec_user, task, run)
|
||||||
|
|
||||||
|
assert acquisitore["urls"] == [] and "calibration" not in worker_calls[0]
|
||||||
@@ -12,12 +12,9 @@ class TestSelectRecipe:
|
|||||||
|
|
||||||
def test_select_recipe_renders(self, logged_in_client, mock_api_client, monkeypatch):
|
def test_select_recipe_renders(self, logged_in_client, mock_api_client, monkeypatch):
|
||||||
"""Recipe selection page renders for MeasurementTec role."""
|
"""Recipe selection page renders for MeasurementTec role."""
|
||||||
monkeypatch.setenv("STATION_CODE", "ST-TEST")
|
# Patch the class attribute instead of reload()ing config/blueprints.measure:
|
||||||
import config
|
# monkeypatch undoes it at teardown, so no ST-TEST leaks into later tests (#79).
|
||||||
import importlib
|
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||||
importlib.reload(config)
|
|
||||||
import blueprints.measure
|
|
||||||
importlib.reload(blueprints.measure)
|
|
||||||
|
|
||||||
mock_api_client.get_station_recipes.return_value = [
|
mock_api_client.get_station_recipes.return_value = [
|
||||||
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
||||||
@@ -47,12 +44,9 @@ class TestSelectRecipe:
|
|||||||
import re
|
import re
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
monkeypatch.setenv("STATION_CODE", "ST-TEST")
|
# Patch the class attribute instead of reload()ing config/blueprints.measure:
|
||||||
import config
|
# monkeypatch undoes it at teardown, so no ST-TEST leaks into later tests (#79).
|
||||||
import importlib
|
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||||
importlib.reload(config)
|
|
||||||
import blueprints.measure
|
|
||||||
importlib.reload(blueprints.measure)
|
|
||||||
|
|
||||||
mock_api_client.get_station_recipes.return_value = []
|
mock_api_client.get_station_recipes.return_value = []
|
||||||
|
|
||||||
@@ -79,12 +73,9 @@ class TestSelectRecipe:
|
|||||||
self, logged_in_client, mock_api_client, monkeypatch
|
self, logged_in_client, mock_api_client, monkeypatch
|
||||||
):
|
):
|
||||||
"""#10: a lot number like ``L'23`` must not break the page."""
|
"""#10: a lot number like ``L'23`` must not break the page."""
|
||||||
monkeypatch.setenv("STATION_CODE", "ST-TEST")
|
# Patch the class attribute instead of reload()ing config/blueprints.measure:
|
||||||
import config
|
# monkeypatch undoes it at teardown, so no ST-TEST leaks into later tests (#79).
|
||||||
import importlib
|
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||||
importlib.reload(config)
|
|
||||||
import blueprints.measure
|
|
||||||
importlib.reload(blueprints.measure)
|
|
||||||
|
|
||||||
mock_api_client.get_station_recipes.return_value = []
|
mock_api_client.get_station_recipes.return_value = []
|
||||||
|
|
||||||
@@ -393,6 +384,13 @@ class TestStartSequence:
|
|||||||
class TestTaskComplete:
|
class TestTaskComplete:
|
||||||
"""GET /measure/complete/<recipe_id> tests."""
|
"""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):
|
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."""
|
"""The summary carries the measurement clock, so it has to render with it."""
|
||||||
mock_api_client.get.side_effect = [
|
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.
|
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
|
# 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(
|
def test_selection_screen_carries_the_traceability_rules(
|
||||||
logged_in_client, mock_api_client, monkeypatch,
|
logged_in_client, mock_api_client, monkeypatch,
|
||||||
):
|
):
|
||||||
monkeypatch.setenv("STATION_CODE", "ST-TEST")
|
# Patch instead of reload()ing config/blueprints.measure: the patch is undone at
|
||||||
import importlib
|
# teardown, a reload would leave ST-TEST behind for later tests (#79).
|
||||||
|
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||||
import config
|
|
||||||
importlib.reload(config)
|
|
||||||
import blueprints.measure
|
|
||||||
importlib.reload(blueprints.measure)
|
|
||||||
|
|
||||||
mock_api_client.get_station_recipes.return_value = [
|
mock_api_client.get_station_recipes.return_value = [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user