Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a68a9995e | |||
| 8431aa7095 | |||
| 23012fdfbb | |||
| cd40b81f1e | |||
| 6e60e0b1a9 | |||
| 6bc6c0c0d5 | |||
| e5989c9d23 | |||
| 0af6692786 | |||
| 36074ab2f7 | |||
| fd1bbce8aa | |||
| 1453b31cee | |||
| 192d4b590c | |||
| b9f43a0ede | |||
| 5bf374aadd | |||
| 74fe5665b5 | |||
| be904bac07 | |||
| 011c08c9d8 | |||
| 82ad88538d | |||
| bafc776ef7 | |||
| 8fcb4796d5 | |||
| 8d7fa9a12a | |||
| c911449b7c | |||
| ac8604eff5 |
+1
-1
@@ -1,3 +1,3 @@
|
||||
[submodule "vendor/visionsuite"]
|
||||
path = vendor/visionsuite
|
||||
url = ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git
|
||||
url = ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git
|
||||
|
||||
@@ -287,9 +287,9 @@ Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120
|
||||
|
||||
## Visione (V3.0.0)
|
||||
|
||||
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** su `ssh://git.tielogic.xyz:222`: un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
|
||||
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** che punta al repo TieVision-Core (`ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git`, ex visionsuite, rinominato il 15/10/2026; `vs-gui` e `vs-assistant` sono ora in TieVision-Studio e TieVision-Assistant, che TieMeasureFlow non usa; il path resta `vendor/visionsuite`): un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
|
||||
|
||||
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. Pin attuale: bc0df16.
|
||||
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. La regola vale ora per `origin/main` di TieVision-Core. Pin attuale: 2e73691.
|
||||
|
||||
Il worker gira in un container separato (`Dockerfile.vision`), non pubblica porte verso l'esterno ed è raggiungibile solo dal server all'indirizzo interno `http://vision:8100`. Il container non ha accesso al repository Git del progetto principale, quindi il commit di VisionSuite in uso (`engine_version`) non può essere scoperto dentro l'immagine: va stampato al momento del build leggendo il commit dalla macchina che esegue `docker compose build`, con la variabile `VISION_ENGINE_VERSION`. Una build senza questa variabile fallisce subito, invece di produrre un worker che risponde con una versione indovinata:
|
||||
|
||||
|
||||
@@ -47,6 +47,13 @@ WORKDIR /app/flask_app
|
||||
RUN uv run --project /app pybabel compile -d translations && \
|
||||
chown -R app:app /app /tmp/uv-cache
|
||||
|
||||
# Commit mostrato nel piede di pagina (#70). .git e' escluso dal contesto di
|
||||
# build (.dockerignore), quindi lo passa chi costruisce; facoltativo, senza il
|
||||
# piede mostra solo la versione. Uso:
|
||||
# APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client
|
||||
ARG APP_COMMIT=""
|
||||
ENV APP_COMMIT=${APP_COMMIT}
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
@@ -333,6 +333,7 @@ TieMeasureFlow/
|
||||
|---|---|
|
||||
| `docker compose -f docker-compose.dev.yml up -d --build` | Avvia servizi in sviluppo (build incluso) |
|
||||
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
|
||||
| `APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client` | Ricostruisci il client mostrando nel piè di pagina il commit (oltre alla versione di `pyproject.toml`); senza `APP_COMMIT` compare solo la versione |
|
||||
| `docker compose logs -f server` | Segui log server in tempo reale |
|
||||
| `docker compose logs -f client` | Segui log client in tempo reale |
|
||||
| `docker compose ps` | Stato di tutti i servizi |
|
||||
@@ -515,6 +516,22 @@ Copia `.env.example` in `.env` e configura:
|
||||
|
||||
---
|
||||
|
||||
## Camera di stazione (demo fiera, #72)
|
||||
|
||||
Per acquisire il fotogramma da una camera locale (per esempio quella del TC bench) senza passare dall'upload, sul PC della stazione gira un piccolo servizio nativo, fuori da Docker, perché la camera GigE non si raggiunge dal container. Si avvia dalla radice del repository con l'ambiente di VisionSuite (`uv sync --all-packages` in `vendor/visionsuite`):
|
||||
|
||||
```bash
|
||||
vendor/visionsuite/.venv/bin/python scripts/camera_service.py [--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>]
|
||||
```
|
||||
|
||||
Con `--folder` la sorgente `camera:folder` restituisce a rotazione le immagini della cartella, utile per provare senza hardware. Su Linux, perché il container `client` lo raggiunga, si usa `--host 172.17.0.1` (gateway di docker0; il compose di sviluppo mappa `host.docker.internal` su quell'host). Su Windows con Docker Desktop si presume che `host.docker.internal` raggiunga il loopback dell'host, ma va verificato sul PC della demo.
|
||||
|
||||
⚠️ Il servizio non ha autenticazione: un `--host` diverso dal loopback va usato solo su una rete isolata.
|
||||
|
||||
Nella schermata Stazioni si compilano `camera_url` (per esempio `http://host.docker.internal:8765`) e `camera_source` (`camera:<seriale>` oppure `camera:folder`). Per avere due postazioni sullo stesso PC si abilita `STATION_SWITCH_ENABLED`.
|
||||
|
||||
---
|
||||
|
||||
## Documentazione
|
||||
|
||||
Indice completo: [`docs/README.md`](docs/README.md).
|
||||
|
||||
@@ -75,6 +75,9 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
args:
|
||||
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
|
||||
APP_COMMIT: ${APP_COMMIT:-}
|
||||
container_name: tmflow-client
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
@@ -85,6 +88,9 @@ services:
|
||||
# on-premise su LAN in HTTP semplice ha bisogno del cookie non-secure,
|
||||
# altrimenti il browser non lo rimanda mai indietro.
|
||||
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
|
||||
# #72: da container si raggiunge il servizio camera (vs-gui) sull'host Linux
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -89,6 +89,9 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
args:
|
||||
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
|
||||
APP_COMMIT: ${APP_COMMIT:-}
|
||||
container_name: tmflow-client
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Servizio camera minimo di stazione (TieMeasureFlow, #72).
|
||||
|
||||
Cosa fa: espone via HTTP l'acquisizione di una camera (vs-camera: GigE/GenTL,
|
||||
OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
|
||||
`grab` (copiato da TieVision-Studio, src/visionsuite/gui/server.py):
|
||||
GET /camera/sources -> {"sources": [{name, kind, label}]}
|
||||
POST /camera/grab json {source, trigger="continuo", timeout_ms=5000} -> PNG
|
||||
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
|
||||
Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo
|
||||
raggiunge; questo servizio permette di scegliere l'host di ascolto.
|
||||
|
||||
Uso (dalla radice del repo):
|
||||
vendor/visionsuite/.venv/bin/python scripts/camera_service.py \
|
||||
[--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>]
|
||||
--folder abilita la sorgente finta `camera:folder` (loop sulle immagini).
|
||||
Da Docker su Linux: --host 172.17.0.1 (gateway di docker0).
|
||||
|
||||
Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite):
|
||||
vs-camera, fastapi, uvicorn, opencv.
|
||||
|
||||
AVVERTENZA: nessuna autenticazione. Un --host diverso dal loopback espone la
|
||||
camera a chiunque raggiunga la porta: solo su rete isolata.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
from fastapi import Body, FastAPI, HTTPException
|
||||
from fastapi.responses import Response
|
||||
|
||||
from visionsuite.camera.camera_interface import CameraError, CameraGrabError
|
||||
from visionsuite.camera.sources import SourceNotFound, list_sources, open_source
|
||||
|
||||
_TRIGGER = ("continuo",)
|
||||
|
||||
# errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso
|
||||
_MESSAGGI = {
|
||||
"Camera is not open": "La camera non è aperta",
|
||||
"Camera is not grabbing": "La camera non sta acquisendo",
|
||||
"Grab cancelled (grabbing stopped)": "Acquisizione annullata (acquisizione fermata)",
|
||||
"Failed to read frame from RTSP stream": "Lettura del fotogramma dallo stream RTSP non riuscita",
|
||||
}
|
||||
_PREFISSI = (
|
||||
("Failed to open OpenCV device", "Apertura del dispositivo OpenCV non riuscita"),
|
||||
("Failed to open RTSP stream", "Apertura dello stream RTSP non riuscita"),
|
||||
("Failed to reconnect to RTSP stream", "Riconnessione allo stream RTSP non riuscita"),
|
||||
("Failed to read frame after", "Lettura del fotogramma non riuscita dopo"),
|
||||
)
|
||||
|
||||
|
||||
def _traduci(messaggio: str) -> str:
|
||||
if messaggio in _MESSAGGI:
|
||||
return _MESSAGGI[messaggio]
|
||||
for inglese, italiano in _PREFISSI:
|
||||
if messaggio.startswith(inglese):
|
||||
return italiano + messaggio[len(inglese):]
|
||||
return messaggio
|
||||
|
||||
|
||||
def create_app(folder: Path | str | None = None) -> FastAPI:
|
||||
aperte: dict = {}
|
||||
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
|
||||
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
|
||||
|
||||
def _config() -> dict:
|
||||
if folder is None:
|
||||
return {}
|
||||
return {"folder": {"folder": str(folder), "loop": True}}
|
||||
|
||||
def _lock_di(nome: str) -> threading.Lock:
|
||||
with lock:
|
||||
return lock_sorgenti.setdefault(nome, threading.Lock())
|
||||
|
||||
@asynccontextmanager
|
||||
async def _vita(_app):
|
||||
yield
|
||||
with lock: # alla chiusura rilascia le camere aperte
|
||||
sorgenti = list(aperte.values())
|
||||
aperte.clear()
|
||||
for s in sorgenti:
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
app = FastAPI(title="TMF camera service", lifespan=_vita)
|
||||
|
||||
@app.get("/camera/sources")
|
||||
def camera_sources():
|
||||
voci = list_sources(None, cameras=True, config=_config())
|
||||
return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label}
|
||||
for v in voci if v.kind in ("camera", "virtual")]}
|
||||
|
||||
@app.post("/camera/grab")
|
||||
def camera_grab(corpo: dict = Body(default=None)):
|
||||
nome = corpo.get("source") if isinstance(corpo, dict) else None
|
||||
if not isinstance(nome, str) or not nome.strip():
|
||||
raise HTTPException(400, "manca «source»")
|
||||
nome = nome.strip()
|
||||
trigger = corpo.get("trigger", "continuo")
|
||||
if trigger not in _TRIGGER:
|
||||
raise HTTPException(400, "«trigger» supportato: solo continuo")
|
||||
timeout = corpo.get("timeout_ms", 5000)
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
|
||||
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
|
||||
try:
|
||||
with _lock_di(nome): # solo questa sorgente; le altre non aspettano
|
||||
with lock:
|
||||
sorgente = aperte.get(nome)
|
||||
if sorgente is None:
|
||||
sorgente = open_source(nome, None, config=_config())
|
||||
with lock:
|
||||
aperte[nome] = sorgente
|
||||
frame = sorgente.grab(timeout)
|
||||
except SourceNotFound as e:
|
||||
raise HTTPException(404, _traduci(str(e)))
|
||||
except TimeoutError as e:
|
||||
raise HTTPException(504, _traduci(str(e)) or "timeout")
|
||||
except (CameraGrabError, CameraError) as e:
|
||||
raise HTTPException(503, _traduci(str(e)))
|
||||
ok, buf = cv2.imencode(".png", frame)
|
||||
if not ok:
|
||||
raise HTTPException(500, "codifica PNG fallita")
|
||||
return Response(buf.tobytes(), media_type="image/png")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import uvicorn
|
||||
p = argparse.ArgumentParser(description="Servizio camera minimo di stazione (#72)")
|
||||
p.add_argument("--host", default="127.0.0.1")
|
||||
p.add_argument("--port", type=int, default=8765)
|
||||
p.add_argument("--folder", default=None, help="cartella immagini per camera:folder")
|
||||
a = p.parse_args()
|
||||
uvicorn.run(create_app(a.folder), host=a.host, port=a.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Test di scripts/camera_service.py (#72).
|
||||
|
||||
Uso: vendor/visionsuite/.venv/bin/python -m pytest scripts/tests/test_camera_service.py -q
|
||||
Dipende da: venv di VisionSuite (vs-camera, fastapi, httpx, opencv, pytest).
|
||||
"""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
|
||||
|
||||
|
||||
def _carica():
|
||||
spec = importlib.util.spec_from_file_location("camera_service", MODULO)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cartella(tmp_path):
|
||||
for nome, (w, h) in {"a.png": (64, 48), "b.png": (64, 48)}.items():
|
||||
cv2.imwrite(str(tmp_path / nome), np.full((h, w, 3), 128, np.uint8))
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(cartella):
|
||||
mod = _carica()
|
||||
with TestClient(mod.create_app(cartella)) as c:
|
||||
c.mod = mod
|
||||
yield c
|
||||
|
||||
|
||||
def test_sources_contiene_folder(client):
|
||||
r = client.get("/camera/sources")
|
||||
assert r.status_code == 200
|
||||
nomi = [s["name"] for s in r.json()["sources"]]
|
||||
assert "camera:folder" in nomi
|
||||
voce = next(s for s in r.json()["sources"] if s["name"] == "camera:folder")
|
||||
assert {"name", "kind", "label"} <= set(voce)
|
||||
|
||||
|
||||
def test_grab_folder_restituisce_png(client):
|
||||
r = client.post("/camera/grab", json={"source": "camera:folder"})
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "image/png"
|
||||
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
|
||||
assert img.shape[:2] == (48, 64)
|
||||
|
||||
|
||||
def test_grab_ripetuto_in_loop(client):
|
||||
# due immagini in cartella, tre grab: il loop non deve esaurirsi
|
||||
for _ in range(3):
|
||||
assert client.post("/camera/grab", json={"source": "camera:folder"}).status_code == 200
|
||||
|
||||
|
||||
def test_source_mancante_400(client):
|
||||
assert client.post("/camera/grab", json={}).status_code == 400
|
||||
assert client.post("/camera/grab", json={"source": " "}).status_code == 400
|
||||
|
||||
|
||||
def test_source_inesistente_404(client):
|
||||
assert client.post("/camera/grab", json={"source": "camera:non-esiste"}).status_code == 404
|
||||
|
||||
|
||||
def test_timeout_non_valido_400(client):
|
||||
r = client.post("/camera/grab", json={"source": "camera:folder", "timeout_ms": 0})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_errore_camera_503_tradotto(client, monkeypatch):
|
||||
from visionsuite.camera.camera_interface import CameraGrabError
|
||||
|
||||
class Rotta:
|
||||
def grab(self, timeout):
|
||||
raise CameraGrabError("Camera is not open")
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(client.mod, "open_source", lambda *a, **k: Rotta())
|
||||
r = client.post("/camera/grab", json={"source": "camera:finta"})
|
||||
assert r.status_code == 503
|
||||
assert r.json()["detail"] == "La camera non è aperta"
|
||||
|
||||
|
||||
def test_timeout_504(client, monkeypatch):
|
||||
class Lenta:
|
||||
def grab(self, timeout):
|
||||
raise TimeoutError("scaduto")
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(client.mod, "open_source", lambda *a, **k: Lenta())
|
||||
r = client.post("/camera/grab", json={"source": "camera:lenta"})
|
||||
assert r.status_code == 504
|
||||
|
||||
|
||||
def test_sorgente_aperta_una_volta(client, monkeypatch):
|
||||
aperture = []
|
||||
reale = client.mod.open_source
|
||||
|
||||
def conta(*a, **k):
|
||||
aperture.append(a[0])
|
||||
return reale(*a, **k)
|
||||
|
||||
monkeypatch.setattr(client.mod, "open_source", conta)
|
||||
for _ in range(3):
|
||||
client.post("/camera/grab", json={"source": "camera:folder"})
|
||||
assert aperture == ["camera:folder"]
|
||||
@@ -67,6 +67,21 @@ async def create_new_station(
|
||||
# The int-typed station_id param already guards against "by-code" being
|
||||
# matched as a station id, but keeping the explicit order avoids surprises
|
||||
# during refactors (e.g. if someone regroups handlers by HTTP method).
|
||||
@router.get("/by-code/{code}", response_model=StationResponse)
|
||||
async def get_station_by_code(
|
||||
code: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a station by its code (any authenticated user, like /by-code/{code}/recipes) (#72)."""
|
||||
station = await station_service.get_station_by_code(db, code)
|
||||
if station is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Station not found",
|
||||
)
|
||||
return StationResponse.model_validate(station)
|
||||
|
||||
|
||||
@router.get("/by-code/{code}/recipes", response_model=list[RecipeSummary])
|
||||
async def list_recipes_by_station_code(
|
||||
code: str,
|
||||
|
||||
@@ -12,6 +12,7 @@ from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.task_result import TaskResult # #58: esiti dei task della sessione
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.statistics import (
|
||||
CapabilityData,
|
||||
@@ -294,3 +295,76 @@ async def get_recipe_runs(
|
||||
}
|
||||
for r in runs
|
||||
]
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/task-results")
|
||||
async def get_run_task_results(
|
||||
run_id: int,
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""#58: esiti, note, campi e allegati dei task di una sessione di produzione.
|
||||
|
||||
Endpoint di statistica (require_metrologist): GET /api/task-results vuole
|
||||
il ruolo MeasurementTec e non si puo' riusare qui.
|
||||
"""
|
||||
run = (await db.execute(
|
||||
select(ProductionRun.id).where(ProductionRun.id == run_id)
|
||||
)).scalar_one_or_none()
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Sessione non trovata")
|
||||
|
||||
# #58: una query per i risultati della run, una per i task, una per gli utenti.
|
||||
results = (await db.execute(
|
||||
select(TaskResult)
|
||||
.where(TaskResult.production_run_id == run_id)
|
||||
.order_by(TaskResult.created_at.asc(), TaskResult.id.asc())
|
||||
)).scalars().all()
|
||||
|
||||
task_ids = {r.task_id for r in results}
|
||||
tasks = {}
|
||||
if task_ids:
|
||||
tasks = {t.id: t for t in (await db.execute(
|
||||
select(RecipeTask).where(RecipeTask.id.in_(task_ids))
|
||||
)).scalars().all()}
|
||||
user_ids = {r.user_id for r in results}
|
||||
names = {}
|
||||
if user_ids:
|
||||
names = dict((await db.execute(
|
||||
select(User.id, User.display_name).where(User.id.in_(user_ids))
|
||||
)).all())
|
||||
|
||||
counts = {"ok": 0, "nok": 0, "done": 0}
|
||||
out = []
|
||||
for r in results:
|
||||
counts[r.outcome] = counts.get(r.outcome, 0) + 1
|
||||
task = tasks.get(r.task_id)
|
||||
# #58: etichette dai campi del task; se il campo e' stato tolto vale la chiave.
|
||||
labels = {
|
||||
f.get("key"): f.get("label") or f.get("key")
|
||||
for f in (task.fields or []) if isinstance(f, dict)
|
||||
} if task else {}
|
||||
out.append({
|
||||
"id": r.id,
|
||||
"task_id": r.task_id,
|
||||
"task_title": task.title if task else None,
|
||||
"task_type": task.task_type if task else None,
|
||||
"outcome": r.outcome,
|
||||
"note": r.note,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"user_name": names.get(r.user_id),
|
||||
"values": [
|
||||
{"key": k, "label": labels.get(k, k), "value": v}
|
||||
for k, v in (r.values or {}).items()
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"key": a.get("key"),
|
||||
"label": labels.get(a.get("key"), a.get("key")),
|
||||
"name": a.get("name"),
|
||||
"path": a.get("path"),
|
||||
}
|
||||
for a in (r.attachments or []) if isinstance(a, dict)
|
||||
],
|
||||
})
|
||||
return {"run_id": run_id, "counts": counts, "results": out}
|
||||
|
||||
@@ -22,24 +22,44 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'api_keys',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('key_hash', sa.String(64), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
||||
),
|
||||
)
|
||||
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
||||
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
||||
# Idempotent on purpose (#71): a database born from create_all has no
|
||||
# `uq_users_api_key` (only the unique index `ix_users_api_key`), and on
|
||||
# MySQL a run that dies half way (no transactional DDL) leaves `api_keys`
|
||||
# created and `alembic_version` still at 014. Every step checks first.
|
||||
insp = sa.inspect(op.get_bind())
|
||||
|
||||
if 'api_keys' not in insp.get_table_names():
|
||||
op.create_table(
|
||||
'api_keys',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('key_hash', sa.String(64), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
||||
),
|
||||
)
|
||||
existing_idx: set[str] = set()
|
||||
else:
|
||||
existing_idx = {i['name'] for i in insp.get_indexes('api_keys')}
|
||||
|
||||
if 'ix_api_keys_key_hash' not in existing_idx:
|
||||
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
||||
if 'ix_api_keys_user_id' not in existing_idx:
|
||||
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
||||
|
||||
if 'api_key' not in {c['name'] for c in insp.get_columns('users')}:
|
||||
return # column already gone: nothing else to do
|
||||
|
||||
user_idx = {i['name'] for i in insp.get_indexes('users')}
|
||||
user_uq = {u['name'] for u in insp.get_unique_constraints('users')}
|
||||
with op.batch_alter_table('users') as batch:
|
||||
batch.drop_index('ix_users_api_key')
|
||||
batch.drop_constraint('uq_users_api_key', type_='unique')
|
||||
if 'ix_users_api_key' in user_idx:
|
||||
batch.drop_index('ix_users_api_key')
|
||||
if 'uq_users_api_key' in user_uq:
|
||||
batch.drop_constraint('uq_users_api_key', type_='unique')
|
||||
batch.drop_column('api_key')
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""stations.camera_url e stations.camera_source (#72)
|
||||
|
||||
stations.camera_url: servizio camera della stazione (vs-gui o equivalente).
|
||||
stations.camera_source: sorgente da chiedere al servizio, es. camera:<seriale>
|
||||
o camera:folder.
|
||||
|
||||
Revision ID: 020_station_camera
|
||||
Revises: 019_station_vision_worker
|
||||
Create Date: 2026-10-15
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '020_station_camera'
|
||||
down_revision: Union[str, None] = '019_station_vision_worker'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
|
||||
with op.batch_alter_table('stations') as batch:
|
||||
batch.add_column(sa.Column('camera_url', sa.String(500), nullable=True))
|
||||
batch.add_column(sa.Column('camera_source', sa.String(200), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('stations') as batch:
|
||||
batch.drop_column('camera_source')
|
||||
batch.drop_column('camera_url')
|
||||
@@ -5,18 +5,35 @@ from typing import Optional
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
def _normalize_worker_url(value: Optional[str]) -> Optional[str]:
|
||||
"""Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45)."""
|
||||
def _normalize_http_url(value: Optional[str], field: str) -> Optional[str]:
|
||||
"""Vuoto/spazi -> None; altrimenti http(s) senza '/' finale."""
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
if not value.lower().startswith(("http://", "https://")):
|
||||
raise ValueError("vision_worker_url must start with http:// or https://")
|
||||
raise ValueError(f"{field} must start with http:// or https://")
|
||||
return value.rstrip("/")
|
||||
|
||||
|
||||
def _normalize_worker_url(value: Optional[str]) -> Optional[str]:
|
||||
"""Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45)."""
|
||||
return _normalize_http_url(value, "vision_worker_url")
|
||||
|
||||
|
||||
def _normalize_camera_url(value: Optional[str]) -> Optional[str]:
|
||||
"""Vuoto/spazi -> None (nessuna camera); altrimenti http(s) senza '/' finale (#72)."""
|
||||
return _normalize_http_url(value, "camera_url")
|
||||
|
||||
|
||||
def _normalize_camera_source(value: Optional[str]) -> Optional[str]:
|
||||
"""Strip; vuoto -> None (#72)."""
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip() or None
|
||||
|
||||
|
||||
class StationCreate(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=100)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
@@ -25,8 +42,13 @@ class StationCreate(BaseModel):
|
||||
active: bool = True
|
||||
# worker di visione della stazione (#45); vuoto = centrale
|
||||
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
||||
# camera della stazione (#72); vuoto = nessuna camera
|
||||
camera_url: Optional[str] = Field(default=None, max_length=500)
|
||||
camera_source: Optional[str] = Field(default=None, max_length=200)
|
||||
|
||||
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
||||
_check_camera_url = field_validator("camera_url")(_normalize_camera_url)
|
||||
_check_camera_source = field_validator("camera_source")(_normalize_camera_source)
|
||||
|
||||
|
||||
class StationUpdate(BaseModel):
|
||||
@@ -36,8 +58,13 @@ class StationUpdate(BaseModel):
|
||||
active: Optional[bool] = None
|
||||
# worker di visione della stazione (#45); "" -> None riporta al centrale
|
||||
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
||||
# camera della stazione (#72); vuoto = nessuna camera
|
||||
camera_url: Optional[str] = Field(default=None, max_length=500)
|
||||
camera_source: Optional[str] = Field(default=None, max_length=200)
|
||||
|
||||
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
||||
_check_camera_url = field_validator("camera_url")(_normalize_camera_url)
|
||||
_check_camera_source = field_validator("camera_source")(_normalize_camera_source)
|
||||
|
||||
|
||||
class StationResponse(BaseModel):
|
||||
@@ -49,6 +76,8 @@ class StationResponse(BaseModel):
|
||||
notes: Optional[str]
|
||||
active: bool
|
||||
vision_worker_url: Optional[str] = None # #45
|
||||
camera_url: Optional[str] = None # #72
|
||||
camera_source: Optional[str] = None # #72
|
||||
created_by: int
|
||||
created_at: UtcDateTime
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ class Station(Base):
|
||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
# URL del worker di visione della stazione (#45); NULL = worker centrale
|
||||
vision_worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
# camera della stazione (#72): URL del servizio camera e sorgente da chiedergli
|
||||
camera_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
camera_source: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
|
||||
import asyncio
|
||||
import base64
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from src.backend.utils.tempo import to_local, utcnow
|
||||
from pathlib import Path
|
||||
@@ -25,6 +26,23 @@ from src.backend.services.spc_service import (
|
||||
compute_summary,
|
||||
)
|
||||
|
||||
# Kaleido 1.x aggiunge alla pagina di render uno <script> MathJax dal CDN (cdnjs) anche se i grafici
|
||||
# non usano LaTeX: senza Internet (stand, PC di produzione) ogni grafico aspetta il timeout di rete
|
||||
# (~19 s con rete che scarta i pacchetti, TieMeasureFlow#78). I grafici qui sotto non hanno formule:
|
||||
# si passa a Kaleido un file locale vuoto al posto del CDN. Il file sta nella cartella temporanea.
|
||||
def _mathjax_vuoto() -> str:
|
||||
"""Percorso di un .js vuoto da usare come MathJax: nessuna richiesta di rete per i grafici."""
|
||||
path = Path(tempfile.gettempdir()) / "tmf-mathjax-vuoto.js"
|
||||
if not path.exists():
|
||||
path.write_text("/* MathJax non usato: grafici senza LaTeX */", encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
try:
|
||||
pio.defaults.mathjax = _mathjax_vuoto()
|
||||
except (AttributeError, OSError): # plotly vecchio senza pio.defaults o temp non scrivibile: resta il CDN
|
||||
pass
|
||||
|
||||
# Jinja2 environment for report templates
|
||||
_templates_dir = Path(__file__).parent.parent / "templates" / "reports"
|
||||
_jinja_env = Environment(
|
||||
|
||||
@@ -32,6 +32,8 @@ async def create_station(
|
||||
notes=data.notes,
|
||||
active=data.active,
|
||||
vision_worker_url=data.vision_worker_url, # #45
|
||||
camera_url=data.camera_url, # #72
|
||||
camera_source=data.camera_source, # #72
|
||||
created_by=creator.id,
|
||||
)
|
||||
db.add(station)
|
||||
|
||||
@@ -13,7 +13,10 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.operations import Operations
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from alembic.config import Config
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import create_engine, inspect as sa_inspect, text
|
||||
|
||||
from src.backend.config import Settings
|
||||
@@ -132,6 +135,70 @@ class TestApiKeyHashMigration:
|
||||
command.upgrade(cfg, "head") # and up again
|
||||
assert "api_keys" in _inspect(db_path).get_table_names()
|
||||
|
||||
def test_015_on_create_all_schema(self, sqlite_database_url):
|
||||
"""DB nato da create_all: users.api_key ha solo l'indice unique
|
||||
ix_users_api_key, nessun vincolo uq_users_api_key (#71)."""
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
command.upgrade(cfg, "014_vision_fk_ondelete")
|
||||
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
ctx = MigrationContext.configure(conn)
|
||||
ops = Operations(ctx)
|
||||
with ops.batch_alter_table("users") as batch:
|
||||
batch.drop_index("ix_users_api_key")
|
||||
batch.drop_constraint("uq_users_api_key", type_="unique")
|
||||
ops.create_index("ix_users_api_key", "users", ["api_key"], unique=True)
|
||||
engine.dispose()
|
||||
|
||||
insp = _inspect(db_path)
|
||||
assert not insp.get_unique_constraints("users") or "uq_users_api_key" not in {
|
||||
u["name"] for u in insp.get_unique_constraints("users")
|
||||
}
|
||||
|
||||
command.upgrade(cfg, "head") # non deve sollevare
|
||||
|
||||
insp = _inspect(db_path)
|
||||
assert "api_keys" in insp.get_table_names()
|
||||
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||
|
||||
def test_015_resumes_after_partial_run(self, sqlite_database_url):
|
||||
"""015 a meta' (MySQL, DDL non transazionale): api_keys creata e
|
||||
ix_users_api_key tolto, alembic_version ancora 014 (#71)."""
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
command.upgrade(cfg, "014_vision_fk_ondelete")
|
||||
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
ops = Operations(MigrationContext.configure(conn))
|
||||
ops.create_table(
|
||||
"api_keys",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("key_hash", sa.String(64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("last_used_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"], ["users.id"],
|
||||
name="fk_api_keys_user_id", ondelete="CASCADE",
|
||||
),
|
||||
)
|
||||
ops.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True)
|
||||
ops.drop_index("ix_users_api_key", table_name="users")
|
||||
engine.dispose()
|
||||
|
||||
command.upgrade(cfg, "head") # non deve sollevare
|
||||
|
||||
insp = _inspect(db_path)
|
||||
assert "api_keys" in insp.get_table_names()
|
||||
assert {i["name"] for i in insp.get_indexes("api_keys")} >= {
|
||||
"ix_api_keys_key_hash", "ix_api_keys_user_id",
|
||||
}
|
||||
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||
|
||||
|
||||
class TestVisionRecipeDirMigration:
|
||||
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
|
||||
@@ -254,3 +321,23 @@ class TestStationVisionWorkerMigration:
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
assert "vision_worker_url" in cols("stations")
|
||||
assert "worker_url" in cols("vision_results")
|
||||
|
||||
|
||||
class TestStationCameraMigration:
|
||||
"""020_station_camera: stations.camera_url e stations.camera_source, su e giu (#72)."""
|
||||
|
||||
def test_020_upgrade_and_downgrade(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
|
||||
def cols(table):
|
||||
return {c["name"] for c in _inspect(db_path).get_columns(table)}
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
assert {"camera_url", "camera_source"} <= cols("stations")
|
||||
command.downgrade(cfg, "019_station_vision_worker")
|
||||
assert "camera_url" not in cols("stations")
|
||||
assert "camera_source" not in cols("stations")
|
||||
assert "vision_worker_url" in cols("stations") # la 019 resta
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
assert {"camera_url", "camera_source"} <= cols("stations")
|
||||
|
||||
@@ -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
|
||||
@@ -430,3 +430,103 @@ async def test_station_vision_worker_url_must_be_http(client: AsyncClient, admin
|
||||
json={"vision_worker_url": "ftp://x"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# --- camera_url / camera_source (#72) ---
|
||||
|
||||
async def test_station_camera_fields_roundtrip(client: AsyncClient, admin_user):
|
||||
created = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={
|
||||
"code": "ST-CAM", "name": "CAM",
|
||||
"camera_url": " http://127.0.0.1:8765/ ",
|
||||
"camera_source": " camera:folder ",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
assert created.json()["camera_url"] == "http://127.0.0.1:8765"
|
||||
assert created.json()["camera_source"] == "camera:folder"
|
||||
|
||||
sid = created.json()["id"]
|
||||
read = await client.get(f"/api/stations/{sid}", headers=auth_headers(admin_user))
|
||||
assert read.status_code == 200
|
||||
assert read.json()["camera_url"] == "http://127.0.0.1:8765"
|
||||
assert read.json()["camera_source"] == "camera:folder"
|
||||
|
||||
updated = await client.put(
|
||||
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||
json={"camera_url": "https://cam.example:9000", "camera_source": "camera:ABC123"},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["camera_url"] == "https://cam.example:9000"
|
||||
assert updated.json()["camera_source"] == "camera:ABC123"
|
||||
|
||||
cleared = await client.put(
|
||||
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||
json={"camera_url": "", "camera_source": " "},
|
||||
)
|
||||
assert cleared.status_code == 200
|
||||
assert cleared.json()["camera_url"] is None
|
||||
assert cleared.json()["camera_source"] is None
|
||||
|
||||
|
||||
async def test_station_camera_fields_default_to_null(client: AsyncClient, admin_user):
|
||||
created = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-CN", "name": "CN"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
assert created.json()["camera_url"] is None
|
||||
assert created.json()["camera_source"] is None
|
||||
|
||||
|
||||
async def test_station_camera_url_must_be_http(client: AsyncClient, admin_user):
|
||||
resp = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-CF", "name": "CF", "camera_url": "ftp://x"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
created = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-CG", "name": "CG"},
|
||||
)
|
||||
resp = await client.put(
|
||||
f"/api/stations/{created.json()['id']}", headers=auth_headers(admin_user),
|
||||
json={"camera_url": "ftp://x"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_station_by_code_returns_station(client: AsyncClient, admin_user):
|
||||
await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-BC", "name": "BC", "camera_url": "http://h:1", "camera_source": "camera:folder"},
|
||||
)
|
||||
resp = await client.get("/api/stations/by-code/ST-BC", headers=auth_headers(admin_user))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["code"] == "ST-BC"
|
||||
assert resp.json()["camera_url"] == "http://h:1"
|
||||
assert resp.json()["camera_source"] == "camera:folder"
|
||||
|
||||
|
||||
async def test_station_by_code_unknown_is_404(client: AsyncClient, admin_user):
|
||||
resp = await client.get("/api/stations/by-code/NOPE", headers=auth_headers(admin_user))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_station_by_code_any_authenticated_user(
|
||||
client: AsyncClient, measurement_tec_user, admin_user,
|
||||
):
|
||||
await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-BA", "name": "BA"},
|
||||
)
|
||||
resp = await client.get("/api/stations/by-code/ST-BA", headers=auth_headers(measurement_tec_user))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["code"] == "ST-BA"
|
||||
|
||||
|
||||
async def test_station_by_code_requires_auth(client: AsyncClient):
|
||||
resp = await client.get("/api/stations/by-code/ST-BA")
|
||||
assert resp.status_code == 401
|
||||
|
||||
@@ -287,3 +287,116 @@ class TestRunFilter:
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
params={"recipe_id": 1})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestRunTaskResults:
|
||||
"""#58: GET /api/statistics/runs/{id}/task-results."""
|
||||
|
||||
FIELDS = [
|
||||
{"key": "peso", "label": "Peso", "type": "number", "default": None, "attachment": False},
|
||||
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
|
||||
]
|
||||
|
||||
async def _setup(self, client, db_session, maker_user, tec_user):
|
||||
from datetime import datetime
|
||||
from sqlalchemy import select
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.task_result import TaskResult
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TR58")
|
||||
version = (await db_session.execute(
|
||||
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
|
||||
)).scalars().first()
|
||||
db_session.add(Station(code="ST-TR58", name="S", active=True, created_by=maker_user.id))
|
||||
await db_session.commit()
|
||||
station = (await db_session.execute(select(Station))).scalars().first()
|
||||
run = ProductionRun(
|
||||
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||
operator_id=tec_user.id, status="open", started_at=datetime(2026, 1, 1),
|
||||
)
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
h = auth_headers(maker_user)
|
||||
check = (await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=h,
|
||||
json={"title": "Controllo", "task_type": "check"},
|
||||
)).json()
|
||||
note = (await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=h,
|
||||
json={"title": "Nota", "task_type": "note", "fields": self.FIELDS},
|
||||
)).json()
|
||||
# Inseriti fuori ordine: l'API deve ordinare per created_at.
|
||||
rows = [
|
||||
TaskResult(task_id=note["id"], version_id=version.id, production_run_id=run.id,
|
||||
user_id=tec_user.id, outcome="done", note="ok finito",
|
||||
values={"peso": "12", "foto": "x.jpg"},
|
||||
attachments=[{"key": "foto", "path": "task_results/a.jpg", "name": "a.jpg"}],
|
||||
created_at=datetime(2026, 1, 1, 10, 3)),
|
||||
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
|
||||
user_id=tec_user.id, outcome="nok", note="graffio",
|
||||
created_at=datetime(2026, 1, 1, 10, 2)),
|
||||
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
|
||||
user_id=tec_user.id, outcome="ok",
|
||||
created_at=datetime(2026, 1, 1, 10, 1)),
|
||||
# Un esito di un'altra sessione (nessuna) non deve comparire.
|
||||
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=None,
|
||||
user_id=tec_user.id, outcome="ok",
|
||||
created_at=datetime(2026, 1, 1, 9, 0)),
|
||||
]
|
||||
db_session.add_all(rows)
|
||||
await db_session.commit()
|
||||
return recipe, run, check, note
|
||||
|
||||
async def test_counts_order_labels_attachments(
|
||||
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
|
||||
):
|
||||
_, run, check, note = await self._setup(
|
||||
client, db_session, maker_user, measurement_tec_user)
|
||||
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
|
||||
headers=auth_headers(metrologist_user))
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["run_id"] == run.id
|
||||
assert data["counts"] == {"ok": 1, "nok": 1, "done": 1}
|
||||
res = data["results"]
|
||||
assert [r["outcome"] for r in res] == ["ok", "nok", "done"] # per ora crescente
|
||||
assert res[1]["note"] == "graffio"
|
||||
assert res[1]["task_title"] == "Controllo"
|
||||
assert res[1]["task_type"] == "check"
|
||||
assert res[1]["user_name"] == measurement_tec_user.display_name
|
||||
assert res[1]["values"] == [] and res[1]["attachments"] == []
|
||||
n = res[2]
|
||||
assert n["task_title"] == "Nota" and n["note"] == "ok finito"
|
||||
assert n["values"] == [
|
||||
{"key": "peso", "label": "Peso", "value": "12"},
|
||||
{"key": "foto", "label": "Foto", "value": "x.jpg"},
|
||||
]
|
||||
assert n["attachments"] == [
|
||||
{"key": "foto", "label": "Foto", "name": "a.jpg", "path": "task_results/a.jpg"},
|
||||
]
|
||||
|
||||
async def test_label_falls_back_to_key_when_field_removed(
|
||||
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
|
||||
):
|
||||
from src.backend.models.orm.task import RecipeTask
|
||||
_, run, _, note = await self._setup(
|
||||
client, db_session, maker_user, measurement_tec_user)
|
||||
task = await db_session.get(RecipeTask, note["id"])
|
||||
task.fields = [self.FIELDS[0]] # il campo «foto» viene tolto dal task
|
||||
await db_session.commit()
|
||||
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
|
||||
headers=auth_headers(metrologist_user))
|
||||
n = resp.json()["results"][2]
|
||||
assert {v["key"]: v["label"] for v in n["values"]} == {"peso": "Peso", "foto": "foto"}
|
||||
assert n["attachments"][0]["label"] == "foto"
|
||||
|
||||
async def test_unknown_run_is_404(self, client, metrologist_user):
|
||||
resp = await client.get("/api/statistics/runs/99999/task-results",
|
||||
headers=auth_headers(metrologist_user))
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_measurement_tec_is_403(self, client, measurement_tec_user):
|
||||
resp = await client.get("/api/statistics/runs/1/task-results",
|
||||
headers=auth_headers(measurement_tec_user))
|
||||
assert resp.status_code == 403
|
||||
|
||||
@@ -242,6 +242,9 @@ def create_app() -> Flask:
|
||||
"company_logo": session.get("company_logo"),
|
||||
"auto_logout_minutes": session.get("auto_logout_minutes"),
|
||||
"current_year": date.today().year,
|
||||
# Versione/commit nel piede di pagina (#70)
|
||||
"app_version": Config.APP_VERSION,
|
||||
"app_commit": Config.APP_COMMIT,
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
@@ -345,6 +345,7 @@ def task_execute(task_id: int):
|
||||
all_task_ids=all_task_ids,
|
||||
measurement_interval_minutes=measurement_interval_minutes,
|
||||
allow_manual_input=allow_manual_input,
|
||||
camera_enabled=_station_camera(_current_station()[0]) is not None,
|
||||
)
|
||||
|
||||
|
||||
@@ -1000,6 +1001,64 @@ def api_vision_execute():
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
def _station_camera(station_code: str | None) -> tuple[str, str] | None:
|
||||
"""(camera_url, camera_source) of the station, None when it has no camera.
|
||||
|
||||
A station that cannot be read counts as "no camera": the grab button simply
|
||||
does not show, the file input keeps working.
|
||||
"""
|
||||
if not station_code:
|
||||
return None
|
||||
station = api_client.get_station_by_code(station_code)
|
||||
if not isinstance(station, dict) or station.get("error"):
|
||||
return None
|
||||
url = station.get("camera_url")
|
||||
source = station.get("camera_source")
|
||||
if not url or not source:
|
||||
return None
|
||||
return str(url), str(source)
|
||||
|
||||
|
||||
# #72: the browser asks the station's camera service (vs-gui) for a frame; the
|
||||
# service address is a server-side matter, so Flask relays and the PNG goes back as is.
|
||||
@measure_bp.route("/api/vision/grab", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_vision_grab():
|
||||
"""Proxy: grab one frame from the current station's camera, answer a PNG."""
|
||||
camera = _station_camera(_current_station()[0])
|
||||
if camera is None:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Camera non configurata per questa stazione"),
|
||||
}), 409
|
||||
camera_url, camera_source = camera
|
||||
try:
|
||||
resp = http_requests.post(
|
||||
f"{camera_url.rstrip('/')}/camera/grab",
|
||||
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
|
||||
timeout=15,
|
||||
)
|
||||
except http_requests.RequestException as exc:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
|
||||
}), 502
|
||||
if resp.status_code != 200:
|
||||
detail = ""
|
||||
try:
|
||||
body = resp.json()
|
||||
detail = body.get("detail", "") if isinstance(body, dict) else ""
|
||||
except ValueError:
|
||||
detail = (resp.text or "")[:200]
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Acquisizione non riuscita (%(status)s): %(detail)s",
|
||||
status=resp.status_code, detail=detail),
|
||||
}), 502
|
||||
return Response(resp.content, content_type="image/png")
|
||||
|
||||
|
||||
@measure_bp.route("/api/vision/results/<int:result_id>/image", methods=["GET"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
|
||||
import requests as http_requests
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request, session
|
||||
from flask import Blueprint, abort, jsonify, render_template, request, session
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from blueprints.auth import login_required, role_required
|
||||
from config import Config
|
||||
from services.api_client import api_client
|
||||
from services.file_proxy import proxy_file # #58: allegati dei task
|
||||
|
||||
statistics_bp = Blueprint("statistics", __name__)
|
||||
|
||||
@@ -148,6 +149,33 @@ def api_runs():
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
@statistics_bp.route("/api/runs/<int:run_id>/task-results")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def api_run_task_results(run_id: int):
|
||||
"""Proxy: esiti, note, campi e allegati dei task di una sessione (#58)."""
|
||||
resp = api_client.get(f"/api/statistics/runs/{run_id}/task-results")
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
error_body = {"error": True, "detail": resp.get("detail", "")}
|
||||
if resp.get("session_expired"):
|
||||
error_body["session_expired"] = True
|
||||
return jsonify(error_body), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
@statistics_bp.route("/api/files/<path:file_path>")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def api_task_result_file(file_path: str):
|
||||
"""Proxy: allegati dei task (#58). Solo task_results/, mai altri file."""
|
||||
# #58: il Metrologist non deve poter leggere altro dell'upload dir (es. statistics/).
|
||||
if not file_path.startswith("task_results/") or ".." in file_path.split("/"):
|
||||
abort(404)
|
||||
return proxy_file(file_path)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PROXY REPORT PDF → FastAPI
|
||||
# ============================================================================
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""TieMeasureFlow Client Configuration."""
|
||||
import os
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
||||
@@ -10,6 +12,25 @@ load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
||||
DEFAULT_SECRET_KEY = "change-this-to-another-random-secret-key"
|
||||
|
||||
|
||||
def _read_app_version() -> str:
|
||||
"""Versione del prodotto = `[project] version` di pyproject.toml (#70).
|
||||
|
||||
Si risale dalla cartella del modulo: nel container il file sta in
|
||||
/app/pyproject.toml (Dockerfile.frontend), nel repo alla radice. Se non
|
||||
si trova o e' illeggibile torna "dev": il piede di pagina non deve mai
|
||||
rompere l'avvio.
|
||||
"""
|
||||
for parent in Path(__file__).resolve().parents:
|
||||
candidate = parent / "pyproject.toml"
|
||||
if candidate.is_file():
|
||||
try:
|
||||
with candidate.open("rb") as fh:
|
||||
return str(tomllib.load(fh)["project"]["version"])
|
||||
except (OSError, tomllib.TOMLDecodeError, KeyError):
|
||||
return "dev"
|
||||
return "dev"
|
||||
|
||||
|
||||
class Config:
|
||||
"""Flask client configuration."""
|
||||
|
||||
@@ -17,6 +38,12 @@ class Config:
|
||||
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", DEFAULT_SECRET_KEY)
|
||||
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
|
||||
|
||||
# Versione e commit mostrati nel piede di pagina (#70). Letti una volta
|
||||
# all'import. Il commit arriva dal build arg APP_COMMIT (.git non e' nel
|
||||
# contesto di build, vedi .dockerignore); facoltativo: vuoto = non mostrato.
|
||||
APP_VERSION: str = _read_app_version()
|
||||
APP_COMMIT: str = os.environ.get("APP_COMMIT", "")[:7]
|
||||
|
||||
# API Server connection
|
||||
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
|
||||
|
||||
|
||||
@@ -193,5 +193,9 @@ class APIClient:
|
||||
"""Return the list of active recipes assigned to the given station."""
|
||||
return self.get(f"/api/stations/by-code/{station_code}/recipes")
|
||||
|
||||
def get_station_by_code(self, station_code: str) -> dict[str, Any]:
|
||||
"""Return the station with the given code (camera_url / camera_source included)."""
|
||||
return self.get(f"/api/stations/by-code/{station_code}")
|
||||
|
||||
|
||||
api_client = APIClient()
|
||||
|
||||
@@ -18,6 +18,7 @@ function visionExecute() {
|
||||
|
||||
return {
|
||||
busy: false,
|
||||
grabbing: false, // frame requested from the station camera (#72)
|
||||
error: '',
|
||||
result: null, // last execute response
|
||||
imageSrc: '', // object URL of the acquired image (shown under the overlay)
|
||||
@@ -61,6 +62,34 @@ function visionExecute() {
|
||||
}
|
||||
},
|
||||
|
||||
// Station camera (#72): the Flask proxy answers a PNG, which goes through
|
||||
// acquire() like a picked file. Errors show as the execute ones do.
|
||||
async grab() {
|
||||
const cfg = window.__visionCfg || {};
|
||||
if (this.busy || this.grabbing || !cfg.grabUrl) return;
|
||||
this.grabbing = true;
|
||||
this.error = '';
|
||||
let blob = null;
|
||||
try {
|
||||
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||
const resp = await fetch(cfg.grabUrl, {
|
||||
method: 'POST', headers: { 'X-CSRFToken': csrf },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return;
|
||||
}
|
||||
blob = await resp.blob();
|
||||
} catch (e) {
|
||||
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return;
|
||||
} finally {
|
||||
this.grabbing = false;
|
||||
}
|
||||
if (blob) await this.acquire(blob);
|
||||
},
|
||||
|
||||
// Input change: take the file and allow picking the same one again.
|
||||
onPick(ev) {
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
|
||||
@@ -215,6 +215,26 @@
|
||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = worker centrale. Indirizzo completo, con http o https') }}</p>
|
||||
</div>
|
||||
|
||||
{# Camera della stazione (#72): servizio camera (vs-gui) e sorgente da cui prendere il frame #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Camera (URL)') }}</label>
|
||||
<input type="text" x-model="form.camera_url"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||
placeholder="host:8765"
|
||||
maxlength="500">
|
||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Sorgente camera') }}</label>
|
||||
<input type="text" x-model="form.camera_source"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||
placeholder="camera:folder"
|
||||
maxlength="200">
|
||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Es. camera:<seriale> oppure camera:folder') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label>
|
||||
<textarea x-model="form.notes" rows="3"
|
||||
@@ -464,6 +484,8 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
location: '',
|
||||
notes: '',
|
||||
vision_worker_url: '', // #45
|
||||
camera_url: '', // #72
|
||||
camera_source: '', // #72
|
||||
active: true,
|
||||
},
|
||||
|
||||
@@ -517,7 +539,7 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
this.isEditing = false;
|
||||
this.editingId = null;
|
||||
this.errorMsg = '';
|
||||
this.form = { code: '', name: '', location: '', notes: '', vision_worker_url: '', active: true };
|
||||
this.form = { code: '', name: '', location: '', notes: '', vision_worker_url: '', camera_url: '', camera_source: '', active: true };
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
@@ -531,6 +553,8 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
location: station.location || '',
|
||||
notes: station.notes || '',
|
||||
vision_worker_url: station.vision_worker_url || '', // #45
|
||||
camera_url: station.camera_url || '', // #72
|
||||
camera_source: station.camera_source || '', // #72
|
||||
active: station.active,
|
||||
};
|
||||
this.showModal = true;
|
||||
@@ -551,6 +575,8 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
location: this.form.location || null,
|
||||
notes: this.form.notes || null,
|
||||
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
|
||||
camera_url: (this.form.camera_url || '').trim() || null, // #72
|
||||
camera_source: (this.form.camera_source || '').trim() || null, // #72
|
||||
active: this.form.active,
|
||||
};
|
||||
const resp = await fetch(`/admin/api/stations/${this.editingId}`, {
|
||||
@@ -578,6 +604,8 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
location: this.form.location || null,
|
||||
notes: this.form.notes || null,
|
||||
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
|
||||
camera_url: (this.form.camera_url || '').trim() || null, // #72
|
||||
camera_source: (this.form.camera_source || '').trim() || null, // #72
|
||||
active: this.form.active,
|
||||
};
|
||||
const resp = await fetch('/admin/api/stations', {
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center text-xs text-[var(--text-muted)]">
|
||||
<p>TieMeasureFlow © {{ current_year }} - {{ _('Sistema di misurazione industriale') }}</p>
|
||||
<p>TieMeasureFlow © {{ current_year }} - {{ _('Sistema di misurazione industriale') }}{# versione e commit (#70) #} · v{{ app_version }}{% if app_commit %} · {{ app_commit }}{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
<!-- Footer -->
|
||||
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300">
|
||||
<p class="text-xs text-steel dark:text-steel-light tracking-wide">
|
||||
Powered by <span class="font-semibold">TieMeasureFlow</span> — Tielogic
|
||||
Powered by <span class="font-semibold">TieMeasureFlow</span> — Tielogic{# versione e commit (#70) #} · v{{ app_version }}{% if app_commit %} · {{ app_commit }}{% endif %}
|
||||
</p>
|
||||
</footer>
|
||||
{% endblock %}
|
||||
|
||||
@@ -544,6 +544,15 @@
|
||||
<input type="file" accept="image/*" capture="environment" class="hidden"
|
||||
@change="onPick($event)" :disabled="busy">
|
||||
</label>
|
||||
{% if camera_enabled %}
|
||||
{# #72: frame dalla camera della stazione, stessa strada di acquire() #}
|
||||
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()"
|
||||
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
|
||||
:disabled="busy || grabbing">
|
||||
<span>{{ _('Acquisisci dalla camera') }}</span>
|
||||
</button>
|
||||
<span x-show="grabbing" class="text-sm text-[var(--text-muted)]">{{ _('Acquisizione in corso...') }}</span>
|
||||
{% endif %}
|
||||
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
|
||||
</div>
|
||||
|
||||
@@ -1136,6 +1145,7 @@
|
||||
<script>
|
||||
window.__visionCfg = {
|
||||
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
|
||||
grabUrl: '{{ url_for("measure.api_vision_grab") }}',
|
||||
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
|
||||
taskId: {{ task.id|tojson }},
|
||||
taskType: {{ (task.task_type or '')|tojson }},
|
||||
|
||||
@@ -129,6 +129,74 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- #58: esiti, note, campi e allegati dei task della sessione scelta -->
|
||||
<div id="run-task-results" x-show="selectedRunId" x-cloak
|
||||
class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 mb-6 shadow-sm">
|
||||
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Esiti dei task della sessione") }}</h2>
|
||||
<p x-show="taskResultsLoading" class="text-sm text-steel">{{ _("Caricamento...") }}</p>
|
||||
<p x-show="taskResultsError" x-text="taskResultsError" class="text-sm text-red-600"></p>
|
||||
<template x-if="taskResults && !taskResultsLoading">
|
||||
<div>
|
||||
<div class="grid grid-cols-3 gap-3 mb-4 max-w-md">
|
||||
<div class="text-center p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20">
|
||||
<div class="text-2xl font-bold font-mono text-emerald-600" x-text="taskResults.counts.ok"></div>
|
||||
<div class="text-xs text-emerald-700 dark:text-emerald-400 mt-1">{{ _("OK") }}</div>
|
||||
</div>
|
||||
<div class="text-center p-3 rounded-lg bg-red-50 dark:bg-red-900/20">
|
||||
<div class="text-2xl font-bold font-mono text-red-600" x-text="taskResults.counts.nok"></div>
|
||||
<div class="text-xs text-red-700 dark:text-red-400 mt-1">{{ _("NOK") }}</div>
|
||||
</div>
|
||||
<div class="text-center p-3 rounded-lg bg-[var(--bg-primary)]">
|
||||
<div class="text-2xl font-bold font-mono text-[var(--text-primary)]" x-text="taskResults.counts.done"></div>
|
||||
<div class="text-xs text-steel mt-1">{{ _("Fatto") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="taskResults.results.length === 0" class="text-sm text-steel">{{ _("Nessun esito registrato in questa sessione") }}</p>
|
||||
<div x-show="taskResults.results.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-steel uppercase border-b border-[var(--border-color)]">
|
||||
<th class="py-2 pr-3">{{ _("Ora") }}</th>
|
||||
<th class="py-2 pr-3">{{ _("Task") }}</th>
|
||||
<th class="py-2 pr-3">{{ _("Esito") }}</th>
|
||||
<th class="py-2 pr-3">{{ _("Note") }}</th>
|
||||
<th class="py-2 pr-3">{{ _("Campi") }}</th>
|
||||
<th class="py-2 pr-3">{{ _("Allegati") }}</th>
|
||||
<th class="py-2">{{ _("Operatore") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="r in taskResults.results" :key="r.id">
|
||||
<tr class="border-b border-[var(--border-color)] align-top">
|
||||
<td class="py-2 pr-3 font-mono whitespace-nowrap" x-text="resultTime(r.created_at)"></td>
|
||||
<td class="py-2 pr-3" x-text="r.task_title"></td>
|
||||
<td class="py-2 pr-3">
|
||||
<span class="px-2 py-0.5 rounded text-xs font-semibold"
|
||||
:class="r.outcome === 'nok' ? 'bg-red-600 text-white' : (r.outcome === 'ok' ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-200 text-gray-700')"
|
||||
x-text="outcomeLabel(r.outcome)"></span>
|
||||
</td>
|
||||
<td class="py-2 pr-3 whitespace-pre-line" x-text="r.note"></td>
|
||||
<td class="py-2 pr-3">
|
||||
<template x-for="v in r.values" :key="v.key">
|
||||
<div><span class="text-steel" x-text="v.label + ': '"></span><span x-text="v.value"></span></div>
|
||||
</template>
|
||||
</td>
|
||||
<td class="py-2 pr-3">
|
||||
<template x-for="a in r.attachments" :key="a.path">
|
||||
<div><a :href="attachmentUrl(a.path)" target="_blank" rel="noopener"
|
||||
class="text-primary hover:underline" x-text="a.name || a.label"></a></div>
|
||||
</template>
|
||||
</td>
|
||||
<td class="py-2" x-text="r.user_name"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Nessuna ricetta selezionata -->
|
||||
<template x-if="!selectedRecipeId && !loading">
|
||||
<div class="text-center py-16 text-steel">
|
||||
@@ -290,8 +358,52 @@ function spcDashboard() {
|
||||
reportError: '',
|
||||
downloadingReport: false,
|
||||
|
||||
// #58: esiti dei task della sessione scelta
|
||||
taskResults: null,
|
||||
taskResultsLoading: false,
|
||||
taskResultsError: '',
|
||||
|
||||
init() {
|
||||
// Nothing to pre-load
|
||||
// #58: il pannello esiti si ricarica a ogni cambio di sessione
|
||||
this.$watch('selectedRunId', () => this.loadTaskResults());
|
||||
},
|
||||
|
||||
async loadTaskResults() {
|
||||
this.taskResults = null;
|
||||
this.taskResultsError = '';
|
||||
const runId = this.selectedRunId;
|
||||
if (!runId) return;
|
||||
this.taskResultsLoading = true;
|
||||
try {
|
||||
const resp = await fetch('/statistics/api/runs/' + runId + '/task-results');
|
||||
const data = await resp.json();
|
||||
// #58: se nel frattempo la sessione e' cambiata, il risultato e' vecchio
|
||||
if (runId !== this.selectedRunId) return;
|
||||
if (!resp.ok || data.error) {
|
||||
this.taskResultsError = data.detail || {{ _("Errore nel caricamento dei dati")|tojson }};
|
||||
} else {
|
||||
this.taskResults = data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading task results:', e);
|
||||
this.taskResultsError = {{ _("Errore di connessione al server")|tojson }};
|
||||
} finally {
|
||||
if (runId === this.selectedRunId) this.taskResultsLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// #58: ora locale dell'esito (created_at e' UTC senza suffisso)
|
||||
resultTime(iso) {
|
||||
return iso ? new Date(iso + (iso.endsWith('Z') ? '' : 'Z')).toLocaleString() : '';
|
||||
},
|
||||
|
||||
outcomeLabel(o) {
|
||||
return o === 'ok' ? {{ _("OK")|tojson }} : (o === 'nok' ? {{ _("NOK")|tojson }} : {{ _("Fatto")|tojson }});
|
||||
},
|
||||
|
||||
// #58: gli allegati passano dal proxy del blueprint, il browser non manda X-API-Key
|
||||
attachmentUrl(path) {
|
||||
return '/statistics/api/files/' + String(path).split('/').map(encodeURIComponent).join('/');
|
||||
},
|
||||
|
||||
async onRecipeChange() {
|
||||
|
||||
@@ -215,3 +215,42 @@ def test_station_proxies_pass_the_vision_worker_url_untouched(logged_in_client,
|
||||
mock_admin_api.put.assert_called_once_with(
|
||||
"/api/stations/7", data={"vision_worker_url": None},
|
||||
)
|
||||
|
||||
|
||||
# --- Camera per stazione (#72) ---
|
||||
|
||||
def test_station_form_has_the_camera_fields(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.get.return_value = []
|
||||
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
|
||||
assert "form.camera_url" in html
|
||||
assert "form.camera_source" in html
|
||||
|
||||
|
||||
def test_station_list_exposes_the_camera_fields(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.get.side_effect = [
|
||||
[{"id": 1, "code": "ST-001", "name": "Linea A", "location": None,
|
||||
"notes": None, "active": True,
|
||||
"camera_url": "http://10.0.0.9:8765", "camera_source": "camera:folder",
|
||||
"created_by": 1, "created_at": "2026-04-25T10:00:00"}],
|
||||
[],
|
||||
]
|
||||
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
|
||||
assert "http://10.0.0.9:8765" in html
|
||||
assert "camera:folder" in html
|
||||
|
||||
|
||||
def test_station_payload_script_sends_the_camera_fields(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.get.return_value = []
|
||||
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
|
||||
# default, reset, carica, crea, aggiorna: ogni punto che già porta vision_worker_url
|
||||
assert html.count("camera_url") >= html.count("vision_worker_url")
|
||||
assert html.count("camera_source") >= html.count("vision_worker_url")
|
||||
|
||||
|
||||
def test_create_station_proxy_forwards_the_camera_fields(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.post.return_value = {"id": 5, "code": "ST-005", "name": "N"}
|
||||
body = {"code": "ST-005", "name": "N",
|
||||
"camera_url": "http://h:8765", "camera_source": "camera:folder"}
|
||||
resp = logged_in_client.post("/admin/api/stations", json=body)
|
||||
assert resp.status_code == 201
|
||||
mock_admin_api.post.assert_called_once_with("/api/stations", data=body)
|
||||
|
||||
@@ -13,3 +13,12 @@ def test_get_station_recipes_calls_correct_endpoint():
|
||||
called_args, called_kwargs = mock_get.call_args
|
||||
assert called_args[0] == "/api/stations/by-code/ST-001/recipes"
|
||||
assert result == [{"id": 1, "code": "R1", "name": "R1", "active": True}]
|
||||
|
||||
|
||||
def test_get_station_by_code_calls_correct_endpoint():
|
||||
client = APIClient()
|
||||
with patch.object(client, "get") as mock_get:
|
||||
mock_get.return_value = {"id": 1, "code": "ST-001", "camera_url": None}
|
||||
result = client.get_station_by_code("ST-001")
|
||||
assert mock_get.call_args[0][0] == "/api/stations/by-code/ST-001"
|
||||
assert result["code"] == "ST-001"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Versione e commit nel piede di pagina (#70)."""
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from config import Config
|
||||
|
||||
_PYPROJECT = Path(__file__).resolve().parents[4] / "pyproject.toml"
|
||||
|
||||
|
||||
def _pyproject_version() -> str:
|
||||
with _PYPROJECT.open("rb") as fh:
|
||||
return tomllib.load(fh)["project"]["version"]
|
||||
|
||||
|
||||
def test_app_version_matches_pyproject():
|
||||
# La versione mostrata e' quella di pyproject.toml, non un valore a mano
|
||||
assert Config.APP_VERSION == _pyproject_version()
|
||||
|
||||
|
||||
def test_login_page_shows_version(client):
|
||||
resp = client.get("/auth/login")
|
||||
assert resp.status_code == 200
|
||||
assert f"v{_pyproject_version()}" in resp.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_login_page_shows_commit_when_set(client, monkeypatch):
|
||||
# Commit valorizzato (gia' troncato a 7 da Config): compare nel footer
|
||||
monkeypatch.setattr(Config, "APP_COMMIT", "abc1234")
|
||||
assert "abc1234" in client.get("/auth/login").get_data(as_text=True)
|
||||
|
||||
|
||||
def test_login_page_hides_commit_when_empty(client, monkeypatch):
|
||||
# Build senza APP_COMMIT: solo versione, nessun separatore orfano
|
||||
monkeypatch.setattr(Config, "APP_COMMIT", "")
|
||||
html = client.get("/auth/login").get_data(as_text=True)
|
||||
assert f"v{_pyproject_version()}" in html
|
||||
assert f"v{_pyproject_version()} ·" not in html
|
||||
@@ -0,0 +1,161 @@
|
||||
"""gitea:Adriano/TieMeasureFlow#72 - frame grab from the station camera.
|
||||
|
||||
The backend (station.camera_url / camera_source, GET /api/stations/by-code/{code})
|
||||
is built elsewhere: api_client and requests are mocked here.
|
||||
"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from tests.conftest import api_get_router
|
||||
|
||||
STATION = {
|
||||
"id": 1, "code": "ST-TEST", "name": "Linea",
|
||||
"camera_url": "http://host.docker.internal:8765",
|
||||
"camera_source": "camera:folder",
|
||||
}
|
||||
PNG = b"\x89PNG\r\n\x1a\n fake"
|
||||
|
||||
|
||||
def _fake(status=200, content=PNG, ctype="image/png", json_body=None):
|
||||
resp = MagicMock(status_code=status, content=content, text=content.decode("latin-1"))
|
||||
resp.headers = {"content-type": ctype}
|
||||
if json_body is None:
|
||||
resp.json.side_effect = ValueError("no json")
|
||||
else:
|
||||
resp.json.return_value = json_body
|
||||
return resp
|
||||
|
||||
|
||||
def _station(mock_api_client, monkeypatch, station):
|
||||
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||
mock_api_client.get_station_by_code.return_value = station
|
||||
|
||||
|
||||
def test_grab_without_camera_is_409(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, {**STATION, "camera_url": None})
|
||||
with patch("blueprints.measure.http_requests.post") as post:
|
||||
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||
assert resp.status_code == 409
|
||||
assert resp.get_json()["error"]
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
def test_grab_without_source_is_409(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, {**STATION, "camera_source": None})
|
||||
with patch("blueprints.measure.http_requests.post") as post:
|
||||
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||
assert resp.status_code == 409
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
def test_grab_station_lookup_error_is_409(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, {"error": True, "status_code": 404, "detail": "x"})
|
||||
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_grab_passes_png_through(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, STATION)
|
||||
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
|
||||
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||
assert resp.status_code == 200
|
||||
assert resp.mimetype == "image/png"
|
||||
assert resp.data == PNG
|
||||
mock_api_client.get_station_by_code.assert_called_once_with("ST-TEST")
|
||||
args, kwargs = post.call_args
|
||||
assert args[0] == "http://host.docker.internal:8765/camera/grab"
|
||||
assert kwargs["json"] == {
|
||||
"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000,
|
||||
}
|
||||
assert kwargs["timeout"] == 15
|
||||
|
||||
|
||||
def test_grab_service_503_is_502(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, STATION)
|
||||
fake = _fake(503, b"{}", "application/json", {"detail": "camera occupata"})
|
||||
with patch("blueprints.measure.http_requests.post", return_value=fake):
|
||||
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||
assert resp.status_code == 502
|
||||
body = resp.get_json()
|
||||
assert body["error"] is True
|
||||
assert "camera occupata" in body["detail"]
|
||||
|
||||
|
||||
def test_grab_service_504_and_404_are_502(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, STATION)
|
||||
for status in (404, 504):
|
||||
with patch("blueprints.measure.http_requests.post",
|
||||
return_value=_fake(status, b"nope", "text/plain")):
|
||||
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||
assert resp.status_code == 502
|
||||
assert resp.get_json()["error"] is True
|
||||
|
||||
|
||||
def test_grab_connection_error_is_502(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, STATION)
|
||||
with patch("blueprints.measure.http_requests.post",
|
||||
side_effect=requests.ConnectionError("refused")):
|
||||
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||
assert resp.status_code == 502
|
||||
assert resp.get_json()["error"] is True
|
||||
|
||||
|
||||
def test_grab_requires_measurement_tec(flask_app, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, STATION)
|
||||
with flask_app.test_client() as c:
|
||||
with c.session_transaction() as sess:
|
||||
sess["api_key"] = "k"
|
||||
sess["user"] = {"id": 2, "username": "m", "roles": ["Maker"],
|
||||
"is_admin": False, "active": True}
|
||||
sess["user_id"] = 2
|
||||
with patch("blueprints.measure.http_requests.post") as post:
|
||||
resp = c.post("/measure/api/vision/grab")
|
||||
assert resp.status_code == 403
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
def test_grab_requires_login(client):
|
||||
resp = client.post("/measure/api/vision/grab")
|
||||
assert resp.status_code in (302, 401)
|
||||
|
||||
|
||||
# --- execute page: button only when the station has a camera -----------------
|
||||
TASK = {
|
||||
"id": 12, "recipe_id": 3, "version_id": 5, "task_type": "camera_measure",
|
||||
"title": "Foto", "directive": None, "description": None, "file_path": None,
|
||||
"file_type": None, "annotations_json": None, "subtasks": [],
|
||||
}
|
||||
RECIPE = {
|
||||
"id": 3, "code": "REC-1", "name": "Ricetta",
|
||||
"measurement_interval_minutes": 30, "allow_manual_input": True,
|
||||
"current_version": {"id": 5, "version_number": 1},
|
||||
}
|
||||
|
||||
|
||||
def _page(logged_in_client, mock_api_client, monkeypatch, station):
|
||||
_station(mock_api_client, monkeypatch, station)
|
||||
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
"/api/tasks/12": TASK,
|
||||
"/api/recipes/3/tasks": [{"id": 12, "order_index": 0}],
|
||||
"/api/recipes/3": RECIPE,
|
||||
})
|
||||
return logged_in_client.get("/measure/execute/12").get_data(as_text=True)
|
||||
|
||||
|
||||
def test_execute_page_shows_grab_button_with_camera(logged_in_client, mock_api_client, monkeypatch):
|
||||
html = _page(logged_in_client, mock_api_client, monkeypatch, STATION)
|
||||
assert "/measure/api/vision/grab" in html
|
||||
assert "grab()" in html
|
||||
|
||||
|
||||
def test_execute_page_hides_grab_button_without_camera(logged_in_client, mock_api_client, monkeypatch):
|
||||
html = _page(logged_in_client, mock_api_client, monkeypatch, {**STATION, "camera_url": None})
|
||||
assert "grab()" not in html
|
||||
|
||||
|
||||
def test_execute_page_hides_grab_button_when_lookup_fails(logged_in_client, mock_api_client, monkeypatch):
|
||||
html = _page(logged_in_client, mock_api_client, monkeypatch,
|
||||
{"error": True, "status_code": 502, "detail": "down"})
|
||||
assert "grab()" not in html
|
||||
@@ -130,3 +130,40 @@ class TestRunsProxy:
|
||||
mock_api_client.get.return_value = {"total": 1}
|
||||
logged_in_client.get("/statistics/api/summary?recipe_id=1&production_run_id=5")
|
||||
assert mock_api_client.get.call_args.kwargs["params"]["production_run_id"] == "5"
|
||||
|
||||
|
||||
class TestRunTaskResultsProxy:
|
||||
"""#58: esiti dei task e allegati della sessione."""
|
||||
|
||||
def test_task_results_proxy(self, logged_in_client, mock_api_client):
|
||||
payload = {"run_id": 5, "counts": {"ok": 1, "nok": 0, "done": 0}, "results": []}
|
||||
mock_api_client.get.return_value = payload
|
||||
resp = logged_in_client.get("/statistics/api/runs/5/task-results")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == payload
|
||||
assert mock_api_client.get.call_args.args[0] == "/api/statistics/runs/5/task-results"
|
||||
|
||||
def test_task_results_proxy_relays_error(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.get.return_value = {"error": True, "detail": "x", "status_code": 404}
|
||||
resp = logged_in_client.get("/statistics/api/runs/9/task-results")
|
||||
assert resp.status_code == 404
|
||||
assert resp.get_json()["error"] is True
|
||||
|
||||
def test_file_proxy_serves_task_results_only(self, logged_in_client):
|
||||
from unittest.mock import patch
|
||||
from flask import Response
|
||||
with patch("blueprints.statistics.proxy_file",
|
||||
return_value=Response("img", status=200)) as pf:
|
||||
ok = logged_in_client.get("/statistics/api/files/task_results/a.jpg")
|
||||
assert ok.status_code == 200
|
||||
pf.assert_called_once_with("task_results/a.jpg")
|
||||
for bad in ("statistics/x", "task_results/../statistics/x", "../etc/passwd"):
|
||||
assert logged_in_client.get(f"/statistics/api/files/{bad}").status_code == 404
|
||||
assert pf.call_count == 1 # i percorsi rifiutati non arrivano al proxy
|
||||
|
||||
def test_dashboard_has_task_results_panel(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1}
|
||||
html = logged_in_client.get("/statistics/dashboard").get_data(as_text=True)
|
||||
assert 'id="run-task-results"' in html
|
||||
assert "loadTaskResults" in html
|
||||
assert "/statistics/api/runs/" in html
|
||||
|
||||
@@ -2977,6 +2977,36 @@ msgstr "Select a measurement point for the histogram"
|
||||
msgid "Errore nella generazione del report"
|
||||
msgstr "Error generating report"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Esiti dei task della sessione"
|
||||
msgstr "Task outcomes of the session"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Nessun esito registrato in questa sessione"
|
||||
msgstr "No outcome recorded in this session"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Fatto"
|
||||
msgstr "Done"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Ora"
|
||||
msgstr "Time"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Campi"
|
||||
msgstr "Fields"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Allegati"
|
||||
msgstr "Attachments"
|
||||
|
||||
#~ msgid "Esci"
|
||||
#~ msgstr "Logout"
|
||||
|
||||
@@ -3292,3 +3322,40 @@ msgstr "Error generating report"
|
||||
#~ msgid "Crea, modifica e gestisci gli utenti del sistema"
|
||||
#~ msgstr "Create, edit and manage system users"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Camera (URL)"
|
||||
msgstr "Camera (URL)"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale"
|
||||
msgstr "Empty = no camera. Camera service address, with http or https, no trailing /"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Sorgente camera"
|
||||
msgstr "Camera source"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Es. camera:<seriale> oppure camera:folder"
|
||||
msgstr "E.g. camera:<serial> or camera:folder"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Acquisisci dalla camera"
|
||||
msgstr "Capture from camera"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Acquisizione in corso..."
|
||||
msgstr "Capturing..."
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Camera non configurata per questa stazione"
|
||||
msgstr "Camera not configured for this station"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
#, python-format
|
||||
msgid "Servizio camera non raggiungibile: %(detail)s"
|
||||
msgstr "Camera service unreachable: %(detail)s"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
#, python-format
|
||||
msgid "Acquisizione non riuscita (%(status)s): %(detail)s"
|
||||
msgstr "Capture failed (%(status)s): %(detail)s"
|
||||
|
||||
@@ -2981,6 +2981,36 @@ msgstr "Seleziona un punto di misura per l'istogramma"
|
||||
msgid "Errore nella generazione del report"
|
||||
msgstr "Errore nella generazione del report"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Esiti dei task della sessione"
|
||||
msgstr "Esiti dei task della sessione"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Nessun esito registrato in questa sessione"
|
||||
msgstr "Nessun esito registrato in questa sessione"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Fatto"
|
||||
msgstr "Fatto"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Ora"
|
||||
msgstr "Ora"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Campi"
|
||||
msgstr "Campi"
|
||||
|
||||
#: templates/statistics/dashboard.html
|
||||
# #58: pannello esiti dei task della sessione
|
||||
msgid "Allegati"
|
||||
msgstr "Allegati"
|
||||
|
||||
#~ msgid "Esci"
|
||||
#~ msgstr "Esci"
|
||||
|
||||
@@ -3280,3 +3310,40 @@ msgstr "Errore nella generazione del report"
|
||||
#~ msgid "Crea, modifica e gestisci gli utenti del sistema"
|
||||
#~ msgstr "Crea, modifica e gestisci gli utenti del sistema"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Camera (URL)"
|
||||
msgstr "Camera (URL)"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale"
|
||||
msgstr "Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Sorgente camera"
|
||||
msgstr "Sorgente camera"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Es. camera:<seriale> oppure camera:folder"
|
||||
msgstr "Es. camera:<seriale> oppure camera:folder"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Acquisisci dalla camera"
|
||||
msgstr "Acquisisci dalla camera"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Acquisizione in corso..."
|
||||
msgstr "Acquisizione in corso..."
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
msgid "Camera non configurata per questa stazione"
|
||||
msgstr "Camera non configurata per questa stazione"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
#, python-format
|
||||
msgid "Servizio camera non raggiungibile: %(detail)s"
|
||||
msgstr "Servizio camera non raggiungibile: %(detail)s"
|
||||
|
||||
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
|
||||
#, python-format
|
||||
msgid "Acquisizione non riuscita (%(status)s): %(detail)s"
|
||||
msgstr "Acquisizione non riuscita (%(status)s): %(detail)s"
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/visionsuite updated: adf7a2640d...2e73691cc3
Reference in New Issue
Block a user