Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc90d327f1 | |||
| c554fcfda7 | |||
| 990b5d0db8 | |||
| ae2353f7f8 |
@@ -73,3 +73,6 @@ docker-compose.override.yml
|
|||||||
|
|
||||||
# Subagent-driven development scratch: ledger, briefs, review packages
|
# Subagent-driven development scratch: ledger, briefs, review packages
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
# Generato da packaging/windows/build.ps1 (TieMeasureFlow#82): moduli-strumento dagli entry point
|
||||||
|
packaging/windows/_strumenti.py
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
|
|||||||
- ⚠️ Alembic carica `env.py` e `versions\` dal disco, quindi Nuitka non può compilarli: `build.ps1` li copia come soli `.pyc` (`compileall -b`) e `avvio.py` esegue `upgrade head` con `sourceless=true`. È l'unica eccezione al «nessun `.py`/`.pyc`»: il controllo finale di `build.ps1` ammette `.pyc` solo sotto `src\backend\migrations`. `env.py` chiama `fileConfig`, che azzera i gestori del log: `avvio.py` li ricrea dopo le migrazioni.
|
- ⚠️ Alembic carica `env.py` e `versions\` dal disco, quindi Nuitka non può compilarli: `build.ps1` li copia come soli `.pyc` (`compileall -b`) e `avvio.py` esegue `upgrade head` con `sourceless=true`. È l'unica eccezione al «nessun `.py`/`.pyc`»: il controllo finale di `build.ps1` ammette `.pyc` solo sotto `src\backend\migrations`. `env.py` chiama `fileConfig`, che azzera i gestori del log: `avvio.py` li ricrea dopo le migrazioni.
|
||||||
- ⚠️ `numba` è escluso dal pacchetto (`--nofollow-import-to=numba`): le funzioni compilate da Nuitka non hanno bytecode e numba non può farne il JIT. Core e pm2d hanno il ripiego in Python puro (`HAS_NUMBA=False`): funziona ma il matching è più lento. Da misurare sul TC bench.
|
- ⚠️ `numba` è escluso dal pacchetto (`--nofollow-import-to=numba`): le funzioni compilate da Nuitka non hanno bytecode e numba non può farne il JIT. Core e pm2d hanno il ripiego in Python puro (`HAS_NUMBA=False`): funziona ma il matching è più lento. Da misurare sul TC bench.
|
||||||
- ⚠️ L'exe è senza console: `sys.stdout`/`sys.stderr` possono essere `None` (`avvio.py` li sostituisce con il device nullo), uvicorn gira con `log_config=None` e il log va su file (`...\log\tiemeasureflow.log`). I server uvicorn girano in thread (nessun signal handler) e waitress si crea con `create_server`.
|
- ⚠️ L'exe è senza console: `sys.stdout`/`sys.stderr` possono essere `None` (`avvio.py` li sostituisce con il device nullo), uvicorn gira con `log_config=None` e il log va su file (`...\log\tiemeasureflow.log`). I server uvicorn girano in thread (nessun signal handler) e waitress si crea con `create_server`.
|
||||||
|
- ⚠️ Strumenti di visione (TieMeasureFlow#82): nel binario non ci sono i metadati di `vs-tools`, quindi `load_tools()` (entry point `visionsuite.task.tools`) non trova nulla e ogni ricetta fallisce con tipo sconosciuto. `build.ps1` genera `packaging/windows/_strumenti.py` (`MODULI`, gitignorato, letto dal venv del build), `avvio.py` `registra_strumenti()` lo importa prima dei server, e prima di Inno Setup `build.ps1` lancia l'exe con `--controlla-strumenti` (senza licenza né server; JSON in `...\log\controllo-strumenti.json`, exit 3 se il catalogo è vuoto o un modulo non si importa).
|
||||||
- ⚠️ `visionsuite` è un namespace condiviso da più distribuzioni: `build.ps1` elenca i sottopacchetti dagli `__path__` e i metadati con `--include-distribution-metadata`. Un nuovo pacchetto di Core si prende da solo se è in `[tool.uv.sources]` e nel gruppo.
|
- ⚠️ `visionsuite` è un namespace condiviso da più distribuzioni: `build.ps1` elenca i sottopacchetti dagli `__path__` e i metadati con `--include-distribution-metadata`. Un nuovo pacchetto di Core si prende da solo se è in `[tool.uv.sources]` e nel gruppo.
|
||||||
- ⚠️ `tailwind.css` non è nel repo: `build.ps1` lo genera con `npx tailwindcss@3.4.19` (stessa versione di `Dockerfile.frontend`); serve Internet solo a chi costruisce. Il setup finale non ha bisogno di Internet.
|
- ⚠️ `tailwind.css` non è nel repo: `build.ps1` lo genera con `npx tailwindcss@3.4.19` (stessa versione di `Dockerfile.frontend`); serve Internet solo a chi costruisce. Il setup finale non ha bisogno di Internet.
|
||||||
- ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe.
|
- ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe.
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ Come si usa: e' l'exe compilato con Nuitka (build.ps1); da sorgente, dalla radic
|
|||||||
con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`.
|
con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`.
|
||||||
Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove
|
Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove
|
||||||
automatiche; NON salta il controllo di licenza) e `--senza-browser`.
|
automatiche; NON salta il controllo di licenza) e `--senza-browser`.
|
||||||
|
`--controlla-strumenti` (TieMeasureFlow#82): NON richiede la licenza, non avvia server, migrazioni
|
||||||
|
ne' database; registra gli strumenti, scrive su stdout e in
|
||||||
|
%PROGRAMDATA%\\Tielogic\\TieMeasureFlow\\log\\controllo-strumenti.json un JSON
|
||||||
|
{strumenti, kind, falliti, unavailable} ed esce con 0 (strumenti > 0 e nessun fallito) o 3.
|
||||||
|
La usa build.ps1 prima di Inno Setup.
|
||||||
|
|
||||||
Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic.
|
Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic.
|
||||||
|
|
||||||
@@ -25,11 +30,15 @@ Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, wai
|
|||||||
⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono
|
⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono
|
||||||
la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni.
|
la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni.
|
||||||
⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss).
|
⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss).
|
||||||
|
⚠️ Nel binario Nuitka mancano i metadati di vs-tools, quindi `load_tools()` (che cerca gli
|
||||||
|
strumenti come entry point) non ne trova nessuno: `build.ps1` genera `_strumenti.py`
|
||||||
|
(MODULI, dagli entry point del venv) e `registra_strumenti()` li importa per nome (#82).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import importlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -264,6 +273,50 @@ def _prepara_log(dati: Path | None = None) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- strumenti di visione
|
||||||
|
|
||||||
|
def registra_strumenti() -> list[str]:
|
||||||
|
"""Registra gli strumenti di Core; torna i moduli che non si sono importati (#82).
|
||||||
|
|
||||||
|
Nel pacchetto Nuitka gli entry point non esistono: `load_tools()` non trova nulla e
|
||||||
|
l'elenco dei moduli arriva da `_strumenti.py`, generato da build.ps1. Da sorgente
|
||||||
|
`_strumenti` non c'e' e bastano gli entry point. Il worker di visione gira in un thread
|
||||||
|
di questo processo: registrare una volta sola qui vale per tutti."""
|
||||||
|
from visionsuite.task.registry import load_tools
|
||||||
|
load_tools()
|
||||||
|
try:
|
||||||
|
from _strumenti import MODULI # generato da build.ps1, accanto ad avvio.py
|
||||||
|
except ImportError:
|
||||||
|
MODULI = () # da sorgente: gli entry point di load_tools() bastano
|
||||||
|
falliti: list[str] = []
|
||||||
|
for modulo in MODULI:
|
||||||
|
try:
|
||||||
|
importlib.import_module(modulo) # eseguire il modulo registra i suoi @tool
|
||||||
|
except Exception: # un modulo rotto non ferma l'app: si logga e si raccoglie
|
||||||
|
log.error("strumenti %s non importati", modulo, exc_info=True)
|
||||||
|
falliti.append(modulo)
|
||||||
|
return falliti
|
||||||
|
|
||||||
|
|
||||||
|
def controlla_strumenti() -> int:
|
||||||
|
"""Senza licenza, server ne' DB: scrive il JSON del catalogo strumenti. Exit 0 se completo, 3 altrimenti."""
|
||||||
|
from visionsuite.task.graph import registry
|
||||||
|
from visionsuite.task.registry import unavailable
|
||||||
|
falliti = registra_strumenti()
|
||||||
|
kind = sorted(registry())
|
||||||
|
esito = {"strumenti": len(kind), "kind": kind, "falliti": falliti, "unavailable": unavailable()}
|
||||||
|
riga = json.dumps(esito, ensure_ascii=False)
|
||||||
|
if sys.stdout is not None:
|
||||||
|
print(riga)
|
||||||
|
try:
|
||||||
|
f = cartella_dati() / "log" / "controllo-strumenti.json"
|
||||||
|
f.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
f.write_text(riga + "\n", encoding="utf-8") # l'exe e' senza console: il file e' l'unica uscita sicura
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("controllo-strumenti.json non scritto: %s", e)
|
||||||
|
return 0 if kind and not falliti else 3
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- rete
|
# ---------------------------------------------------------------- rete
|
||||||
|
|
||||||
def _porta_aperta(porta: int) -> bool:
|
def _porta_aperta(porta: int) -> bool:
|
||||||
@@ -402,6 +455,8 @@ def main(argv=None) -> int:
|
|||||||
p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)")
|
p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)")
|
||||||
p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)")
|
p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)")
|
||||||
p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)")
|
p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)")
|
||||||
|
p.add_argument("--controlla-strumenti", action="store_true",
|
||||||
|
help="registra gli strumenti, scrive il JSON ed esce (senza licenza ne' server; usata da build.ps1)")
|
||||||
args = p.parse_args(argv)
|
args = p.parse_args(argv)
|
||||||
_prepara_log()
|
_prepara_log()
|
||||||
|
|
||||||
@@ -415,6 +470,9 @@ def main(argv=None) -> int:
|
|||||||
messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre)
|
messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if args.controlla_strumenti:
|
||||||
|
return controlla_strumenti() # prima della licenza: la build non ha una licenza
|
||||||
|
|
||||||
if not controlla_licenza(args.senza_finestre):
|
if not controlla_licenza(args.senza_finestre):
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@@ -449,6 +507,7 @@ def main(argv=None) -> int:
|
|||||||
_prepara_log(dati) # alembic ha riconfigurato il logging
|
_prepara_log(dati) # alembic ha riconfigurato il logging
|
||||||
log.info("migrazioni applicate (primo avvio: %s)", primo_avvio)
|
log.info("migrazioni applicate (primo avvio: %s)", primo_avvio)
|
||||||
|
|
||||||
|
registra_strumenti() # prima del server: il worker di visione legge registry() gia' popolato (#82)
|
||||||
thread = avvia_server(args.host, dati)
|
thread = avvia_server(args.host, dati)
|
||||||
if not args.senza_browser:
|
if not args.senza_browser:
|
||||||
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
|
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ segue da solo, quindi i sottopacchetti si elencano uno per uno dagli __path__ in
|
|||||||
⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba
|
⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba
|
||||||
non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False):
|
non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False):
|
||||||
funziona ma e' piu' lento nel matching.
|
funziona ma e' piu' lento nel matching.
|
||||||
|
⚠️ Gli strumenti di vs-tools si registrano solo come entry point (visionsuite.task.tools) e i
|
||||||
|
metadati di vs-tools non stanno nel binario (TieMeasureFlow#82): il build genera
|
||||||
|
packaging\windows\_strumenti.py (MODULI, gitignorato) che avvio.py importa, e prima di
|
||||||
|
Inno Setup esegue l'exe con --controlla-strumenti: se il catalogo e' vuoto il build fallisce.
|
||||||
⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`):
|
⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`):
|
||||||
si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo.
|
si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo.
|
||||||
#>
|
#>
|
||||||
@@ -85,8 +89,9 @@ try {
|
|||||||
$pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))"
|
$pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))"
|
||||||
if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" }
|
if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" }
|
||||||
$includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" })
|
$includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" })
|
||||||
# Nessun --include-distribution-metadata per vs-*: nulla li legge e Nuitka rifiuta i metadati di una
|
# Nessun --include-distribution-metadata per vs-*: Nuitka rifiuta i metadati di una distribuzione il cui
|
||||||
# distribuzione il cui pacchetto (visionsuite.camera, namespace) non e' di primo livello.
|
# pacchetto (visionsuite.camera, namespace) non e' di primo livello. Gli entry point di vs-tools, che
|
||||||
|
# load_tools() cercherebbe nei metadati, si rimpiazzano con _strumenti.py (sotto, TieMeasureFlow#82).
|
||||||
Write-Host "Pacchetti visionsuite: $($includi -join ' ')"
|
Write-Host "Pacchetti visionsuite: $($includi -join ' ')"
|
||||||
|
|
||||||
# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..."
|
# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..."
|
||||||
@@ -103,6 +108,25 @@ if (Test-Path $leggimi) {
|
|||||||
# Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH
|
# Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH
|
||||||
$env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';')
|
$env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';')
|
||||||
|
|
||||||
|
# TieMeasureFlow#82: i moduli-strumento dagli entry point del venv del build (nel binario non ci sono i
|
||||||
|
# metadati di vs-tools, quindi load_tools() non li troverebbe). Si usa $py, non `uv run`.
|
||||||
|
$strumenti = Join-Path $radice 'packaging\windows\_strumenti.py'
|
||||||
|
$genera = @'
|
||||||
|
import sys
|
||||||
|
from importlib.metadata import entry_points
|
||||||
|
moduli = sorted({ep.value for ep in entry_points(group="visionsuite.task.tools")})
|
||||||
|
if not moduli:
|
||||||
|
sys.exit(1)
|
||||||
|
righe = ["# Generato da build.ps1 (TieMeasureFlow#82): non modificare, non committare.", "MODULI = ("]
|
||||||
|
righe += [f" {m!r}," for m in moduli] + [")"]
|
||||||
|
with open(sys.argv[1], "w", encoding="utf-8", newline="") as f:
|
||||||
|
print(*righe, sep=chr(10), file=f)
|
||||||
|
print(len(moduli))
|
||||||
|
'@
|
||||||
|
$nModuli = $genera | & $py - $strumenti
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Entry point visionsuite.task.tools non trovati nel venv: nessuno strumento da includere" }
|
||||||
|
Write-Host "Moduli-strumento ($nModuli) in $strumenti"
|
||||||
|
|
||||||
$out = Join-Path $radice 'build\nuitka'
|
$out = Join-Path $radice 'build\nuitka'
|
||||||
New-Item -ItemType Directory -Force $out | Out-Null
|
New-Item -ItemType Directory -Force $out | Out-Null
|
||||||
$nuitka = @(
|
$nuitka = @(
|
||||||
@@ -201,6 +225,17 @@ $sporchi = Get-ChildItem $sorgente -Recurse -Include *.py, *.pyc -File |
|
|||||||
Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' }
|
Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' }
|
||||||
if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" }
|
if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" }
|
||||||
|
|
||||||
|
# TieMeasureFlow#82: l'exe deve vedere gli strumenti (--controlla-strumenti non vuole licenza ne' apre server/DB)
|
||||||
|
$exe = Join-Path $sorgente 'TieMeasureFlow.exe'
|
||||||
|
$jsonControllo = Join-Path $env:PROGRAMDATA 'Tielogic\TieMeasureFlow\log\controllo-strumenti.json'
|
||||||
|
Remove-Item $jsonControllo -ErrorAction SilentlyContinue # un JSON vecchio non deve far passare il controllo
|
||||||
|
# l'exe e' GUI-subsystem: con & non si aspetta, serve Start-Process -Wait
|
||||||
|
$proc = Start-Process -FilePath $exe -ArgumentList '--controlla-strumenti' -Wait -PassThru
|
||||||
|
$esito = if (Test-Path $jsonControllo) { Get-Content $jsonControllo -Raw } else { '(controllo-strumenti.json non scritto)' }
|
||||||
|
if ($proc.ExitCode -ne 0) { throw "Controllo strumenti fallito (codice $($proc.ExitCode)): $esito" }
|
||||||
|
$nStrumenti = ($esito | ConvertFrom-Json).strumenti
|
||||||
|
Write-Host "Controllo strumenti: $nStrumenti tipi registrati nell'exe"
|
||||||
|
|
||||||
if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return }
|
if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return }
|
||||||
|
|
||||||
$dist = Join-Path $radice 'dist'
|
$dist = Join-Path $radice 'dist'
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import importlib.util
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -195,3 +197,77 @@ def test_chiavi_extra_di_config_env_passano_ma_non_sovrascrivono_il_contratto(av
|
|||||||
avvio.main(["--senza-finestre", "--senza-browser"])
|
avvio.main(["--senza-finestre", "--senza-browser"])
|
||||||
assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1"
|
assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1"
|
||||||
assert os.environ["APP_ENV"] == "production" # le variabili fisse vincono
|
assert os.environ["APP_ENV"] == "production" # le variabili fisse vincono
|
||||||
|
|
||||||
|
|
||||||
|
# --- TieMeasureFlow#82: strumenti nel pacchetto (nel binario non ci sono gli entry point) ---
|
||||||
|
|
||||||
|
def _modulo_strumenti(monkeypatch, moduli):
|
||||||
|
# finto _strumenti.py generato da build.ps1
|
||||||
|
finto = types.ModuleType("_strumenti")
|
||||||
|
finto.MODULI = tuple(moduli)
|
||||||
|
monkeypatch.setitem(sys.modules, "_strumenti", finto)
|
||||||
|
|
||||||
|
|
||||||
|
def test_registra_strumenti_da_sorgente_senza_modulo_generato(avvio, monkeypatch):
|
||||||
|
# _strumenti.py esiste solo dopo build.ps1: se manca (sorgente) bastano gli entry point
|
||||||
|
monkeypatch.setitem(sys.modules, "_strumenti", None) # None in sys.modules = ImportError all'import
|
||||||
|
from visionsuite.task.graph import registry
|
||||||
|
assert avvio.registra_strumenti() == []
|
||||||
|
assert len(registry()) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_registra_strumenti_modulo_inesistente_non_solleva(avvio, monkeypatch):
|
||||||
|
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image", "visionsuite.tools.non_esiste"))
|
||||||
|
assert avvio.registra_strumenti() == ["visionsuite.tools.non_esiste"] # raccolto, non sollevato
|
||||||
|
|
||||||
|
|
||||||
|
def test_registra_strumenti_importa_i_moduli_elencati(avvio, monkeypatch):
|
||||||
|
# senza entry point (come nell'exe) i moduli di MODULI sono l'unica via: li si importa per nome
|
||||||
|
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image",))
|
||||||
|
visti = []
|
||||||
|
monkeypatch.setattr(avvio.importlib, "import_module", lambda nome: visti.append(nome))
|
||||||
|
assert avvio.registra_strumenti() == [] and visti == ["visionsuite.tools.image"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_controlla_strumenti_senza_licenza_scrive_json(avvio, monkeypatch, capsys, tmp_path):
|
||||||
|
monkeypatch.setitem(sys.modules, "_strumenti", None)
|
||||||
|
|
||||||
|
# la licenza non deve essere nemmeno chiesta, e server e migrazioni non devono partire
|
||||||
|
def mai(*a, **k):
|
||||||
|
raise AssertionError("licenza, server o migrazioni chiamati da --controlla-strumenti")
|
||||||
|
|
||||||
|
monkeypatch.setattr(avvio.lic, "verifica", mai)
|
||||||
|
monkeypatch.setattr(avvio.lic, "cerca_licenza", mai)
|
||||||
|
monkeypatch.setattr(avvio, "avvia_server", mai)
|
||||||
|
monkeypatch.setattr(avvio, "esegui_migrazioni", mai)
|
||||||
|
assert avvio.main(["--controlla-strumenti", "--senza-finestre"]) == 0
|
||||||
|
dati = json.loads(capsys.readouterr().out)
|
||||||
|
assert dati["strumenti"] > 0 and dati["strumenti"] == len(dati["kind"])
|
||||||
|
assert dati["falliti"] == []
|
||||||
|
scritto = tmp_path / "Tielogic" / "TieMeasureFlow" / "log" / "controllo-strumenti.json"
|
||||||
|
assert json.loads(scritto.read_text(encoding="utf-8")) == dati
|
||||||
|
assert avvio.aperti == [] and not (tmp_path / "Tielogic" / "TieMeasureFlow" / "tmf.db").exists() # nessun DB
|
||||||
|
|
||||||
|
|
||||||
|
def test_controlla_strumenti_exit_3_se_un_modulo_fallisce(avvio, monkeypatch, capsys):
|
||||||
|
_modulo_strumenti(monkeypatch, ("visionsuite.tools.non_esiste",))
|
||||||
|
assert avvio.main(["--controlla-strumenti"]) == 3
|
||||||
|
assert json.loads(capsys.readouterr().out)["falliti"] == ["visionsuite.tools.non_esiste"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_controlla_strumenti_exit_3_se_il_catalogo_e_vuoto(avvio, monkeypatch, capsys):
|
||||||
|
# niente strumenti registrati (es. entry point assenti e _strumenti vuoto): il controllo deve fallire
|
||||||
|
import visionsuite.task.graph as graph
|
||||||
|
monkeypatch.setattr(avvio, "registra_strumenti", lambda: [])
|
||||||
|
monkeypatch.setattr(graph, "registry", lambda: {})
|
||||||
|
assert avvio.main(["--controlla-strumenti"]) == 3
|
||||||
|
assert json.loads(capsys.readouterr().out)["strumenti"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_avvio_normale_registra_gli_strumenti_prima_del_server(avvio, tmp_path, monkeypatch):
|
||||||
|
# main() chiama registra_strumenti() dopo licenza e migrazioni e prima di avvia_server
|
||||||
|
_scrivi_licenza(tmp_path)
|
||||||
|
monkeypatch.setattr(avvio, "registra_strumenti", lambda: avvio.chiamate.append("strumenti") or [])
|
||||||
|
assert avvio.main(["--senza-finestre", "--senza-browser"]) == 0
|
||||||
|
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||||
|
assert avvio.chiamate == ["migrazioni", "strumenti", ("127.0.0.1", dati)]
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
"""Seed per il PC della fiera: utenti, stazione, tre ricette e quattro settimane di misure.
|
||||||
|
|
||||||
|
Cosa fa
|
||||||
|
Popola un database TieMeasureFlow (SQLite del pacchetto Windows) cosi' che al primo
|
||||||
|
avvio NON compaia la procedura di setup e la demo abbia gia' qualcosa da mostrare:
|
||||||
|
|
||||||
|
- utenti adriano (admin), maker, capoturno, operatore (password nella costante
|
||||||
|
UTENTI qui sotto, decise da Adriano per la fiera);
|
||||||
|
- la stazione ST-DEFAULT (quella del pacchetto, vedi packaging/windows/avvio.py)
|
||||||
|
con camera_url http://127.0.0.1:8765 e camera_source camera:601020 (Itala);
|
||||||
|
- FIERA-FLANGIA «Flangia Ø80» (5 quote) e FIERA-ALBERO «Albero rettificato»
|
||||||
|
(4 quote), con circa 4 settimane di storico fino a ieri (lun-ven 8-17, una
|
||||||
|
produzione chiusa per giorno, lotti e seriali coerenti, misure con Cpk diversi
|
||||||
|
per quota, una deriva lenta nell'ultima settimana, una quota con 1-3% fuori
|
||||||
|
tolleranza);
|
||||||
|
- FIERA-DXF «Profilo DXF (visione)»: un task dxf_compare senza ricetta di visione
|
||||||
|
(si crea in fiera dal pannello «Crea ricetta da DXF»).
|
||||||
|
|
||||||
|
«Setup fatto» per il backend vuol dire: esiste almeno un utente admin
|
||||||
|
(api/routers/setup.py::_admin_exists), quindi basta creare adriano.
|
||||||
|
|
||||||
|
Come si usa (da daitarn, TMF fermo sul PC, su una COPIA del file SQLite)
|
||||||
|
uv run python scripts/seed_fiera.py --db C:/percorso/tmf.db [--replace] [--dry-run]
|
||||||
|
[--fine AAAA-MM-GG]
|
||||||
|
|
||||||
|
Il file deve avere gia' lo schema (alembic upgrade head: lo fa l'exe al primo
|
||||||
|
avvio). --replace toglie solo i dati FIERA-* (ricette, misure, produzioni) e li
|
||||||
|
rifa'; senza, un secondo giro non duplica le ricette. Gli utenti e la stazione
|
||||||
|
sono idempotenti: se esistono si aggiornano password, ruoli e camera.
|
||||||
|
--dry-run fa tutto e annulla (stampa i numeri). Il seme casuale e' fisso: stesso
|
||||||
|
comando e stessa data di fine, stessi dati.
|
||||||
|
|
||||||
|
Da cosa dipende
|
||||||
|
src.backend (modelli ORM, auth_service, measurement_service), SQLAlchemy +
|
||||||
|
aiosqlite, tzdata su Windows (gia' in pyproject). Non usa MySQL.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from sqlalchemy import delete, event, select
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession, async_sessionmaker, create_async_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
# La radice del repo nel path, cosi' "src.backend" si importa lanciando lo script da ovunque
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from src.backend.database import async_session_factory # noqa: E402
|
||||||
|
from src.backend.models.orm.measurement import Measurement # noqa: E402
|
||||||
|
from src.backend.models.orm.production import ( # noqa: E402
|
||||||
|
ProductionEvent, ProductionRun,
|
||||||
|
)
|
||||||
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: E402
|
||||||
|
from src.backend.models.orm.setting import RecipeVersionAudit # noqa: E402
|
||||||
|
from src.backend.models.orm.station import ( # noqa: E402
|
||||||
|
Station, StationRecipeAssignment,
|
||||||
|
)
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask # noqa: E402
|
||||||
|
from src.backend.models.orm.task_result import TaskResult # noqa: E402
|
||||||
|
from src.backend.models.orm.user import User # noqa: E402
|
||||||
|
from src.backend.services import auth_service # noqa: E402
|
||||||
|
from src.backend.services.measurement_service import calculate_pass_fail # noqa: E402
|
||||||
|
|
||||||
|
CODES = ("FIERA-FLANGIA", "FIERA-ALBERO", "FIERA-DXF")
|
||||||
|
STATION_CODE = "ST-DEFAULT" # STATION_CODE del pacchetto Windows (avvio.py PREDEFINITI)
|
||||||
|
CAMERA_URL = "http://127.0.0.1:8765"
|
||||||
|
CAMERA_SOURCE = "camera:601020" # Itala sul PC fiera
|
||||||
|
SEED = 20261026 # seme fisso: dati ripetibili
|
||||||
|
FUSO = ZoneInfo("Europe/Rome") # le misure sono salvate in UTC naive, l'orario di turno e' locale
|
||||||
|
GIORNI_STORICO = 28 # circa 4 settimane
|
||||||
|
|
||||||
|
# (username, password, nome, ruoli, admin). Password "123" per tutti, chiesta da Adriano per la fiera (#85); il login non controlla la lunghezza.
|
||||||
|
UTENTI = [
|
||||||
|
("adriano", "123", "Adriano Dal Pastro",
|
||||||
|
["Maker", "MeasurementTec", "Metrologist", "Supervisor"], True),
|
||||||
|
("maker", "123", "Marco Ferrari (Maker)", ["Maker"], False),
|
||||||
|
("capoturno", "123", "Luca Bianchi (Capoturno)",
|
||||||
|
["Supervisor", "MeasurementTec"], False),
|
||||||
|
("operatore", "123", "Giulia Rossi (Operatore)", ["MeasurementTec"], False),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _quota(marker, descrizione, nominale, tol, unita="mm", dec=3):
|
||||||
|
"""Quota simmetrica: tolleranza = nominale +/- tol, warning al 70% della tolleranza."""
|
||||||
|
return dict(
|
||||||
|
marker_number=marker, description=descrizione, nominal=nominale,
|
||||||
|
ltl=round(nominale - tol, dec), lwl=round(nominale - 0.7 * tol, dec),
|
||||||
|
uwl=round(nominale + 0.7 * tol, dec), utl=round(nominale + tol, dec),
|
||||||
|
unit=unita,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Profilo statistico di una quota: scostamento medio (in frazioni di tol), sigma (in
|
||||||
|
# frazioni di tol) e deriva (frazione di tol aggiunta linearmente nell'ultima settimana).
|
||||||
|
# Cpk ~ (1 - |scostamento|) / (3 * sigma): sigma 0.2 -> 1,67; 0.33 -> 1,0.
|
||||||
|
FLANGIA = [
|
||||||
|
# (task, quota, scostamento, sigma, deriva)
|
||||||
|
(0, _quota(1, "Diametro esterno Ø80", 80.0, 0.05), 0.0, 0.20, 0.0), # Cpk ~1,67 capace
|
||||||
|
(0, _quota(2, "Diametro foro centrale Ø25", 25.0, 0.02), 0.0, 0.33, 0.0), # Cpk ~1,0 al limite
|
||||||
|
(1, _quota(3, "Spessore flangia", 12.0, 0.10), 0.0, 0.22, 0.55), # deriva ultima settimana
|
||||||
|
(1, _quota(4, "Interasse fori Ø60", 60.0, 0.10), 0.10, 0.25, 0.0), # Cpk ~1,2
|
||||||
|
(1, _quota(5, "Profondita' lamatura", 8.0, 0.15), 0.50, 0.244, 0.0), # ~2% fuori tolleranza
|
||||||
|
]
|
||||||
|
ALBERO = [
|
||||||
|
(0, _quota(1, "Diametro cuscinetto Ø20 h6", 20.0, 0.010), 0.0, 0.20, 0.0), # Cpk ~1,67
|
||||||
|
(0, _quota(2, "Diametro sede Ø25 j5", 25.0, 0.008), 0.0, 0.25, 0.0), # Cpk ~1,33
|
||||||
|
(1, _quota(3, "Lunghezza totale", 150.0, 0.10), 0.0, 0.33, 0.0), # Cpk ~1,0
|
||||||
|
(1, _quota(4, "Rugosita' Ra", 0.40, 0.20, unita="µm"), 0.20, 0.25, 0.0), # Cpk ~1,07
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ora_utc(giorno: date, minuti_da_mezzanotte: int) -> datetime:
|
||||||
|
"""Ora locale di officina -> datetime UTC naive, come le salva il backend."""
|
||||||
|
locale = datetime.combine(giorno, time(0, 0), tzinfo=FUSO) + timedelta(minutes=minuti_da_mezzanotte)
|
||||||
|
return locale.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _giorni_lavorativi(fine: date) -> list[date]:
|
||||||
|
"""Lun-ven degli ultimi GIORNI_STORICO giorni, fino a `fine` compreso."""
|
||||||
|
giorni = [fine - timedelta(days=i) for i in range(GIORNI_STORICO)]
|
||||||
|
return sorted(g for g in giorni if g.weekday() < 5)
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_giornalieri(rng: random.Random, n: int) -> list[int]:
|
||||||
|
"""n orari (minuti dalla mezzanotte) in 8-12 e 13-17, ogni ~30 min con un po' di scarto."""
|
||||||
|
mezz_ore = [8 * 60 + 30 * i for i in range(8)] + [13 * 60 + 30 * i for i in range(8)]
|
||||||
|
scelti = sorted(rng.sample(mezz_ore, n))
|
||||||
|
return [m + rng.randint(0, 9) for m in scelti]
|
||||||
|
|
||||||
|
|
||||||
|
async def _upsert_utenti(session: AsyncSession) -> dict[str, User]:
|
||||||
|
"""Crea gli utenti o, se esistono, ne aggiorna password, nome, ruoli e flag admin."""
|
||||||
|
utenti = {}
|
||||||
|
for username, password, nome, ruoli, admin in UTENTI:
|
||||||
|
esistente = (await session.execute(
|
||||||
|
select(User).where(User.username == username)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if esistente is None:
|
||||||
|
utenti[username] = await auth_service.create_user(
|
||||||
|
session, username=username, password=password, display_name=nome,
|
||||||
|
roles=ruoli, is_admin=admin,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
esistente.password_hash = auth_service.hash_password(password)
|
||||||
|
esistente.display_name = nome
|
||||||
|
esistente.roles = ruoli
|
||||||
|
esistente.is_admin = admin
|
||||||
|
esistente.active = True
|
||||||
|
utenti[username] = esistente
|
||||||
|
await session.flush()
|
||||||
|
return utenti
|
||||||
|
|
||||||
|
|
||||||
|
async def _upsert_stazione(session: AsyncSession, owner: User) -> Station:
|
||||||
|
"""La stazione del pacchetto, con la camera Itala; se c'e' gia' ne aggiorna la camera."""
|
||||||
|
stazione = (await session.execute(
|
||||||
|
select(Station).where(Station.code == STATION_CODE)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if stazione is None:
|
||||||
|
stazione = Station(
|
||||||
|
code=STATION_CODE, name="Postazione fiera", location="Stand",
|
||||||
|
created_by=owner.id,
|
||||||
|
)
|
||||||
|
session.add(stazione)
|
||||||
|
stazione.camera_url = CAMERA_URL
|
||||||
|
stazione.camera_source = CAMERA_SOURCE
|
||||||
|
await session.flush()
|
||||||
|
return stazione
|
||||||
|
|
||||||
|
|
||||||
|
async def _conta_e_cancella(session: AsyncSession, ricette: list[Recipe]) -> dict:
|
||||||
|
"""Toglie le ricette FIERA-* e tutto cio' che e' misurato contro di esse. Ritorna i conteggi."""
|
||||||
|
conti = {"misure": 0, "produzioni": 0, "eventi": 0}
|
||||||
|
for ricetta in ricette:
|
||||||
|
versioni = (await session.execute(
|
||||||
|
select(RecipeVersion.id).where(RecipeVersion.recipe_id == ricetta.id)
|
||||||
|
)).scalars().all()
|
||||||
|
runs = (await session.execute(
|
||||||
|
select(ProductionRun.id).where(ProductionRun.recipe_id == ricetta.id)
|
||||||
|
)).scalars().all()
|
||||||
|
if versioni:
|
||||||
|
conti["misure"] += (await session.execute(
|
||||||
|
delete(Measurement).where(Measurement.version_id.in_(versioni))
|
||||||
|
)).rowcount
|
||||||
|
await session.execute( # esiti dei task di queste versioni (FK sulle versioni)
|
||||||
|
delete(TaskResult).where(TaskResult.version_id.in_(versioni))
|
||||||
|
)
|
||||||
|
if runs:
|
||||||
|
conti["eventi"] += (await session.execute(
|
||||||
|
delete(ProductionEvent).where(ProductionEvent.run_id.in_(runs))
|
||||||
|
)).rowcount
|
||||||
|
conti["produzioni"] += (await session.execute(
|
||||||
|
delete(ProductionRun).where(ProductionRun.id.in_(runs))
|
||||||
|
)).rowcount
|
||||||
|
await session.execute(
|
||||||
|
delete(StationRecipeAssignment).where(StationRecipeAssignment.recipe_id == ricetta.id)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(RecipeVersionAudit).where(RecipeVersionAudit.recipe_id == ricetta.id)
|
||||||
|
)
|
||||||
|
# Versioni, task e quote se ne vanno con la cascade ORM della ricetta.
|
||||||
|
await session.delete(ricetta)
|
||||||
|
await session.flush()
|
||||||
|
return conti
|
||||||
|
|
||||||
|
|
||||||
|
async def _nuova_ricetta(session, owner, stazione, *, code, name, description,
|
||||||
|
interval, requires_serial, note_versione):
|
||||||
|
"""Ricetta + versione 1 corrente + assegnazione alla stazione."""
|
||||||
|
ricetta = Recipe(
|
||||||
|
code=code, name=name, description=description, created_by=owner.id, active=True,
|
||||||
|
measurement_interval_minutes=interval, requires_lot=True,
|
||||||
|
requires_serial=requires_serial, allow_manual_input=True,
|
||||||
|
)
|
||||||
|
session.add(ricetta)
|
||||||
|
await session.flush()
|
||||||
|
versione = RecipeVersion(
|
||||||
|
recipe_id=ricetta.id, version_number=1, is_current=True,
|
||||||
|
created_by=owner.id, change_notes=note_versione,
|
||||||
|
)
|
||||||
|
session.add(versione)
|
||||||
|
session.add(StationRecipeAssignment(
|
||||||
|
station_id=stazione.id, recipe_id=ricetta.id, assigned_by=owner.id,
|
||||||
|
))
|
||||||
|
await session.flush()
|
||||||
|
return ricetta, versione
|
||||||
|
|
||||||
|
|
||||||
|
async def _task(session, versione, ordine, titolo, tipo, direttiva=None,
|
||||||
|
descrizione=None, quote=()):
|
||||||
|
task = RecipeTask(
|
||||||
|
version_id=versione.id, order_index=ordine, title=titolo, task_type=tipo,
|
||||||
|
directive=direttiva, description=descrizione,
|
||||||
|
)
|
||||||
|
session.add(task)
|
||||||
|
await session.flush()
|
||||||
|
sottotask = []
|
||||||
|
for q in quote:
|
||||||
|
st = RecipeSubtask(task_id=task.id, **q)
|
||||||
|
session.add(st)
|
||||||
|
sottotask.append(st)
|
||||||
|
await session.flush()
|
||||||
|
return task, sottotask
|
||||||
|
|
||||||
|
|
||||||
|
async def _ricetta_meccanica(session, owner, stazione, *, code, name, description,
|
||||||
|
interval, requires_serial, profilo, titoli_task):
|
||||||
|
"""Ricetta con una nota iniziale e un task di misura per ogni gruppo di quote del profilo."""
|
||||||
|
ricetta, versione = await _nuova_ricetta(
|
||||||
|
session, owner, stazione, code=code, name=name, description=description,
|
||||||
|
interval=interval, requires_serial=requires_serial,
|
||||||
|
note_versione="Ricetta dimostrativa per la fiera",
|
||||||
|
)
|
||||||
|
await _task(session, versione, 0, "Preparazione del pezzo", "note",
|
||||||
|
direttiva="Prima di misurare",
|
||||||
|
descrizione="Pulire il pezzo e controllare che sia a **temperatura ambiente**.")
|
||||||
|
sottotask_per_quota = {}
|
||||||
|
for indice, titolo in enumerate(titoli_task):
|
||||||
|
quote = [q for (t, q, *_r) in profilo if t == indice]
|
||||||
|
_t, sottotask = await _task(
|
||||||
|
session, versione, indice + 1, titolo, "measure",
|
||||||
|
direttiva="Misurare le quote in sequenza", quote=quote,
|
||||||
|
)
|
||||||
|
for st in sottotask:
|
||||||
|
sottotask_per_quota[st.marker_number] = st
|
||||||
|
return ricetta, versione, sottotask_per_quota
|
||||||
|
|
||||||
|
|
||||||
|
def _valore(rng, quota, scost, sigma, deriva, frazione_tempo):
|
||||||
|
"""Un valore: normale attorno a nominale + scostamento (+ deriva nell'ultima settimana)."""
|
||||||
|
tol = quota["utl"] - quota["nominal"]
|
||||||
|
media = quota["nominal"] + tol * (scost + deriva * max(0.0, (frazione_tempo - 0.75) / 0.25))
|
||||||
|
return round(rng.gauss(media, tol * sigma), 6)
|
||||||
|
|
||||||
|
|
||||||
|
async def _storico(session, rng, fine, owner_ricetta, stazione, utenti, ricetta, versione,
|
||||||
|
sottotask, profilo, pezzi_giorno, prefisso_lotto, prefisso_seriale):
|
||||||
|
"""Una produzione chiusa per giorno lavorativo, con i suoi pezzi, eventi e misure."""
|
||||||
|
giorni = _giorni_lavorativi(fine)
|
||||||
|
operatore, capoturno = utenti["operatore"], utenti["capoturno"]
|
||||||
|
task_ids = {} # marker -> id del task di misura
|
||||||
|
for marker, st in sottotask.items():
|
||||||
|
task_ids[marker] = st.task_id
|
||||||
|
ordine_task = sorted(set(task_ids.values()))
|
||||||
|
ultimo_task = ordine_task[-1]
|
||||||
|
contatore = 0
|
||||||
|
totale = 0
|
||||||
|
for indice_giorno, giorno in enumerate(giorni):
|
||||||
|
frazione = indice_giorno / max(1, len(giorni) - 1) # 0 = primo giorno, 1 = ieri
|
||||||
|
n = rng.randint(*pezzi_giorno)
|
||||||
|
slot = _slot_giornalieri(rng, n)
|
||||||
|
lotto = f"{prefisso_lotto}{giorno:%y%m%d}"
|
||||||
|
inizio, fine_run = _ora_utc(giorno, slot[0] - 5), _ora_utc(giorno, slot[-1] + 10)
|
||||||
|
run = ProductionRun(
|
||||||
|
station_id=stazione.id, recipe_id=ricetta.id, version_id=versione.id,
|
||||||
|
operator_id=operatore.id, lot_number=lotto, status="closed",
|
||||||
|
measurement_interval_minutes=ricetta.measurement_interval_minutes,
|
||||||
|
cycle_count=n, started_at=inizio, closed_at=fine_run, closed_by=capoturno.id,
|
||||||
|
active_station_id=None,
|
||||||
|
)
|
||||||
|
session.add(run)
|
||||||
|
await session.flush()
|
||||||
|
session.add(ProductionEvent(run_id=run.id, event_type="start", user_id=operatore.id,
|
||||||
|
created_at=inizio))
|
||||||
|
for numero, minuti in enumerate(slot, start=1):
|
||||||
|
contatore += 1
|
||||||
|
seriale = f"{prefisso_seriale}{contatore:05d}"
|
||||||
|
istante = _ora_utc(giorno, minuti)
|
||||||
|
for marker in sorted(sottotask):
|
||||||
|
st = sottotask[marker]
|
||||||
|
_t, quota, scost, sigma, deriva = next(p for p in profilo if p[1]["marker_number"] == marker)
|
||||||
|
valore = _valore(rng, quota, scost, sigma, deriva, frazione)
|
||||||
|
esito, scarto = calculate_pass_fail(valore, st)
|
||||||
|
m = Measurement(
|
||||||
|
subtask_id=st.id, version_id=versione.id, measured_by=operatore.id,
|
||||||
|
value=valore, pass_fail=esito, deviation=scarto, lot_number=lotto,
|
||||||
|
serial_number=seriale,
|
||||||
|
input_method="usb_caliper" if rng.random() < 0.75 else "manual",
|
||||||
|
input_duration_ms=rng.randint(2500, 9000),
|
||||||
|
production_run_id=run.id,
|
||||||
|
# un fuori tolleranza passa solo con l'ok del capoturno (come in produzione)
|
||||||
|
supervisor_id=capoturno.id if esito == "fail" else None,
|
||||||
|
authorised_at=istante + timedelta(minutes=1) if esito == "fail" else None,
|
||||||
|
measured_at=istante + timedelta(seconds=20 * marker),
|
||||||
|
)
|
||||||
|
session.add(m)
|
||||||
|
totale += 1
|
||||||
|
# un task_measured per ogni task di misura non finale, poi il ciclo completato
|
||||||
|
for tid in ordine_task:
|
||||||
|
tipo = "cycle_completed" if tid == ultimo_task else "task_measured"
|
||||||
|
session.add(ProductionEvent(
|
||||||
|
run_id=run.id, event_type=tipo, user_id=operatore.id, task_id=tid,
|
||||||
|
created_at=istante + timedelta(minutes=1),
|
||||||
|
))
|
||||||
|
session.add(ProductionEvent(run_id=run.id, event_type="close", user_id=capoturno.id,
|
||||||
|
supervisor_id=capoturno.id, created_at=fine_run))
|
||||||
|
await session.flush()
|
||||||
|
return contatore, totale
|
||||||
|
|
||||||
|
|
||||||
|
async def _ricetta_dxf(session, owner, stazione):
|
||||||
|
"""Demo visione: un task dxf_compare, senza grafo (si crea in fiera dal pannello DXF)."""
|
||||||
|
ricetta, versione = await _nuova_ricetta(
|
||||||
|
session, owner, stazione, code="FIERA-DXF", name="Profilo DXF (visione)",
|
||||||
|
description="Confronto di un profilo con il disegno DXF tramite la camera. "
|
||||||
|
"La ricetta di visione si crea in fiera dal pannello «Crea ricetta da DXF».",
|
||||||
|
interval=None, requires_serial=False, note_versione="Ricetta dimostrativa per la fiera",
|
||||||
|
)
|
||||||
|
await _task(session, versione, 0, "Preparazione del pezzo", "note",
|
||||||
|
direttiva="Prima di misurare",
|
||||||
|
descrizione="Appoggiare il pezzo sul piano della camera, **in piano**.")
|
||||||
|
await _task(
|
||||||
|
session, versione, 1, "Confronto profilo con DXF", "dxf_compare",
|
||||||
|
direttiva="Acquisire e confrontare",
|
||||||
|
descrizione="Lo scostamento massimo del profilo rispetto al DXF deve restare entro la tolleranza.",
|
||||||
|
quote=[_quota(1, "Scostamento massimo profilo", 0.0, 0.15)],
|
||||||
|
)
|
||||||
|
return ricetta
|
||||||
|
|
||||||
|
|
||||||
|
async def seed(replace: bool, dry_run: bool, fine: date) -> int:
|
||||||
|
"""Esegue il seed in una sola transazione; con dry_run annulla tutto alla fine."""
|
||||||
|
rng = random.Random(SEED)
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
utenti = await _upsert_utenti(session)
|
||||||
|
owner = utenti["adriano"]
|
||||||
|
stazione = await _upsert_stazione(session, owner)
|
||||||
|
|
||||||
|
esistenti = list((await session.execute(
|
||||||
|
select(Recipe).where(Recipe.code.in_(CODES))
|
||||||
|
)).scalars().all())
|
||||||
|
if esistenti and not replace:
|
||||||
|
print(f"Ricette FIERA gia' presenti ({', '.join(r.code for r in esistenti)}): "
|
||||||
|
"non duplico. Usa --replace per rifarle.")
|
||||||
|
else:
|
||||||
|
if esistenti:
|
||||||
|
conti = await _conta_e_cancella(session, esistenti)
|
||||||
|
print(f"--replace: rimosse {', '.join(r.code for r in esistenti)} "
|
||||||
|
f"({conti['misure']} misure, {conti['produzioni']} produzioni, "
|
||||||
|
f"{conti['eventi']} eventi)")
|
||||||
|
|
||||||
|
r1, v1, sub1 = await _ricetta_meccanica(
|
||||||
|
session, owner, stazione, code="FIERA-FLANGIA", name="Flangia Ø80",
|
||||||
|
description="Flangia tornita in alluminio: diametri, spessore, interasse fori "
|
||||||
|
"e profondita' della lamatura. Lotto e seriale obbligatori.",
|
||||||
|
interval=30, requires_serial=True, profilo=FLANGIA,
|
||||||
|
titoli_task=["Misure diametrali", "Spessore e posizione fori"],
|
||||||
|
)
|
||||||
|
n1, m1 = await _storico(session, rng, fine, owner, stazione, utenti, r1, v1, sub1,
|
||||||
|
FLANGIA, (10, 14), "LF", "FL")
|
||||||
|
r2, v2, sub2 = await _ricetta_meccanica(
|
||||||
|
session, owner, stazione, code="FIERA-ALBERO", name="Albero rettificato",
|
||||||
|
description="Albero in acciaio rettificato: sedi dei cuscinetti, lunghezza e "
|
||||||
|
"rugosita'. Tolleranze strette.",
|
||||||
|
interval=30, requires_serial=False, profilo=ALBERO,
|
||||||
|
titoli_task=["Diametri rettificati", "Lunghezza e rugosita'"],
|
||||||
|
)
|
||||||
|
n2, m2 = await _storico(session, rng, fine, owner, stazione, utenti, r2, v2, sub2,
|
||||||
|
ALBERO, (8, 11), "LA", "AL")
|
||||||
|
await _ricetta_dxf(session, owner, stazione)
|
||||||
|
print(f"FIERA-FLANGIA: {n1} pezzi, {m1} misure; FIERA-ALBERO: {n2} pezzi, {m2} misure")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
await session.rollback()
|
||||||
|
print("--dry-run: nessuna modifica salvata.")
|
||||||
|
else:
|
||||||
|
await session.commit()
|
||||||
|
print(f"Seed fiera completato (stazione {stazione.code}, utenti: "
|
||||||
|
f"{', '.join(u[0] for u in UTENTI)}).")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _factory_per_file(db: Path):
|
||||||
|
"""Sessioni async su un file SQLite qualsiasi, con le stesse PRAGMA dell'app (FK attive)."""
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{db.resolve().as_posix()}", echo=False)
|
||||||
|
|
||||||
|
@event.listens_for(engine.sync_engine, "connect")
|
||||||
|
def _pragma(dbapi_connection, _record) -> None:
|
||||||
|
cur = dbapi_connection.cursor()
|
||||||
|
cur.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cur.execute("PRAGMA busy_timeout=5000")
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return engine, async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def _main_async(args) -> int:
|
||||||
|
global async_session_factory
|
||||||
|
engine, async_session_factory = _factory_per_file(args.db)
|
||||||
|
try:
|
||||||
|
fine = date.fromisoformat(args.fine) if args.fine else date.today() - timedelta(days=1)
|
||||||
|
return await seed(args.replace, args.dry_run, fine)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument("--db", required=True, type=Path, help="file SQLite con lo schema gia' creato")
|
||||||
|
parser.add_argument("--replace", action="store_true",
|
||||||
|
help="rimuove i dati FIERA-* (ricette, misure, produzioni) e li rifa'")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="esegue tutto e annulla")
|
||||||
|
parser.add_argument("--fine", default=None, help="ultimo giorno dello storico AAAA-MM-GG (default: ieri)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not args.db.is_file():
|
||||||
|
print(f"File non trovato: {args.db}")
|
||||||
|
return 2
|
||||||
|
return asyncio.run(_main_async(args))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""Test di scripts/seed_fiera.py (issue #85): il seed del PC della fiera.
|
||||||
|
|
||||||
|
Lo script non e' un modulo del pacchetto: si carica da file con importlib, come
|
||||||
|
test_seed_collaudo. La parte "dato" gira sul DB in memoria dei test; l'ultimo test
|
||||||
|
percorre invece la strada vera (file SQLite con `alembic upgrade head` + --db).
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import sqlite3
|
||||||
|
from argparse import Namespace
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from src.backend.config import Settings, settings
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.recipe import Recipe
|
||||||
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services.auth_service import authenticate_user
|
||||||
|
from src.backend.tests.conftest import TestSessionFactory, test_engine
|
||||||
|
|
||||||
|
_SCRIPT_PATH = Path(__file__).resolve().parents[3] / "scripts" / "seed_fiera.py"
|
||||||
|
_ALEMBIC_INI = Path(__file__).resolve().parents[1] / "migrations" / "alembic.ini"
|
||||||
|
FINE = date(2026, 10, 22) # data fissa: il test non dipende dal giorno in cui gira
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def seed_fiera(monkeypatch):
|
||||||
|
spec = importlib.util.spec_from_file_location("seed_fiera", _SCRIPT_PATH)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
monkeypatch.setattr(module, "async_session_factory", TestSessionFactory)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
async def _utente(db_session, username) -> User:
|
||||||
|
return (await db_session.execute(
|
||||||
|
select(User).where(User.username == username)
|
||||||
|
)).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSeedFiera:
|
||||||
|
async def test_utenti_ruoli_e_login(self, seed_fiera, db_session):
|
||||||
|
assert await seed_fiera.seed(False, False, FINE) == 0
|
||||||
|
|
||||||
|
attesi = {
|
||||||
|
"adriano": ("123", True),
|
||||||
|
"maker": ("123", False),
|
||||||
|
"capoturno": ("123", False),
|
||||||
|
"operatore": ("123", False),
|
||||||
|
}
|
||||||
|
for username, (password, admin) in attesi.items():
|
||||||
|
utente = await authenticate_user(db_session, username, password)
|
||||||
|
assert utente is not None, username
|
||||||
|
assert utente.is_admin is admin
|
||||||
|
assert (await _utente(db_session, "maker")).roles == ["Maker"]
|
||||||
|
assert "Supervisor" in (await _utente(db_session, "capoturno")).roles
|
||||||
|
assert (await _utente(db_session, "operatore")).roles == ["MeasurementTec"]
|
||||||
|
assert {"Maker", "Metrologist"} <= set((await _utente(db_session, "adriano")).roles)
|
||||||
|
|
||||||
|
async def test_setup_risulta_completato(self, seed_fiera, client, monkeypatch):
|
||||||
|
import src.backend.api.routers.setup as setup_mod
|
||||||
|
monkeypatch.setattr(settings, "setup_password", "test-setup-password")
|
||||||
|
monkeypatch.setattr(setup_mod, "engine", test_engine)
|
||||||
|
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
|
||||||
|
|
||||||
|
# prima del seed il frontend vede il setup da fare, dopo no
|
||||||
|
assert (await client.get("/api/setup/status")).json() == {"initialised": False}
|
||||||
|
await seed_fiera.seed(False, False, FINE)
|
||||||
|
assert (await client.get("/api/setup/status")).json() == {"initialised": True}
|
||||||
|
|
||||||
|
async def test_stazione_e_ricette(self, seed_fiera, db_session):
|
||||||
|
await seed_fiera.seed(False, False, FINE)
|
||||||
|
|
||||||
|
stazione = (await db_session.execute(
|
||||||
|
select(Station).where(Station.code == "ST-DEFAULT")
|
||||||
|
)).scalar_one()
|
||||||
|
assert stazione.camera_url == "http://127.0.0.1:8765"
|
||||||
|
assert stazione.camera_source == "camera:601020"
|
||||||
|
|
||||||
|
assegnate = (await db_session.execute(
|
||||||
|
select(Recipe.code)
|
||||||
|
.join(StationRecipeAssignment, StationRecipeAssignment.recipe_id == Recipe.id)
|
||||||
|
.where(StationRecipeAssignment.station_id == stazione.id)
|
||||||
|
)).scalars().all()
|
||||||
|
assert sorted(assegnate) == ["FIERA-ALBERO", "FIERA-DXF", "FIERA-FLANGIA"]
|
||||||
|
|
||||||
|
# la ricetta DXF ha un task dxf_compare e nessun grafo di visione
|
||||||
|
from src.backend.models.orm.task import RecipeTask
|
||||||
|
dxf = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.task_type == "dxf_compare")
|
||||||
|
)).scalars().all()
|
||||||
|
assert len(dxf) == 1 and dxf[0].vision_json is None
|
||||||
|
|
||||||
|
async def test_misure_nel_numero_atteso(self, seed_fiera, db_session):
|
||||||
|
await seed_fiera.seed(False, False, FINE)
|
||||||
|
|
||||||
|
# ogni pezzo ha una misura per quota: pezzi 150-300 per ricetta
|
||||||
|
for codice, quote in (("FIERA-FLANGIA", 5), ("FIERA-ALBERO", 4)):
|
||||||
|
misure = (await db_session.execute(
|
||||||
|
select(func.count(Measurement.id))
|
||||||
|
.where(Measurement.version_id.in_(
|
||||||
|
select(seed_fiera.RecipeVersion.id)
|
||||||
|
.join(Recipe, Recipe.id == seed_fiera.RecipeVersion.recipe_id)
|
||||||
|
.where(Recipe.code == codice)
|
||||||
|
))
|
||||||
|
)).scalar_one()
|
||||||
|
assert misure % quote == 0
|
||||||
|
assert 150 <= misure // quote <= 300, (codice, misure // quote)
|
||||||
|
|
||||||
|
# lun-ven soltanto, e le misure compaiono nelle produzioni
|
||||||
|
giorni = (await db_session.execute(
|
||||||
|
select(seed_fiera.ProductionRun.started_at)
|
||||||
|
)).scalars().all()
|
||||||
|
assert giorni and all(g.weekday() < 5 for g in giorni)
|
||||||
|
senza_run = (await db_session.execute(
|
||||||
|
select(func.count(Measurement.id)).where(Measurement.production_run_id.is_(None))
|
||||||
|
)).scalar_one()
|
||||||
|
assert senza_run == 0
|
||||||
|
|
||||||
|
async def test_statistiche_numeriche(self, seed_fiera, client, db_session):
|
||||||
|
await seed_fiera.seed(False, False, FINE)
|
||||||
|
adriano = await _utente(db_session, "adriano")
|
||||||
|
from src.backend.services.auth_service import issue_api_key
|
||||||
|
chiave = await issue_api_key(db_session, adriano)
|
||||||
|
intestazioni = {"X-API-Key": chiave}
|
||||||
|
|
||||||
|
flangia = (await db_session.execute(
|
||||||
|
select(Recipe).where(Recipe.code == "FIERA-FLANGIA")
|
||||||
|
)).scalar_one()
|
||||||
|
quote = (await client.get(
|
||||||
|
"/api/statistics/subtasks", headers=intestazioni,
|
||||||
|
params={"recipe_id": flangia.id},
|
||||||
|
)).json()
|
||||||
|
assert len(quote) == 5
|
||||||
|
cpk = []
|
||||||
|
for quota in quote:
|
||||||
|
risposta = await client.get(
|
||||||
|
"/api/statistics/capability", headers=intestazioni,
|
||||||
|
params={"recipe_id": flangia.id, "subtask_id": quota["id"]},
|
||||||
|
)
|
||||||
|
assert risposta.status_code == 200
|
||||||
|
d = risposta.json()
|
||||||
|
for campo in ("mean", "std_dev", "cp", "cpk"):
|
||||||
|
assert isinstance(d[campo], (int, float)), (quota["id"], campo)
|
||||||
|
cpk.append(d["cpk"])
|
||||||
|
# Cpk diversi per quota: una capace, una al limite o sotto
|
||||||
|
assert max(cpk) > 1.4
|
||||||
|
assert min(cpk) < 1.1
|
||||||
|
|
||||||
|
async def test_secondo_giro_non_duplica_e_replace_rifa(self, seed_fiera, db_session):
|
||||||
|
async def conta():
|
||||||
|
return [
|
||||||
|
(await db_session.execute(select(func.count(t.id)))).scalar_one()
|
||||||
|
for t in (Recipe, Measurement, User)
|
||||||
|
]
|
||||||
|
|
||||||
|
await seed_fiera.seed(False, False, FINE)
|
||||||
|
primo = await conta()
|
||||||
|
await seed_fiera.seed(False, False, FINE)
|
||||||
|
assert await conta() == primo # utenti aggiornati, ricette e misure non duplicate
|
||||||
|
|
||||||
|
await seed_fiera.seed(True, False, FINE)
|
||||||
|
assert await conta() == primo # stessi numeri: il seme e' fisso
|
||||||
|
|
||||||
|
async def test_dry_run_non_salva(self, seed_fiera, db_session):
|
||||||
|
await seed_fiera.seed(False, True, FINE)
|
||||||
|
assert (await db_session.execute(select(func.count(User.id)))).scalar_one() == 0
|
||||||
|
assert (await db_session.execute(select(func.count(Measurement.id)))).scalar_one() == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_file_sqlite_con_alembic(seed_fiera, tmp_path, monkeypatch):
|
||||||
|
"""La strada vera: schema da `alembic upgrade head`, poi il seed con --db."""
|
||||||
|
db = tmp_path / "tmf.db"
|
||||||
|
url = f"sqlite+aiosqlite:///{db.as_posix()}"
|
||||||
|
monkeypatch.setattr(Settings, "database_url", property(lambda self: url))
|
||||||
|
# alembic fa asyncio.run(): fuori dal loop di pytest-asyncio, in un thread
|
||||||
|
import asyncio
|
||||||
|
await asyncio.to_thread(command.upgrade, Config(str(_ALEMBIC_INI)), "head")
|
||||||
|
|
||||||
|
argomenti = Namespace(db=db, replace=False, dry_run=False, fine=FINE.isoformat())
|
||||||
|
assert await seed_fiera._main_async(argomenti) == 0
|
||||||
|
|
||||||
|
con = sqlite3.connect(db)
|
||||||
|
try:
|
||||||
|
assert con.execute("SELECT COUNT(*) FROM users WHERE is_admin=1").fetchone()[0] == 1
|
||||||
|
assert con.execute("SELECT COUNT(*) FROM recipes").fetchone()[0] == 3
|
||||||
|
assert con.execute("SELECT COUNT(*) FROM measurements").fetchone()[0] > 1000
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
Reference in New Issue
Block a user