Compare commits
328 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27d7c1e832 | |||
| 4b1b23cfb6 | |||
| 4b57efc73c | |||
| 7c03f53000 | |||
| 67337fa232 | |||
| 661b782bad | |||
| 25f8b4d609 | |||
| b98d1f15f4 | |||
| 8f9731839a | |||
| 552f96bb47 | |||
| 71daab184f | |||
| 9beaf2ed67 | |||
| 800ec41254 | |||
| 9c45a51770 | |||
| 4a48af2ae3 | |||
| b879563738 | |||
| f504c86491 | |||
| 632fd51050 | |||
| 8eb80e20a8 | |||
| b2f82fe779 | |||
| 3c5c5f9bc5 | |||
| a5e8472e88 | |||
| d6cee498ad | |||
| e7d5489438 | |||
| 828471d27a | |||
| cf64a247be | |||
| 813b66d0c5 | |||
| 8c6d6c1630 | |||
| 1afaaa0bae | |||
| cb66371a5b | |||
| 390d6210d0 | |||
| 2760623b53 | |||
| 13eeec7119 | |||
| 0629f1b5ec | |||
| 9eb5942459 | |||
| 11c7d2e98b | |||
| 88bba213e2 | |||
| 8ce7b489d6 | |||
| bc90d327f1 | |||
| e46965cb51 | |||
| c554fcfda7 | |||
| 08e46e335b | |||
| e38e1a2eaf | |||
| 7fa5bacdfe | |||
| 896a30b376 | |||
| 13dbc8f13b | |||
| 8925968f34 | |||
| 2959b79fa2 | |||
| 990b5d0db8 | |||
| 1bfcfaa5f2 | |||
| ad6ce5d11d | |||
| 351739ec36 | |||
| 9f2d624b4f | |||
| 9fe82d7cef | |||
| ae2353f7f8 | |||
| d652891c1c | |||
| 0b22643d69 | |||
| 15e31e2b8a | |||
| f8f5f248ff | |||
| f33415df6a | |||
| 62b5e412ca | |||
| 0e92959022 | |||
| f23e87e585 | |||
| 114e4a10ae | |||
| 3aac8cc490 | |||
| 8b3e897668 | |||
| eea3e319fb | |||
| bfc7c391e8 | |||
| 8d4c4020f6 | |||
| 0a68a9995e | |||
| 959e92552b | |||
| 8431aa7095 | |||
| 69cbf53060 | |||
| 23012fdfbb | |||
| d89a2e6c8d | |||
| 242add047b | |||
| 09c4029978 | |||
| cd40b81f1e | |||
| 6e60e0b1a9 | |||
| 6bc6c0c0d5 | |||
| e5989c9d23 | |||
| 0af6692786 | |||
| 36074ab2f7 | |||
| fd1bbce8aa | |||
| 1453b31cee | |||
| 192d4b590c | |||
| b9f43a0ede | |||
| 5bf374aadd | |||
| 74fe5665b5 | |||
| be904bac07 | |||
| 011c08c9d8 | |||
| 82ad88538d | |||
| bafc776ef7 | |||
| 8fcb4796d5 | |||
| 8d7fa9a12a | |||
| c911449b7c | |||
| ac8604eff5 | |||
| 631ba4ee05 | |||
| 40eed5c2af | |||
| 275bbd1a5f | |||
| 95d643d6d8 | |||
| 9306fbf653 | |||
| 1a5e98f072 | |||
| fdfcf05951 | |||
| 26884ebf4f | |||
| 8b3d3a8424 | |||
| 153fafd1b4 | |||
| cf8fab4c08 | |||
| b7cb061fee | |||
| 6a8ad4dae4 | |||
| d750cf9e0e | |||
| 2564498915 | |||
| 21f8bae5ac | |||
| c1f8a5e2ff | |||
| dee27ac8e1 | |||
| 736b853e22 | |||
| 627ae15591 | |||
| c6d4260ede | |||
| df1727be73 | |||
| 459d1c71ea | |||
| 05e3faa9d4 | |||
| dc94b2e778 | |||
| 3c55550d93 | |||
| e07c4d4eee | |||
| 0b45f1c35a | |||
| 743f246cbf | |||
| f541f4a810 | |||
| dffb11f9f2 | |||
| c8dd5eecfd | |||
| f7d6d008c7 | |||
| 837f3d1069 | |||
| f5a2affa0f | |||
| 7ef28b7a9f | |||
| 06b87a15b3 | |||
| 3b3ecceb2a | |||
| 3899d5ecd9 | |||
| f986c172c2 | |||
| e44ac3129f | |||
| 4e52095423 | |||
| 23bfc93f1c | |||
| 07e23b957d | |||
| c1c7ae8d0e | |||
| cf49695140 | |||
| c9e01a223b | |||
| 3690c35c47 | |||
| fc1902a1bb | |||
| 6648882ec1 | |||
| b5aa19170e | |||
| 2d03577165 | |||
| 27dc5e77af | |||
| 8e3316957c | |||
| 3c1d5f1323 | |||
| db5d686938 | |||
| c764cf73c6 | |||
| 86290274e5 | |||
| ee0f65ed39 | |||
| 18d0db8a0c | |||
| 3856ee335a | |||
| e640c4a36e | |||
| accf416cc9 | |||
| 84a4b5f34d | |||
| 9238f8926b | |||
| a829bf3eb0 | |||
| 7ac291b8ac | |||
| 486e1288bf | |||
| 0d004c43ff | |||
| 35f46926a9 | |||
| 1d93eded27 | |||
| 70e2072b79 | |||
| 97c49f2b5b | |||
| d483fa1b6a | |||
| 48c47e871e | |||
| cc686129c0 | |||
| b27d56d4f7 | |||
| 96dd8d6ea3 | |||
| 2b00f83a2e | |||
| e10585a071 | |||
| 8b43e7a601 | |||
| 2de226f1fd | |||
| ddb764d639 | |||
| c37533110b | |||
| 12d5b7e154 | |||
| 2b59c3ec49 | |||
| 72f0cb1f1b | |||
| c0583da478 | |||
| e8d21b78a9 | |||
| 4384c4860b | |||
| 1bb00b14f2 | |||
| e20299548f | |||
| 805f41c757 | |||
| bfe66a5710 | |||
| 77d95fdcc4 | |||
| 7b015078e9 | |||
| befdce5e6a | |||
| f739f963c7 | |||
| 31fa78bdfd | |||
| ac11cec276 | |||
| 3e99b64fc4 | |||
| 8864b2021b | |||
| 54bc384650 | |||
| 403c923188 | |||
| 65f6a4b18b | |||
| 5ca3dc14cd | |||
| a2f357b3d3 | |||
| 0ec5988581 | |||
| 1c5292d1e0 | |||
| c8caf40af9 | |||
| d40c74870e | |||
| 1576fa190b | |||
| ac76c0a395 | |||
| 302903a8a7 | |||
| b15037851b | |||
| b024724c7e | |||
| 1d5f24510c | |||
| 3f70f99a40 | |||
| 78970c69b0 | |||
| 9afe1e712b | |||
| 92f130479a | |||
| e8a8aeed66 | |||
| 20957e517d | |||
| 10261b66f0 | |||
| 7a21ec4e44 | |||
| e91ef739af | |||
| 1921bc3302 | |||
| 67392a946c | |||
| a330f2b340 | |||
| 1fee29358f | |||
| ac9bc14f5d | |||
| 0dce2299d0 | |||
| d1833ba203 | |||
| 925ed196dd | |||
| 48089bcba6 | |||
| a6d555d07a | |||
| ea9da34899 | |||
| 51f3fe32d4 | |||
| 920ab2fb73 | |||
| 7249cb8a8c | |||
| 8cc5e8266a | |||
| 665d25e574 | |||
| 401457d21f | |||
| 4adad9ab9b | |||
| f930fd98f0 | |||
| 39a6f62089 | |||
| f20b92bafb | |||
| 6bfe3a5d1a | |||
| 5717b3e7aa | |||
| 85d7976f4e | |||
| a72e3f11af | |||
| 46566ebfa5 | |||
| 0bd95367bb | |||
| 38ef0587f5 | |||
| 533edc5be8 | |||
| 3870cc3b0f | |||
| 398af016f8 | |||
| b86eeada86 | |||
| ddf7788d77 | |||
| 05f2937611 | |||
| 71da162e1f | |||
| 9387e7c306 | |||
| 0a3d1f5222 | |||
| c5f3366dd1 | |||
| 9546ded1e8 | |||
| b578ac9e1b | |||
| cb0fab8c92 | |||
| 767bede43b | |||
| 245975791f | |||
| e0f32fe8a9 | |||
| 2101b9b2c9 | |||
| c4a429d952 | |||
| 78816dfe8c | |||
| 98b1c5ae85 | |||
| 919ee7ac79 | |||
| e2aae88858 | |||
| 5aa3d595ad | |||
| bde8fafd77 | |||
| 6fbff2fe76 | |||
| 7bc3c1f938 | |||
| 55e5e0153f | |||
| e7889f80a3 | |||
| a159eb2b8b | |||
| 28ee44bcf1 | |||
| 2a566321f3 | |||
| d0e07b5e5f | |||
| 3f03ec8ab1 | |||
| edb7eb382c | |||
| 8c61a557f8 | |||
| 821c7c39d5 | |||
| 83f8e2d332 | |||
| f3e593610b | |||
| fd571c479e | |||
| 25a788f430 | |||
| 61c265b38b | |||
| 74b348988e | |||
| a36a37aca0 | |||
| b8d04c54f7 | |||
| 34a72e6f1a | |||
| 4c75a32186 | |||
| fdfe1072d8 | |||
| 6ff78b2150 | |||
| bff577b461 | |||
| 5cc5123a0c | |||
| 2eb5c51353 | |||
| b325eb3512 | |||
| a7254d8932 | |||
| d2bf0b5828 | |||
| 9bd605c958 | |||
| e05eb66a1c | |||
| 6bfe0a98e8 | |||
| 1da7e5c7af | |||
| 0748ce9b1e | |||
| b9c767fb0c | |||
| 6772e3166a | |||
| 182aa9fa9a | |||
| f1c7a28296 | |||
| 52d78ea5c1 | |||
| fb96bad000 | |||
| 6a8931e108 | |||
| 85a00dea1b | |||
| e4eb4cd932 | |||
| 4de7d78b66 | |||
| 2a2d40bec9 | |||
| 6e284b0c0c | |||
| 742cc1fb58 | |||
| 563b7789f4 | |||
| e4b29c0b2d | |||
| 1a0431366f | |||
| 86df67f2e5 | |||
| ea8e4687b5 |
+38
-10
@@ -1,15 +1,43 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache
|
||||
# Build context exclusions: keep image small and rebuilds fast.
|
||||
|
||||
# VCS
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
*.md
|
||||
node_modules
|
||||
.gitattributes
|
||||
|
||||
# Local envs and caches
|
||||
.venv
|
||||
venv
|
||||
*.egg-info
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov
|
||||
.mypy_cache
|
||||
htmlcov/
|
||||
.mypy_cache/
|
||||
*.egg-info
|
||||
|
||||
# IDE / editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Local-only notes
|
||||
docs/
|
||||
*.docx
|
||||
|
||||
# Uploads are a runtime volume, never baked in.
|
||||
uploads/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
.omc/
|
||||
|
||||
# Competitor analysis (local only)
|
||||
Concorrente/
|
||||
|
||||
+65
-1
@@ -3,6 +3,8 @@
|
||||
# ===========================
|
||||
|
||||
# --- Database ---
|
||||
# DB_PASSWORD e DB_ROOT_PASSWORD (sezione Docker sotto): in produzione (docker-compose.yml)
|
||||
# sono obbligatorie, senza default — `docker compose config` si rifiuta se mancano.
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_NAME=tiemeasureflow
|
||||
@@ -10,12 +12,20 @@ DB_USER=tmflow
|
||||
DB_PASSWORD=change_me_in_production
|
||||
|
||||
# --- Server ---
|
||||
# APP_ENV: "dev" (default) o "test" fanno partire il server senza controlli;
|
||||
# qualunque altro valore (es. "production") fa scattare i rifiuti descritti
|
||||
# sotto per SERVER_SECRET_KEY e DB_PASSWORD ancora ai valori committati.
|
||||
APP_ENV=dev
|
||||
# SERVER_SECRET_KEY: in produzione obbligatoria, senza default, e il server rifiuta
|
||||
# di avviarsi se resta uguale al valore committato qui sotto.
|
||||
SERVER_HOST=0.0.0.0
|
||||
SERVER_PORT=8000
|
||||
SERVER_SECRET_KEY=change-this-to-a-random-secret-key
|
||||
SERVER_CORS_ORIGINS=http://localhost:5000
|
||||
|
||||
# --- Client ---
|
||||
# CLIENT_SECRET_KEY: in produzione obbligatoria, senza default, e il client rifiuta
|
||||
# di avviarsi se resta uguale al valore committato qui sotto.
|
||||
CLIENT_HOST=0.0.0.0
|
||||
CLIENT_PORT=5000
|
||||
CLIENT_SECRET_KEY=change-this-to-another-random-secret-key
|
||||
@@ -24,15 +34,69 @@ API_SERVER_URL=http://localhost:8000
|
||||
# Each physical tablet/PC deployment must set this unique per-station value.
|
||||
# Leave empty only for a single-station all-in-one demo using ST-DEFAULT.
|
||||
STATION_CODE=ST-DEFAULT
|
||||
# Allow switching station from the URL (?station=CODE) during commissioning, so a
|
||||
# single PC can exercise several stations. Leave at 0 in production: on the shop
|
||||
# floor the station identity comes from the local install, and measuring against
|
||||
# another station's recipes would silently break traceability.
|
||||
STATION_SWITCH_ENABLED=0
|
||||
# Cookie di sessione "Secure": di default segue FLASK_DEBUG (sicuro salvo
|
||||
# debug). Impostare a false solo per l'installazione on-premise che serve
|
||||
# HTTP in LAN (#38.4) - un cookie Secure su un'origine non-HTTPS viene
|
||||
# scartato dal browser in silenzio e il login non regge mai.
|
||||
# SESSION_COOKIE_SECURE=false
|
||||
|
||||
# --- Vision ---
|
||||
# Internal address of the vision worker. Never exposed outside tmflow-net.
|
||||
VISION_WORKER_URL=http://vision:8100
|
||||
# The pinned VisionSuite commit, stamped into the vision image at build time
|
||||
# (Dockerfile.vision ARG). Required: `docker compose build vision` fails
|
||||
# immediately without it. Set with:
|
||||
# VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD)
|
||||
VISION_ENGINE_VERSION=
|
||||
|
||||
# --- File Storage ---
|
||||
UPLOAD_DIR=server/uploads
|
||||
# Resolved against the project root in src/backend/config.py.
|
||||
# Default "uploads" maps to <project_root>/uploads, mounted as a Docker
|
||||
# volume in production.
|
||||
UPLOAD_DIR=uploads
|
||||
MAX_UPLOAD_SIZE_MB=50
|
||||
|
||||
# --- Setup Page ---
|
||||
SETUP_PASSWORD= # Password per /api/setup, vuoto = disabilitato
|
||||
# Una volta creato un admin, /api/setup/{reset-db,seed,manage-user,
|
||||
# change-user-password,init-db} rispondono 403. true per un deploy demo/test
|
||||
# riprovisionato via script: non impostarlo su un'installazione reale.
|
||||
SETUP_ALLOW_AFTER_INIT=false
|
||||
|
||||
# --- AI (OpenRouter) ---
|
||||
OPENROUTER_API_KEY= # API key per parsing schede tecniche, vuoto = disabilitato
|
||||
OPENROUTER_MODEL=anthropic/claude-sonnet-4 # Modello AI da utilizzare
|
||||
|
||||
# --- Deploy (introdotte dalla revisione del 21/09/2026) ---
|
||||
# Dominio pubblico dell'installazione, usato nelle rule Traefik di
|
||||
# docker-compose.yml (produzione). Obbligatorio, senza default.
|
||||
APP_DOMAIN=
|
||||
# Quanti hop di reverse proxy fidati ci sono davanti al server (Nginx o
|
||||
# Traefik): serve a calcolare l'IP reale del tablet per il rate limiter.
|
||||
TRUSTED_PROXY_COUNT=1
|
||||
# Numero di worker Uvicorn del server. Il Dockerfile ne avvia 4 di default
|
||||
# indipendentemente da questa variabile; impostarla esplicitamente per farla
|
||||
# coincidere se si cambia il numero di worker nell'immagine.
|
||||
WEB_CONCURRENCY=1
|
||||
# Cookie di sessione Flask con flag Secure. true di default (richiede HTTPS,
|
||||
# come dietro Traefik in docker-compose.yml). Le installazioni on-premise su
|
||||
# rete LAN in HTTP semplice (docker-compose.dev.yml, Nginx senza SSL) la
|
||||
# impostano a false, altrimenti il browser non rimanda mai indietro il cookie.
|
||||
SESSION_COOKIE_SECURE=true
|
||||
# Timeout (secondi) di un'esecuzione del worker di visione.
|
||||
VISION_RUN_TIMEOUT_S=100
|
||||
# Quante esecuzioni di visione in parallelo accetta il worker.
|
||||
VISION_MAX_CONCURRENCY=2
|
||||
# #46: radice sotto cui il worker legge le cartelle ricetta (recipe_dir relativo).
|
||||
VISION_RECIPES_ROOT=/app/uploads
|
||||
|
||||
# --- Docker ---
|
||||
# DB_ROOT_PASSWORD: in produzione (docker-compose.yml) obbligatoria, senza default.
|
||||
DB_ROOT_PASSWORD=root_password_change_me
|
||||
NGINX_PORT=80
|
||||
NGINX_SSL_PORT=443
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# TieMeasureFlow#81: static del frontend sempre con LF, anche con core.autocrlf=true su Windows:
|
||||
# le impronte sha256 di static/vendor/VERSIONS.md (test_offline.py) e il pacchetto Windows le vogliono identiche.
|
||||
src/frontend/flask_app/static/** text=auto eol=lf
|
||||
@@ -0,0 +1,119 @@
|
||||
# TieMeasureFlow#76: setup.exe di TieMeasureFlow per Windows, generato al tag v*.
|
||||
# Cosa fa: submodule vendor/visionsuite (deploy key), build.ps1 (uv sync Python 3.13, Tailwind,
|
||||
# Nuitka --standalone, migrazioni .pyc, DLL di WeasyPrint, Inno Setup), poi il setup come
|
||||
# artifact e come asset della release Gitea del tag.
|
||||
# Da cosa dipende: un runner Gitea con label `windows` che giri su un host Windows (non in
|
||||
# container) con Visual Studio/Build Tools (MSVC), git e node (serve a actions/checkout e a
|
||||
# Tailwind); uv e Inno Setup li installa il workflow se mancano. Secret SUBMODULI_SSH_KEY
|
||||
# (deploy key di sola lettura di TieVision-Core, impostato sul repo il 17/10/2026).
|
||||
# Il runner `windows` e' act_runner in modalita' host su daitarn (capacity 1).
|
||||
# ⚠️ upload-artifact@v3: e' la versione che usa gia' vs-native.yml in TieVision-Core su questo
|
||||
# Gitea; la v4 usa un protocollo che act_runner puo' non supportare.
|
||||
# ⚠️ L'asset della release usa il token del job (secrets.GITHUB_TOKEN): se la chiamata risponde
|
||||
# 403 serve un token personale in un secret (es. RILASCI_TOKEN) al posto di GITHUB_TOKEN.
|
||||
# ⚠️ La build da freddo e' lunga (Nuitka compila backend, visione, plotly, opencv...): timeout 180 min.
|
||||
name: rilascio-windows
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
setup-windows:
|
||||
runs-on: windows
|
||||
timeout-minutes: 180
|
||||
defaults:
|
||||
run:
|
||||
shell: powershell
|
||||
steps:
|
||||
# Checkout senza ssh-key (come ci.yml): i submodule si inizializzano a parte, con la deploy key.
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Submodule privati (deploy key in sola lettura)
|
||||
env:
|
||||
SUBMODULI_SSH_KEY: ${{ secrets.SUBMODULI_SSH_KEY }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
# cartella temporanea del job, non ~/.ssh: su un runner host e' l'account dell'utente
|
||||
$ssh = Join-Path $env:TEMP ("submoduli-" + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Force $ssh | Out-Null
|
||||
$chiave = Join-Path $ssh 'chiave'
|
||||
# LF e newline finale: OpenSSH rifiuta una chiave con CRLF
|
||||
[System.IO.File]::WriteAllText($chiave, ($env:SUBMODULI_SSH_KEY.Trim() + "`n"), (New-Object System.Text.UTF8Encoding $false))
|
||||
# OpenSSH su Windows rifiuta una chiave leggibile da altri utenti; (F) e non (R):
|
||||
# con la sola lettura Remove-Item finale non puo' cancellarla
|
||||
icacls $chiave /inheritance:r | Out-Null
|
||||
icacls $chiave /grant:r "$($env:USERNAME):(F)" | Out-Null
|
||||
$known = Join-Path $ssh 'known_hosts'
|
||||
[System.IO.File]::WriteAllText($known, "[git.tielogic.xyz]:222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDvABW6Tvruqkf7KJhJZUiAX6NeCx0enwcNXzZbECQ8J`n", (New-Object System.Text.UTF8Encoding $false))
|
||||
$env:GIT_SSH_COMMAND = "ssh -i $($chiave -replace '\\','/') -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$($known -replace '\\','/')"
|
||||
try {
|
||||
git submodule update --init --recursive
|
||||
if ($LASTEXITCODE -ne 0) { throw "git submodule update fallito" }
|
||||
} finally {
|
||||
# la deploy key non resta mai sul disco del runner
|
||||
Remove-Item $ssh -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
- name: uv (se manca)
|
||||
run: |
|
||||
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
||||
irm https://astral.sh/uv/install.ps1 | iex
|
||||
}
|
||||
# il PATH del passo successivo
|
||||
"$env:USERPROFILE\.local\bin" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH
|
||||
|
||||
- name: Node (se manca)
|
||||
run: |
|
||||
# serve a Tailwind (npx) in build.ps1
|
||||
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
|
||||
winget install --id OpenJS.NodeJS.LTS -e --silent --accept-package-agreements --accept-source-agreements
|
||||
}
|
||||
"$env:ProgramFiles\nodejs" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH
|
||||
|
||||
- name: Inno Setup (se manca)
|
||||
run: |
|
||||
$trovato = (Get-Command ISCC.exe -ErrorAction SilentlyContinue) -or
|
||||
(Test-Path "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe") -or
|
||||
(Test-Path "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe")
|
||||
if (-not $trovato) {
|
||||
winget install --id JRSoftware.InnoSetup -e --silent --accept-package-agreements --accept-source-agreements
|
||||
}
|
||||
|
||||
- name: Build (Nuitka + Inno Setup)
|
||||
# GITHUB_REF_NAME e' il tag (v0.1.0); con workflow_dispatch e' il ramo: build.ps1 rifiuta
|
||||
# un nome senza numeri, quindi in quel caso si passa 0.0.0
|
||||
run: |
|
||||
$v = '${{ github.ref_name }}'
|
||||
if ($v -notmatch '^v?\d+\.\d+\.\d+') { $v = '0.0.0' }
|
||||
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 -Versione $v
|
||||
if ($LASTEXITCODE -ne 0) { throw "build.ps1 fallito" }
|
||||
|
||||
- name: Setup come artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: TieMeasureFlow-setup
|
||||
path: dist/TieMeasureFlow-setup-*.exe
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Asset della release Gitea (solo ai tag)
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$h = @{ Authorization = "token $env:TOKEN" }
|
||||
# la release del tag: la crea il primo job che arriva, poi si riusa
|
||||
try {
|
||||
$rel = Invoke-RestMethod -Headers $h "$env:API/releases/tags/$env:TAG"
|
||||
} catch {
|
||||
$corpo = @{ tag_name = $env:TAG; name = "TieMeasureFlow $env:TAG" } | ConvertTo-Json
|
||||
$rel = Invoke-RestMethod -Method Post -Headers $h -ContentType 'application/json' -Body $corpo "$env:API/releases"
|
||||
}
|
||||
$setup = Get-ChildItem dist\TieMeasureFlow-setup-*.exe | Select-Object -First 1
|
||||
# curl.exe (incluso in Windows 10+) fa il multipart senza codice a mano
|
||||
curl.exe --fail -sS -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@$($setup.FullName)" "$env:API/releases/$($rel.id)/assets?name=$($setup.Name)"
|
||||
if ($LASTEXITCODE -ne 0) { throw "caricamento dell'asset fallito" }
|
||||
+17
-11
@@ -33,21 +33,20 @@ env/
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Uploads (server-side files)
|
||||
server/uploads/images/*
|
||||
server/uploads/pdfs/*
|
||||
server/uploads/logos/*
|
||||
server/uploads/reports/*
|
||||
!server/uploads/images/.gitkeep
|
||||
!server/uploads/pdfs/.gitkeep
|
||||
!server/uploads/logos/.gitkeep
|
||||
!server/uploads/reports/.gitkeep
|
||||
# Uploads (server-side files, mounted as a volume in production): ignora tutto
|
||||
# sotto uploads/, comprese le sottocartelle create a runtime (vision reference
|
||||
# images, cartelle per ricetta/versione) — tieni solo i .gitkeep che mantengono
|
||||
# le cartelle vuote in git.
|
||||
uploads/*
|
||||
!uploads/**/.gitkeep
|
||||
|
||||
# TailwindCSS output
|
||||
client/static/css/tailwind.css
|
||||
src/frontend/flask_app/static/css/tailwind.css
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
src/frontend/flask_app/package.json
|
||||
src/frontend/flask_app/package-lock.json
|
||||
|
||||
# Flask-Babel compiled
|
||||
*.mo
|
||||
@@ -63,10 +62,17 @@ node_modules/
|
||||
htmlcov/
|
||||
|
||||
# Debug files
|
||||
client/static/js/fabric-debug.js
|
||||
src/frontend/flask_app/static/js/fabric-debug.js
|
||||
|
||||
# Misc
|
||||
nul
|
||||
|
||||
# Competitor analysis (local only)
|
||||
Concorrente/
|
||||
docker-compose.override.yml
|
||||
|
||||
# Subagent-driven development scratch: ledger, briefs, review packages
|
||||
.superpowers/
|
||||
|
||||
# Generato da packaging/windows/build.ps1 (TieMeasureFlow#82): moduli-strumento dagli entry point
|
||||
packaging/windows/_strumenti.py
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "vendor/visionsuite"]
|
||||
path = vendor/visionsuite
|
||||
url = ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git
|
||||
@@ -0,0 +1 @@
|
||||
3.11
|
||||
@@ -0,0 +1 @@
|
||||
*
|
||||
@@ -5,10 +5,70 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## Panoramica
|
||||
|
||||
TieMeasureFlow by Tielogic - Sistema di gestione task per misurazioni con calibro manuale.
|
||||
Monorepo con **server FastAPI** (backend API, porta 8000) e **client Flask** (frontend tablet, porta 5000), orchestrati con Docker Compose + Nginx reverse proxy + MySQL 8.0.
|
||||
Monorepo con **backend FastAPI** (porta 8000) e **frontend Flask** (porta 5000), orchestrati con Docker Compose + Nginx reverse proxy + MySQL 8.0.
|
||||
|
||||
## Layout del repository (V3.0.0)
|
||||
|
||||
A partire dalla migrazione V2.0.0 (struttura conforme alla spec `python-project-spec-design.md`), con l'aggiunta del modulo di visione in V3.0.0 (`src/vision`, `src/vision_worker`, `vendor/visionsuite`):
|
||||
|
||||
```
|
||||
TieMeasureFlow/
|
||||
├── pyproject.toml # Dipendenze monorepo (uv)
|
||||
├── uv.lock # Lock file riproducibile
|
||||
├── .python-version # 3.11
|
||||
├── Dockerfile # Backend (uv + uvicorn)
|
||||
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
|
||||
├── Dockerfile.vision # Worker di visione (uv, Python 3.13)
|
||||
├── docker-compose.dev.yml # Dev (Nginx)
|
||||
├── docker-compose.yml # Prod (Traefik + SSL)
|
||||
├── nginx/
|
||||
├── uploads/ # Volume montato in /app/uploads
|
||||
├── vendor/
|
||||
│ └── visionsuite/ # Submodule: libreria di visione (vs-core, vs-task, vs-measure, vs-pm2d)
|
||||
├── docs/
|
||||
└── src/
|
||||
├── backend/
|
||||
│ ├── main.py # Entry FastAPI
|
||||
│ ├── config.py
|
||||
│ ├── database.py
|
||||
│ ├── api/
|
||||
│ │ ├── routers/ # 11 router REST
|
||||
│ │ └── middleware/ # api_key, rate_limit, security_headers, logging
|
||||
│ ├── models/
|
||||
│ │ ├── orm/ # SQLAlchemy
|
||||
│ │ └── api/ # Pydantic schemas
|
||||
│ ├── services/ # Logica business
|
||||
│ ├── migrations/ # Alembic
|
||||
│ ├── templates/ # Setup page
|
||||
│ └── tests/ # pytest
|
||||
├── frontend/
|
||||
│ └── flask_app/ # Flask + Jinja2 + Alpine.js (deroga vs spec React,
|
||||
│ ├── app.py # giustificata: tablet UX server-side, USB calipers,
|
||||
│ ├── config.py # workflow operatore con Fabric.js)
|
||||
│ ├── blueprints/
|
||||
│ ├── services/
|
||||
│ ├── templates/
|
||||
│ ├── static/
|
||||
│ ├── translations/
|
||||
│ └── tests/
|
||||
├── vision/ # Runner che esegue i grafi di VisionSuite (Python 3.13)
|
||||
└── vision_worker/ # Shell FastAPI attorno al runner, container separato
|
||||
```
|
||||
|
||||
Dipendenze gestite con **uv** (no `requirements.txt`):
|
||||
- `[project] dependencies` = core condivisi (pydantic, dotenv)
|
||||
- `[project.optional-dependencies] server` = backend FastAPI
|
||||
- `[project.optional-dependencies] client` = frontend Flask
|
||||
- `[project.optional-dependencies] dev` = pytest, httpx, aiosqlite
|
||||
|
||||
## Comandi di Sviluppo
|
||||
|
||||
### Setup iniziale
|
||||
```bash
|
||||
cp .env.example .env
|
||||
uv sync --extra server --extra client --extra dev # installa tutto
|
||||
```
|
||||
|
||||
### Avvio servizi (Docker)
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d # Sviluppo (Nginx, porta 80)
|
||||
@@ -20,51 +80,63 @@ docker compose ps # Stato servizi
|
||||
|
||||
### Avvio manuale (senza Docker)
|
||||
```bash
|
||||
# Server (terminale 1)
|
||||
cd server && uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
# Backend (terminale 1)
|
||||
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
# Client (terminale 2)
|
||||
cd client && flask run --host 0.0.0.0 --port 5000
|
||||
# Frontend (terminale 2) — gunicorn richiede cwd interna per app:create_app()
|
||||
cd src/frontend/flask_app && uv run --project ../../.. gunicorn --bind 0.0.0.0:5000 app:create_app()
|
||||
|
||||
# TailwindCSS watch (terminale 3)
|
||||
cd client && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
||||
cd src/frontend/flask_app && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
||||
```
|
||||
|
||||
### Database & Migrations
|
||||
```bash
|
||||
# alembic.ini è in server/migrations/, serve il flag -c
|
||||
cd server && alembic -c migrations/alembic.ini upgrade head # Applica migrazioni
|
||||
cd server && alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione
|
||||
cd server && alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima
|
||||
docker compose exec server alembic -c migrations/alembic.ini upgrade head # Via Docker
|
||||
# alembic.ini in src/backend/migrations/, serve il flag -c
|
||||
uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Applica migrazioni
|
||||
uv run alembic -c src/backend/migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera
|
||||
uv run alembic -c src/backend/migrations/alembic.ini downgrade -1 # Rollback
|
||||
docker compose exec server uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Via Docker
|
||||
```
|
||||
Nota: `env.py` sovrascrive la URL di alembic.ini con quella da `.env` (`settings.database_url`). `script_location = %(here)s` usa path relativo.
|
||||
Nota: `env.py` aggiunge la project root a `sys.path`, sovrascrive la URL di alembic.ini con quella da `.env` (`settings.database_url`). `script_location = %(here)s` usa path relativo.
|
||||
|
||||
### Test
|
||||
```bash
|
||||
# Server (usa SQLite in-memory via aiosqlite, no MySQL richiesto)
|
||||
cd server && pytest # Tutti i test
|
||||
cd server && pytest tests/test_auth.py # Singolo modulo
|
||||
cd server && pytest tests/test_auth.py::test_login_success # Singolo test
|
||||
cd server && pytest --cov # Con copertura
|
||||
# Tutti i test (backend + frontend)
|
||||
uv run pytest
|
||||
|
||||
# Client
|
||||
cd client && pytest
|
||||
cd client && pytest tests/test_auth.py
|
||||
# Solo backend (SQLite in-memory via aiosqlite, no MySQL richiesto)
|
||||
uv run pytest src/backend/tests/
|
||||
uv run pytest src/backend/tests/test_auth.py
|
||||
uv run pytest src/backend/tests/test_auth.py::test_login_success
|
||||
uv run pytest --cov src/backend
|
||||
|
||||
# Solo frontend
|
||||
uv run pytest src/frontend/flask_app/tests/
|
||||
```
|
||||
|
||||
### Gestione dipendenze (uv)
|
||||
```bash
|
||||
uv add <pacchetto> # core (entrambi)
|
||||
uv add --optional server <pacchetto> # solo backend
|
||||
uv add --optional client <pacchetto> # solo frontend
|
||||
uv add --optional dev <pacchetto> # solo dev/test
|
||||
uv sync --extra server --extra client --extra dev # reinstalla
|
||||
uv lock # rigenera uv.lock
|
||||
```
|
||||
|
||||
### i18n (Traduzioni)
|
||||
```bash
|
||||
# Estrai stringhe
|
||||
cd client && pybabel extract -F babel.cfg -k _ -o translations/messages.pot .
|
||||
cd src/frontend/flask_app && uv run pybabel extract -F translations/babel.cfg -k _ -o translations/messages.pot .
|
||||
|
||||
# Aggiorna catalogo
|
||||
cd client && pybabel update -i translations/messages.pot -d translations
|
||||
cd src/frontend/flask_app && uv run pybabel update -i translations/messages.pot -d translations
|
||||
|
||||
# Compila .po → .mo
|
||||
cd client && pybabel compile -d translations
|
||||
cd src/frontend/flask_app && uv run pybabel compile -d translations
|
||||
# oppure
|
||||
cd client && python compile_translations.py
|
||||
cd src/frontend/flask_app && uv run python compile_translations.py
|
||||
```
|
||||
|
||||
### Setup iniziale
|
||||
@@ -78,7 +150,7 @@ Browser/Tablet → Nginx (:80/443) → Flask Client (:5000) → APIClient → Fa
|
||||
```
|
||||
Il client Flask è un frontend server-side che comunica col backend via REST API. Ogni richiesta dal client al server include l'header `X-API-Key` per autenticazione.
|
||||
|
||||
### Server (FastAPI) — `server/`
|
||||
### Server (FastAPI) — `src/backend/`
|
||||
- **main.py**: entry point, lifespan async (`@asynccontextmanager`), registra middleware e 10 router. Health: `GET /api/health`
|
||||
- **config.py**: `Settings` (pydantic_settings.BaseSettings), legge da `../.env`. Rate limits: login 5/min, general 100/min
|
||||
- **database.py**: SQLAlchemy 2.0 async engine con `AsyncSession`, pool 10+20 overflow, `pool_recycle=3600`, `expire_on_commit=False`
|
||||
@@ -87,10 +159,10 @@ Il client Flask è un frontend server-side che comunica col backend via REST API
|
||||
- **middleware/**: Stack order (outermost→innermost): AccessLogMiddleware → CORSMiddleware → SecurityHeadersMiddleware → RateLimitMiddleware. Nota: `add_middleware()` in Starlette wrappa l'app, quindi l'ultimo aggiunto (AccessLog) è il più esterno. Il commento in `main.py` dice "outermost" per RateLimit ma è fuorviante. api_key.py (auth dependency `get_current_user()`), rate_limit.py (sliding window 60s per-IP, in-memory dicts), security_headers.py (CSP con `unsafe-eval` per Plotly.js, HSTS solo con SSL), logging.py (audit trail async su DB, esclude /api/health, /docs, /openapi.json, /redoc)
|
||||
- **models/**: User (include `email`, `language_pref`, `theme_pref`), Recipe (`image_path` per preview), RecipeVersion, RecipeTask, RecipeSubtask (`image_path` per immagine specifica), Measurement (`synced_to_csv`, `input_method`), AccessLog, SystemSetting, RecipeVersionAudit
|
||||
- **schemas/**: Pydantic v2 per validazione I/O API
|
||||
- **migrations/**: Alembic con `alembic.ini` e `env.py` nella directory `server/migrations/`
|
||||
- **migrations/**: Alembic con `alembic.ini` e `env.py` nella directory `src/backend/migrations/`
|
||||
- **tests/**: pytest + pytest-asyncio, SQLite in-memory (`sqlite+aiosqlite://`, StaticPool), WeasyPrint mockato via `sys.modules`, rate limit reset tra test
|
||||
|
||||
### Client (Flask) — `client/`
|
||||
### Client (Flask) — `src/frontend/flask_app/`
|
||||
- **app.py**: factory pattern `create_app()`, CSRF (`WTF_CSRF_TIME_LIMIT=3600`), Babel i18n (`default_locale="it"`)
|
||||
- **blueprints/**: auth (login/logout/session), maker (editor ricette con Fabric.js), measure (esecuzione misurazioni), statistics (dashboard SPC con Plotly.js), admin (gestione utenti CRUD, cambio password, toggle attivo — solo `is_admin`)
|
||||
- **services/api_client.py**: singleton `APIClient` — wrapper HTTP (get/post/put/delete) con gestione errori normalizzata, timeout 30s, header X-API-Key da session
|
||||
@@ -99,7 +171,7 @@ Il client Flask è un frontend server-side che comunica col backend via REST API
|
||||
- **translations/**: Flask-Babel, cataloghi .po/.mo per IT/EN. Locale selector: `session["language"]` → Accept-Language → `"it"`
|
||||
- **config.py**: `PERMANENT_SESSION_LIFETIME=28800` (8h), cookie secure in produzione, `BABEL_DEFAULT_TIMEZONE="Europe/Rome"`
|
||||
|
||||
## Template Structure (`client/templates/base.html`)
|
||||
## Template Structure (`src/frontend/flask_app/templates/base.html`)
|
||||
|
||||
Ordine blocchi in `base.html`:
|
||||
```
|
||||
@@ -127,7 +199,7 @@ In alternativa, usare il filtro custom `|tojson_attr` (registrato in `app.py`) c
|
||||
|
||||
Per selettori CSS in `x-data`: usare `meta[name=csrf-token]` senza virgolette interne.
|
||||
|
||||
## Fabric.js Annotation Editor (`client/static/js/annotation-editor.js`)
|
||||
## Fabric.js Annotation Editor (`src/frontend/flask_app/static/js/annotation-editor.js`)
|
||||
|
||||
Editor annotazioni su disegni tecnici (Fabric.js 5.3.1, ~1200 righe). Pattern critici:
|
||||
|
||||
@@ -152,16 +224,16 @@ Le ricette usano un versioning condizionale. L'endpoint `PUT /api/recipes/{id}`
|
||||
|
||||
La stessa logica si applica nel task router: aggiungere un task a una ricetta con measurements crea una nuova versione.
|
||||
|
||||
La versione corrente ha `is_current=True`, le precedenti `False`. Audit trail in `recipe_version_audit` (CREATE, UPDATE, ACTIVATE, RETIRE). Logica in `server/services/recipe_service.py`.
|
||||
La versione corrente ha `is_current=True`, le precedenti `False`. Audit trail in `recipe_version_audit` (CREATE, UPDATE, ACTIVATE, RETIRE). Logica in `src/backend/services/recipe_service.py`.
|
||||
|
||||
### Calcolo Pass/Fail
|
||||
Ogni subtask ha 4 limiti di tolleranza: UTL (upper tolerance), UWL (upper warning), LWL (lower warning), LTL (lower tolerance) più un valore nominale. Il calcolo in `server/services/measurement_service.py`:
|
||||
Ogni subtask ha 4 limiti di tolleranza: UTL (upper tolerance), UWL (upper warning), LWL (lower warning), LTL (lower tolerance) più un valore nominale. Il calcolo in `src/backend/services/measurement_service.py`:
|
||||
- Fuori UTL/LTL → **fail**
|
||||
- Fuori UWL/LWL ma dentro UTL/LTL → **warning**
|
||||
- Dentro UWL/LWL → **pass**
|
||||
|
||||
### SPC (Statistical Process Control)
|
||||
Calcoli in `server/services/spc_service.py` usando solo `math` e `statistics` stdlib (no numpy/scipy): summary (conteggi pass/warning/fail), capability (Cp, Cpk, Pp, Ppk), control chart (UCL/LCL = mean ± 3σ), histogram (20 bin + curva normale).
|
||||
Calcoli in `src/backend/services/spc_service.py` usando solo `math` e `statistics` stdlib (no numpy/scipy): summary (conteggi pass/warning/fail), capability (Cp, Cpk, Pp, Ppk), control chart (UCL/LCL = mean ± 3σ), histogram (20 bin + curva normale).
|
||||
|
||||
### Autenticazione
|
||||
1. Login con username/password → server ritorna `api_key` (64 char random)
|
||||
@@ -187,7 +259,7 @@ Upload in `uploads/{recipe_id}/{version_id}/`. Tipi ammessi: JPEG, PNG, GIF, Web
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
### Server (`server/tests/conftest.py`)
|
||||
### Server (`src/backend/tests/conftest.py`)
|
||||
- SQLite in-memory `sqlite+aiosqlite://` con `StaticPool` (singola connessione condivisa tra fixture, app e test)
|
||||
- WeasyPrint mockato prima di qualsiasi import server: `sys.modules["weasyprint"] = MagicMock()`
|
||||
- Rate limit buckets resettati tra test (walk middleware stack → clear dicts)
|
||||
@@ -196,7 +268,7 @@ Upload in `uploads/{recipe_id}/{version_id}/`. Tipi ammessi: JPEG, PNG, GIF, Web
|
||||
- Helper: `auth_headers(user)` → `{"X-API-Key": user.api_key}`
|
||||
- Client httpx: `AsyncClient` con `ASGITransport(app=app)`, override di `get_db` dependency
|
||||
|
||||
### Client (`client/tests/conftest.py`)
|
||||
### Client (`src/frontend/flask_app/tests/conftest.py`)
|
||||
- `api_client` patchato in 4 blueprint (auth, maker, measure, statistics). **admin NON è patchato** — i test admin devono gestire il mock manualmente
|
||||
- `logged_in_client` fixture pre-popola session con `api_key`, `user_id`, `language`, `theme` + user dict
|
||||
- CSRF disabilitato nei test: `WTF_CSRF_ENABLED=False`
|
||||
@@ -213,6 +285,25 @@ Il server esegue Alembic migrations automaticamente all'avvio (`CMD` include `al
|
||||
|
||||
Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120s`, cache statica 7d, security headers duplicati a livello proxy.
|
||||
|
||||
## Visione (V3.0.0)
|
||||
|
||||
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. 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:
|
||||
|
||||
```bash
|
||||
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||
docker compose -f docker-compose.dev.yml build vision
|
||||
```
|
||||
|
||||
Test del runner e del worker, in un ambiente Python 3.13 dedicato (con `uv run pytest` di default, su Python 3.11, questi test risultano *skipped*, non falliti):
|
||||
|
||||
```bash
|
||||
uv run --python 3.13 --extra vision --extra vision-worker --extra dev pytest src/vision/tests src/vision_worker/tests
|
||||
```
|
||||
|
||||
## Convenzioni Codice
|
||||
- Python 3.11+, type hints ovunque
|
||||
- async/await per tutte le operazioni DB (server)
|
||||
@@ -232,7 +323,7 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
|
||||
- DB: `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_ROOT_PASSWORD` (Docker)
|
||||
- Server: `SERVER_HOST`, `SERVER_PORT`, `SERVER_SECRET_KEY`, `SERVER_CORS_ORIGINS`
|
||||
- Client: `CLIENT_HOST`, `CLIENT_PORT`, `CLIENT_SECRET_KEY`, `API_SERVER_URL`
|
||||
- Upload: `UPLOAD_DIR` (default `"uploads"`, relativo a `server/`), `MAX_UPLOAD_SIZE_MB`
|
||||
- Upload: `UPLOAD_DIR` (default `"uploads"`, relativo a `src/backend/`), `MAX_UPLOAD_SIZE_MB`
|
||||
- Docker: `NGINX_PORT`, `NGINX_SSL_PORT`
|
||||
- Setup: `SETUP_PASSWORD` (vuota = endpoint disabilitato)
|
||||
- SSL: `SSL_CERTFILE`, `SSL_KEYFILE`
|
||||
@@ -244,3 +335,25 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
|
||||
- Font UI: Inter
|
||||
- Font numeri: JetBrains Mono
|
||||
- Dark mode: `darkMode: 'class'` in tailwind.config.js, toggle via `Alpine.store('theme')` con localStorage key `tmf-theme`
|
||||
|
||||
## Pacchetto Windows (TieMeasureFlow#76)
|
||||
|
||||
- File: `packaging/windows/avvio.py` (entry point: licenza, `config.env`, variabili d'ambiente, migrazioni, quattro server in thread), `build.ps1` (uv sync 3.13, Tailwind, Nuitka `--standalone`, migrazioni come `.pyc`, Inno Setup), `TieMeasureFlow.iss`, `TieMeasureFlow.ico`; CI `.gitea/workflows/rilascio-windows.yml` (tag `v*`, runner con etichetta `windows`); test in `packaging/windows/tests/` (licenza firmata con la chiave di prova del contratto, server e migrazioni sostituiti). Piani collegati: SQLite su file (#77) e PDF/DLL (#78). Modello: TieVision-Studio#1.
|
||||
- Un solo processo `TieMeasureFlow.exe`: thread `web` (Flask + waitress, `--host`, 8780), `backend` (8000), `vision` (8100), `camera` (8765) con uvicorn. Dati in `%PROGRAMDATA%\Tielogic\TieMeasureFlow` (`tmf.db`, `uploads`, `camera`, `log`, `config.env`); licenza in `%PROGRAMDATA%\Tielogic\licenze\tiemeasureflow.json` (mai nel repo).
|
||||
- ⚠️ Python del pacchetto: 3.13 in un ambiente separato (`build\venv313`, `UV_PROJECT_ENVIRONMENT`); il repo resta su `.python-version` 3.11 per lo sviluppo. `vs-license` e `vs-camera` hanno il marker `python_version>='3.13'` nel gruppo `windows`, altrimenti `uv lock` fallisce sul `requires-python >=3.11`.
|
||||
- ⚠️ Le variabili d'ambiente si impostano PRIMA di importare backend e frontend (leggono la configurazione all'import): in `avvio.py` gli import pesanti stanno dentro le funzioni. `APP_ENV=production` rifiuta i segreti di default, quindi `config.env` genera anche `DB_PASSWORD` (inutilizzata con SQLite).
|
||||
- ⚠️ Il frontend Flask non è un pacchetto (`from config import Config`, `blueprints`, `services`, `app`) e `scripts/camera_service.py` neppure: da sorgente `avvio.py` aggiunge `flask_app` e `scripts` al `sys.path`; per Nuitka `build.ps1` li mette su `PYTHONPATH` e li include con `--include-module/--include-package`. I dati di Flask (`templates`, `static`, `translations`) vanno nella radice della cartella standalone, `src\backend\templates` sotto `src\backend`.
|
||||
- ⚠️ Il browser parla con Flask (8780); la pagina di setup `/api/setup` invece è del backend: si apre su `http://127.0.0.1:8000/api/setup` (in Docker la serve nginx). `avvio.py` la apre da sola se il database non ha utenti.
|
||||
- ⚠️ Alembic carica `env.py` e `versions\` dal disco, quindi Nuitka non può compilarli: `build.ps1` li copia come soli `.pyc` (`compileall -b`) e `avvio.py` esegue `upgrade head` con `sourceless=true`. È l'unica eccezione al «nessun `.py`/`.pyc`»: il controllo finale di `build.ps1` ammette `.pyc` solo sotto `src\backend\migrations`. `env.py` chiama `fileConfig`, che azzera i gestori del log: `avvio.py` li ricrea dopo le migrazioni.
|
||||
- ⚠️ `numba` è escluso dal pacchetto (`--nofollow-import-to=numba`): le funzioni compilate da Nuitka non hanno bytecode e numba non può farne il JIT. Core e pm2d hanno il ripiego in Python puro (`HAS_NUMBA=False`): funziona ma il matching è più lento. Da misurare sul TC bench.
|
||||
- ⚠️ L'exe è senza console: `sys.stdout`/`sys.stderr` possono essere `None` (`avvio.py` li sostituisce con il device nullo), uvicorn gira con `log_config=None` e il log va su file (`...\log\tiemeasureflow.log`). I server uvicorn girano in thread (nessun signal handler) e waitress si crea con `create_server`.
|
||||
- ⚠️ Strumenti di visione (TieMeasureFlow#82): nel binario non ci sono i metadati di `vs-tools`, quindi `load_tools()` (entry point `visionsuite.task.tools`) non trova nulla e ogni ricetta fallisce con tipo sconosciuto. `build.ps1` genera `packaging/windows/_strumenti.py` (`MODULI`, gitignorato, letto dal venv del build), `avvio.py` `registra_strumenti()` lo importa prima dei server, e prima di Inno Setup `build.ps1` lancia l'exe con `--controlla-strumenti` (senza licenza né server; JSON in `...\log\controllo-strumenti.json`, exit 3 se il catalogo è vuoto o un modulo non si importa).
|
||||
- ⚠️ `visionsuite` è un namespace condiviso da più distribuzioni: `build.ps1` elenca i sottopacchetti dagli `__path__` e i metadati con `--include-distribution-metadata`. Un nuovo pacchetto di Core si prende da solo se è in `[tool.uv.sources]` e nel gruppo.
|
||||
- ⚠️ `tailwind.css` non è nel repo: `build.ps1` lo genera con `npx tailwindcss@3.4.19` (stessa versione di `Dockerfile.frontend`); serve Internet solo a chi costruisce. Il setup finale non ha bisogno di Internet.
|
||||
- ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe.
|
||||
- ⚠️ `SETUP_PASSWORD` e le chiavi stanno in `config.env`, in una cartella scrivibile da tutti gli utenti locali (serve all'exe, che gira con l'utente che lo lancia): non è un luogo per segreti ad alto valore.
|
||||
- ⚠️ CI di rilascio: il runner `windows` è su daitarn (act_runner, modalità host, capacity 1); `upload-artifact` è alla v3. Non committare `build/` e `dist/`.
|
||||
- ⚠️ `STATION_CODE`: senza una stazione l'interfaccia risponde 503 («Stazione non configurata»). `config.env` nasce con `STATION_CODE=ST-DEFAULT` (la stazione creata dal seed della pagina di setup); le chiavi extra di `config.env` (`STATION_CODE`, `STATION_SWITCH_ENABLED`, ...) passano all'ambiente ma non sovrascrivono le variabili fisse del contratto.
|
||||
- ⚠️ Alembic anche fuori da `avvio.py`: `setup.py` (`_stamp_head`) apre `alembic.ini` con un `Config` suo, quindi `build.ps1` scrive `sourceless = true` nell'`alembic.ini` del pacchetto; e `env.py` chiama `logging.config.fileConfig`, che azzererebbe il log su file, quindi `avvio.py` lo sostituisce con un no-op.
|
||||
- ⚠️ Bug noto, non del pacchetto: `POST /api/setup/init-db` risponde 500 (`asyncio.run() cannot be called from a running event loop`, perché `_stamp_head` esegue `env.py` dentro il loop). Nel pacchetto non serve: le migrazioni creano già le tabelle; la pagina di setup si usa per «Seed» o per creare l'amministratore.
|
||||
- ⚠️ Le richieste di login all'API sono limitate (5 al minuto): negli script di prova distanziarle.
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
FROM python:3.11.16-slim AS base
|
||||
|
||||
# Install uv (fast Python package manager) from official slim image.
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /usr/local/bin/
|
||||
|
||||
# System libs required by WeasyPrint at runtime.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libcairo2 \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
libffi-dev \
|
||||
shared-mime-info \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user the app runs as at runtime. -m creates a home dir: `uv run`
|
||||
# in the CMD below writes its cache under $HOME, and without one it has
|
||||
# nowhere writable to put it.
|
||||
RUN useradd -m -u 10001 app
|
||||
# Independent of $HOME regardless: keeps the cache path explicit and writable
|
||||
# by the app user even if the home directory assumption ever changes.
|
||||
ENV UV_CACHE_DIR=/tmp/uv-cache
|
||||
# `uv run` must use the venv built by `uv sync` below as it is: re-validating
|
||||
# the project would look for vendor/visionsuite (the vision-worker extra), which
|
||||
# this image does not carry (issue 39). `uv sync` itself is not affected by it.
|
||||
ENV UV_NO_SYNC=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy lockfile + project metadata first to maximize Docker layer cache.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY .python-version ./
|
||||
|
||||
# Install ONLY backend deps from the locked dependencies. --frozen ensures
|
||||
# we never resolve at build time; --no-dev keeps the image lean.
|
||||
RUN uv sync --frozen --no-dev --extra server
|
||||
|
||||
# Now copy the actual sources.
|
||||
COPY src/ ./src/
|
||||
|
||||
# Uploads directory (mounted as a volume in production).
|
||||
RUN mkdir -p /app/uploads/images /app/uploads/pdfs /app/uploads/logos /app/uploads/reports \
|
||||
&& chown -R app:app /app /tmp/uv-cache
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Entry point: run Alembic migrations then start Uvicorn through uv so
|
||||
# it uses the pinned interpreter and venv from `uv sync`.
|
||||
CMD ["sh", "-c", \
|
||||
"uv run alembic -c src/backend/migrations/alembic.ini upgrade head && \
|
||||
uv run uvicorn src.backend.main:app \
|
||||
--host 0.0.0.0 --port 8000 --workers 4 \
|
||||
--proxy-headers --forwarded-allow-ips='*'"]
|
||||
@@ -0,0 +1,71 @@
|
||||
# TailwindCSS built in its own stage, from the official Node image, instead
|
||||
# of `curl | bash` against nodesource inside the final image (point 2, C2).
|
||||
FROM node:20.20.2-bookworm-slim AS tailwind-build
|
||||
|
||||
WORKDIR /app/flask_app
|
||||
COPY src/frontend/flask_app/ ./
|
||||
|
||||
# Versione esatta, non `tailwindcss@3`. Con il vincolo aperto ogni ricostruzione
|
||||
# dell'immagine poteva prendere una minor diversa e produrre un CSS diverso senza
|
||||
# che nessuno l'avesse validato: per un sistema che produce evidenze per audit
|
||||
# ISO 9001 / IATF 16949 è lo stesso problema che il punto 12 risolve per le
|
||||
# librerie del browser. Aggiornarla è una decisione, non un effetto collaterale.
|
||||
RUN npm install tailwindcss@3.4.19 && \
|
||||
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
||||
|
||||
FROM python:3.11.16-slim AS base
|
||||
|
||||
# uv from the official slim image (fast Python package manager).
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /usr/local/bin/
|
||||
|
||||
# Non-root user the app runs as at runtime. -m creates a home dir: `uv run`
|
||||
# in the CMD below writes its cache under $HOME, and without one it has
|
||||
# nowhere writable to put it.
|
||||
RUN useradd -m -u 10001 app
|
||||
# Independent of $HOME regardless: keeps the cache path explicit and writable
|
||||
# by the app user even if the home directory assumption ever changes.
|
||||
ENV UV_CACHE_DIR=/tmp/uv-cache
|
||||
# `uv run` must use the venv built by `uv sync` below as it is: re-validating
|
||||
# the project would look for vendor/visionsuite (the vision-worker extra), which
|
||||
# this image does not carry (issue 39). `uv sync` itself is not affected by it.
|
||||
ENV UV_NO_SYNC=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Resolve Python deps from the project lockfile (only the `client` extra).
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY .python-version ./
|
||||
RUN uv sync --frozen --no-dev --extra client
|
||||
|
||||
# Copy the Flask app sources, then overlay the TailwindCSS build produced in
|
||||
# the node stage above — the final image never installs Node.
|
||||
COPY src/frontend/flask_app/ ./flask_app/
|
||||
COPY --from=tailwind-build /app/flask_app/static/css/tailwind.css ./flask_app/static/css/tailwind.css
|
||||
|
||||
# Compile Flask-Babel translation catalogs.
|
||||
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
|
||||
|
||||
# Gunicorn behind Nginx/Traefik. Worker count and proxy trust kept in sync
|
||||
# with the per-tablet rate-limiting fix (see V2.0.0 perf commit).
|
||||
CMD ["uv", "run", "--project", "/app", "gunicorn", \
|
||||
"--workers", "5", \
|
||||
"--threads", "4", \
|
||||
"--worker-class", "gthread", \
|
||||
"--timeout", "60", \
|
||||
"--bind", "0.0.0.0:5000", \
|
||||
"--access-logfile", "-", \
|
||||
"--forwarded-allow-ips", "*", \
|
||||
"app:create_app()"]
|
||||
@@ -0,0 +1,53 @@
|
||||
FROM python:3.13.15-slim
|
||||
|
||||
# opencv-python (pulled in transitively by vs-core/vs-task/vs-measure/vs-pm2d)
|
||||
# needs libGL and glib at runtime even though it is never imported by name here.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libgl1 libglib2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /usr/local/bin/uv
|
||||
|
||||
# Non-root user the app runs as at runtime. Same UID as the server's Dockerfile
|
||||
# so both containers agree on ownership of the shared uploads volume (point 13).
|
||||
# -m creates a home dir: `uv run` in the CMD below writes its cache under
|
||||
# $HOME, and without one it has nowhere writable to put it.
|
||||
RUN useradd -m -u 10001 app
|
||||
# Independent of $HOME regardless: keeps the cache path explicit and writable
|
||||
# by the app user even if the home directory assumption ever changes.
|
||||
ENV UV_CACHE_DIR=/tmp/uv-cache
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY vendor/ ./vendor/
|
||||
RUN uv sync --extra vision-worker --frozen --no-dev
|
||||
|
||||
COPY src/vision/ ./src/vision/
|
||||
COPY src/vision_worker/ ./src/vision_worker/
|
||||
|
||||
# Ruling R8: the version is stamped at build time, not discovered at runtime.
|
||||
# COPY vendor/ above does not bring vendor/visionsuite/.git along - it is a
|
||||
# gitlink file pointing at ../../.git/modules/vendor/visionsuite, which lives
|
||||
# outside the build context - so `git rev-parse HEAD` cannot work in here.
|
||||
# Pass the submodule's commit in from the host, which does have git:
|
||||
# VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||
# docker compose build vision
|
||||
ARG VISION_ENGINE_VERSION
|
||||
# I4: a build without the commit must fail now, at build time - not later, at
|
||||
# every request including /health, with a container Docker still reports as
|
||||
# healthy because no healthcheck existed to say otherwise.
|
||||
RUN test -n "$VISION_ENGINE_VERSION" || { \
|
||||
echo "VISION_ENGINE_VERSION is required. Build with:"; \
|
||||
echo ' VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) docker compose build vision'; \
|
||||
exit 1; \
|
||||
}
|
||||
ENV VISION_ENGINE_VERSION=${VISION_ENGINE_VERSION}
|
||||
|
||||
RUN chown -R app:app /app /tmp/uv-cache
|
||||
USER app
|
||||
|
||||
EXPOSE 8100
|
||||
|
||||
# Two workers, not four: each holds the vision stack in memory.
|
||||
CMD ["uv", "run", "uvicorn", "src.vision_worker.main:app", \
|
||||
"--host", "0.0.0.0", "--port", "8100", "--workers", "2"]
|
||||
@@ -1,6 +1,10 @@
|
||||
# TieMeasureFlow by Tielogic
|
||||
|
||||
Sistema di gestione task per misurazioni con calibro manuale. Soluzione tablet-first, multi-ruolo, con statistiche SPC (Statistical Process Control) integrate.
|
||||
Sistema di gestione task per misurazioni con calibro manuale. Soluzione tablet-first, multi-ruolo, con statistiche SPC (Statistical Process Control) integrate, identità per-stazione e rate limiting per-tablet.
|
||||
|
||||
> **Versione corrente:** V3.0.0 (in sviluppo) — branch `V3.0.0`.
|
||||
> Per stato dettagliato e prossimi passi vedi [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) e [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md).
|
||||
> V3.0.0 lavora sui quindici punti di `TieMeasureFlow_modifiche_2026-07-28.md`: vedi [Novità di V3.0.0](#novità-di-v300).
|
||||
|
||||
---
|
||||
|
||||
@@ -12,10 +16,39 @@ Caratteristiche principali:
|
||||
|
||||
- Recipe versioning condizionale (copy-on-write se esistono misurazioni, update in-place altrimenti)
|
||||
- Calcolo pass/fail/warning su quattro limiti di tolleranza (UTL, UWL, LWL, LTL)
|
||||
- Tracciamento del tempo di inserimento per ogni misura (`input_duration_ms`): il client misura quanto tempo l'operatore impiega su ciascun subtask, dall'attivazione al salvataggio, utile per analisi di tempo ciclo e produttività
|
||||
- SPC: Cp, Cpk, Pp, Ppk, control chart UCL/LCL, istogramma con curva normale — puro stdlib (no numpy)
|
||||
- Annotazioni grafiche su disegni tecnici (Fabric.js) con viewer sincronizzato all'esecuzione
|
||||
- Identità per-stazione (`STATION_CODE`): ogni tablet vede solo le ricette assegnate alla propria stazione, gestione assegnazioni via GUI admin
|
||||
- Interfaccia completamente localizzata IT/EN, dark mode, ottimizzata per tablet touch
|
||||
- Autenticazione API Key, rate limiting sliding window, audit log persistente
|
||||
- Autenticazione API Key, rate limiting sliding window per-IP reale (X-Forwarded-For-aware), audit log persistente
|
||||
- Capacità testata per ~20 tablet contemporanei (gunicorn 5 workers × 4 thread + uvicorn 4 workers async)
|
||||
|
||||
---
|
||||
|
||||
## Novità di V3.0.0
|
||||
|
||||
V3.0.0 lavora sui quindici punti raccolti in `TieMeasureFlow_modifiche_2026-07-28.md`,
|
||||
dal sopralluogo del 28/07. Quanto segue è **fatto e in esercizio**.
|
||||
|
||||
| # | Punto | Cosa cambia |
|
||||
|---|---|---|
|
||||
| 1 · 6 | **Produzione con una vita propria** | `production_runs` e `production_events` sul server: avvio, fermo linea, ripresa e fine produzione esistono come stato, non come pulsanti. La fine produzione emette il file di statistica dell'intera produzione |
|
||||
| 2 | **Tipo di task dichiarato** | `task_type` (`note`, `measure`, `drawing`, `xf_compare`, `camera_measure`) al posto di «ha subtask, quindi è una misura»: un task di misura senza quote non si comporta più come una nota |
|
||||
| 3 | **Ciclo di misura** | L'intervallo della ricetta vive sul server, sopravvive al cambio pagina, conta anche il ritardo (in rosso, oltre lo zero) e riporta l'operatore alla misura. Cicalino via WebAudio |
|
||||
| 5 | **Fuori tolleranza vincolante** | Una quota fuori tolleranza blocca l'avanzamento finché il capoturno non autorizza — o finché quella stessa quota non viene rimisurata dentro i limiti. L'autorizzazione resta scritta sulla misura e nel CSV |
|
||||
| 7 | **Stazioni** | Ricette per stazione, cambio stazione da URL per il collaudo, reset per riga |
|
||||
| 8 | **Tracciabilità obbligatoria** | `requires_lot` / `requires_serial` per ricetta, verificati sul server: nessuna porta d'ingresso li aggira, barcode compreso |
|
||||
| 9 | **Blocco inserimento manuale** | `allow_manual_input`: con la ricetta a solo calibro il tastierino non viene disegnato affatto, e il server rifiuta comunque un valore digitato a mano |
|
||||
| 10 | **Sequenza per l'operatore** | La ricetta si apre sul primo task, non su un elenco. La lista scende a secondo livello e dice quali task sono rimasti incompiuti (`2/3`). «Fine ciclo misura» è visibile da subito e spento finché mancano quote |
|
||||
| 11 | **Descrizioni formattate** | `**grassetto**` e a capo nelle descrizioni dei task, con marcatura e non HTML: la sanificazione è per costruzione |
|
||||
| 12 | **Funzionamento senza internet** | Tutte le librerie e i font nell'installazione, versioni congelate e verificate per impronta, Content-Security-Policy a sola origine locale |
|
||||
| 14 | **Layout stabile** | Una cornice sola per tutte le viste, allineata alla navbar; spazio della barra di scorrimento sempre riservato; schermata di misura su `100dvh`. Vedi [`docs/architecture/LAYOUT.md`](docs/architecture/LAYOUT.md) |
|
||||
| 15 | **Statistica separata** | Si registra sempre, si consulta a parte: nessun percorso dell'operatore porta alla statistica (verificato da test) |
|
||||
|
||||
**Aperto**: il punto 4 (numero di tentativi prima del capoturno) attende le risposte
|
||||
del cliente su modalità di autorizzazione e numero di tentativi; il punto 13
|
||||
(generazione task dalla scheda tecnica con l'AI) è fuori offerta.
|
||||
|
||||
---
|
||||
|
||||
@@ -26,25 +59,25 @@ Browser/Tablet
|
||||
|
|
||||
Reverse Proxy (Nginx — sviluppo | Traefik+SSL — produzione)
|
||||
|
|
||||
Flask Client :5000 (rendering server-side, Jinja2 + Alpine.js)
|
||||
| X-API-Key
|
||||
FastAPI Server :8000 (API REST asincrona)
|
||||
Flask Frontend :5000 (rendering server-side, Jinja2 + Alpine.js)
|
||||
| X-API-Key + X-Forwarded-For
|
||||
FastAPI Backend :8000 (API REST asincrona)
|
||||
|
|
||||
MySQL 8.0
|
||||
```
|
||||
|
||||
Il client Flask non espone mai le credenziali al browser: ogni chiamata al backend avviene server-side con l'header `X-API-Key` estratto dalla sessione Flask.
|
||||
Il frontend Flask non espone mai le credenziali al browser: ogni chiamata al backend avviene server-side con l'header `X-API-Key` estratto dalla sessione Flask. L'IP reale del tablet è propagato in `X-Forwarded-For` per il rate limiter.
|
||||
|
||||
---
|
||||
|
||||
## Stack Tecnologico
|
||||
|
||||
### Backend (server/)
|
||||
### Backend (`src/backend/`)
|
||||
|
||||
| Componente | Versione | Ruolo |
|
||||
|---|---|---|
|
||||
| FastAPI | ultima stabile | Framework API REST asincrono |
|
||||
| SQLAlchemy 2.0 | async | ORM con pool connessioni |
|
||||
| SQLAlchemy 2.0 | async | ORM con pool connessioni 10+20 |
|
||||
| asyncmy | ultima stabile | Driver MySQL asincrono |
|
||||
| MySQL | 8.0 | Database relazionale |
|
||||
| Alembic | ultima stabile | Migrazioni schema |
|
||||
@@ -54,35 +87,65 @@ Il client Flask non espone mai le credenziali al browser: ogni chiamata al backe
|
||||
| bcrypt | ultima stabile | Hashing password |
|
||||
| Pillow | ultima stabile | Thumbnail automatici upload |
|
||||
|
||||
### Frontend (client/)
|
||||
### Frontend (`src/frontend/flask_app/`)
|
||||
|
||||
| Componente | Versione | Ruolo |
|
||||
|---|---|---|
|
||||
| Flask | 3.x | Framework web server-side |
|
||||
| gunicorn | 21+ | WSGI server (5 workers × 4 thread gthread) |
|
||||
| Jinja2 | incluso in Flask | Template engine |
|
||||
| Alpine.js | 3.x (CDN) | Reattivita leggera lato client |
|
||||
| TailwindCSS | 3.x | CSS utility-first |
|
||||
| Plotly.js | CDN | Grafici SPC interattivi |
|
||||
| Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici |
|
||||
| Alpine.js | 3.15.12 (locale) | Reattività leggera lato client |
|
||||
| TailwindCSS | 3.4.19 (build) | CSS utility-first, compilato nell'immagine |
|
||||
| Plotly.js | 2.32.0 (locale) | Grafici SPC interattivi |
|
||||
| PDF.js | 3.11.174 (locale) | Visualizzazione disegni PDF, worker incluso |
|
||||
| Fabric.js | 5.3.1 (locale) | Editor annotazioni disegni tecnici |
|
||||
| Inter + JetBrains Mono | woff2 locali | Font UI e numeri |
|
||||
| Flask-Babel | ultima stabile | i18n IT/EN |
|
||||
|
||||
**Nessuna libreria arriva dalla rete.** Tutte stanno in
|
||||
[`src/frontend/flask_app/static/vendor/`](src/frontend/flask_app/static/vendor/VERSIONS.md)
|
||||
con versione nel nome e impronta SHA-256 verificata da un test: l'installazione a
|
||||
Tràfilo è su rete di produzione isolata, dove una pagina che aspetta un CDN è una
|
||||
pagina bianca. Una Content-Security-Policy a sola origine locale (`app.py`) fa sì che
|
||||
un tag verso l'esterno aggiunto in futuro venga rifiutato alla scrivania, non in
|
||||
reparto.
|
||||
|
||||
### Tooling
|
||||
|
||||
| Componente | Ruolo |
|
||||
|---|---|
|
||||
| **uv** | Package manager Python (no `requirements.txt`) |
|
||||
| `pyproject.toml` | Dipendenze monorepo con extra `server`/`client`/`dev` |
|
||||
| `uv.lock` | Lockfile per build riproducibili |
|
||||
| `.python-version` | Pin Python 3.11 |
|
||||
| pytest + pytest-asyncio + httpx + aiosqlite | Test stack |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start con Docker
|
||||
|
||||
Docker Compose è il metodo raccomandato. Gestisce database, migrazioni e configurazione Nginx in un solo comando.
|
||||
Docker Compose è il metodo raccomandato. Gestisce database, migrazioni, build delle immagini con `uv` e configurazione Nginx in un solo comando.
|
||||
|
||||
```bash
|
||||
# 1. Clona il repository
|
||||
git clone <repository-url>
|
||||
# 1. Clona il repository CON il sottomodulo di VisionSuite (vendor/visionsuite):
|
||||
# senza --recurse-submodules la cartella resta vuota e la build del worker di
|
||||
# visione fallisce. Il sottomodulo è su ssh://git.tielogic.xyz:222: serve una
|
||||
# chiave SSH autorizzata su quel repo, sia sulla macchina di sviluppo sia sulla
|
||||
# VPS che farà la build.
|
||||
git clone --recurse-submodules ssh://git@git.tielogic.xyz:222/Adriano/TieMeasureFlow.git
|
||||
cd TieMeasureFlow
|
||||
# Chi ha già clonato senza il sottomodulo:
|
||||
# git submodule update --init
|
||||
|
||||
# 2. Configura le variabili d'ambiente
|
||||
cp .env.example .env
|
||||
# Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD
|
||||
# Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD, STATION_CODE per ogni tablet
|
||||
|
||||
# 3. Avvia i servizi (ambiente di sviluppo)
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
# 3. Valorizza VISION_ENGINE_VERSION PRIMA della build: il worker di visione
|
||||
# (Dockerfile.vision) rifiuta di costruirsi senza — vedi "Il worker di
|
||||
# visione" più sotto per il motivo. Poi avvia i servizi (ambiente di sviluppo).
|
||||
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
# 4. Verifica lo stato dei container
|
||||
docker compose -f docker-compose.dev.yml ps
|
||||
@@ -90,6 +153,7 @@ docker compose -f docker-compose.dev.yml ps
|
||||
# 5. Setup iniziale (solo al primo avvio)
|
||||
# Apri http://localhost/api/setup nel browser
|
||||
# Usa SETUP_PASSWORD configurata in .env
|
||||
# Lo script seed crea anche la stazione ST-DEFAULT con tutte le ricette assegnate
|
||||
```
|
||||
|
||||
L'applicazione sarà disponibile su:
|
||||
@@ -97,8 +161,9 @@ L'applicazione sarà disponibile su:
|
||||
- Frontend: http://localhost
|
||||
- API: http://localhost/api
|
||||
- Pagina setup: http://localhost/api/setup
|
||||
- Admin stazioni: http://localhost/admin/stations (solo `is_admin`)
|
||||
|
||||
Per il deployment in produzione (Traefik + SSL) consulta [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
||||
Per il deployment in produzione (Traefik + SSL) consulta [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -108,12 +173,14 @@ Dopo il primo avvio, la pagina `/api/setup` (protetta da `SETUP_PASSWORD`) perme
|
||||
|
||||
- **Initialize Database** — crea tutte le tabelle
|
||||
- **Create Admin User** — crea l'utente amministratore con credenziali da `.env`
|
||||
- **Seed Demo Data** — carica ricette, misurazioni e utenti di esempio
|
||||
- **Seed Demo Data** — carica ricette, misurazioni e utenti di esempio + crea stazione `ST-DEFAULT` con tutte le ricette assegnate
|
||||
- **Reset Database** — elimina e ricrea tutte le tabelle (attenzione: cancella tutti i dati)
|
||||
- **Gestione utenti** — crea, modifica, attiva/disattiva account dalla stessa pagina
|
||||
|
||||
Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
|
||||
|
||||
Per gestire stazioni e assegnazioni ricette dopo il setup: `/admin/stations` (richiede login admin).
|
||||
|
||||
---
|
||||
|
||||
## Setup Manuale (Senza Docker)
|
||||
@@ -121,8 +188,9 @@ Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
|
||||
### Requisiti
|
||||
|
||||
- Python 3.11 o superiore
|
||||
- Node.js 18 o superiore
|
||||
- Node.js 18 o superiore (per TailwindCSS)
|
||||
- MySQL 8.0
|
||||
- [uv](https://docs.astral.sh/uv/) installato
|
||||
|
||||
### 1. Database MySQL
|
||||
|
||||
@@ -139,35 +207,37 @@ SQL
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Imposta DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, SERVER_SECRET_KEY, CLIENT_SECRET_KEY, SETUP_PASSWORD
|
||||
# Imposta DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, SERVER_SECRET_KEY,
|
||||
# CLIENT_SECRET_KEY, SETUP_PASSWORD, STATION_CODE
|
||||
```
|
||||
|
||||
### 3. Server FastAPI
|
||||
### 3. Installa dipendenze (uv)
|
||||
|
||||
```bash
|
||||
cd server
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
alembic -c migrations/alembic.ini upgrade head
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
uv sync --extra server --extra client --extra dev
|
||||
```
|
||||
|
||||
### 4. Client Flask
|
||||
### 4. Backend FastAPI
|
||||
|
||||
```bash
|
||||
cd client
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
pybabel compile -d translations
|
||||
flask run --host 0.0.0.0 --port 5000
|
||||
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### 5. TailwindCSS (watch per sviluppo)
|
||||
### 5. Frontend Flask
|
||||
|
||||
```bash
|
||||
cd client
|
||||
# Compila i cataloghi i18n una volta
|
||||
cd src/frontend/flask_app && uv run --project ../../.. pybabel compile -d translations && cd -
|
||||
|
||||
# Avvia (development)
|
||||
cd src/frontend/flask_app && uv run --project ../../.. flask run --host 0.0.0.0 --port 5000
|
||||
```
|
||||
|
||||
### 6. TailwindCSS (watch per sviluppo)
|
||||
|
||||
```bash
|
||||
cd src/frontend/flask_app
|
||||
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
||||
```
|
||||
|
||||
@@ -185,9 +255,10 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
|
||||
| Ruolo | Descrizione |
|
||||
|---|---|
|
||||
| **Maker** | Crea e gestisce ricette di misurazione: caricamento disegni (PDF/immagini), annotazioni Fabric.js, definizione task/subtask, configurazione tolleranze, versioning copy-on-write |
|
||||
| **MeasurementTec** | Esegue misurazioni: scansione barcode per selezione ricetta, interfaccia task-driven, input da calibro USB HID o numpad touch, validazione real-time pass/warning/fail |
|
||||
| **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart |
|
||||
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account |
|
||||
| **MeasurementTec** | Esegue misurazioni: scansione barcode per selezione ricetta, interfaccia task-driven, input da calibro USB HID o numpad touch, validazione real-time pass/warning/fail. Vede solo le ricette assegnate alla propria stazione (`STATION_CODE`) |
|
||||
| **Supervisor** (capoturno) | Autorizza ciò che l'operatore non può decidere da solo: una quota fuori tolleranza che deve restare, il fermo linea, la fine produzione. L'autorizzazione resta scritta sulla misura (`supervisor_id`, `authorised_at`) e finisce nel file di statistica |
|
||||
| **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart. **L'operatore non raggiunge la statistica da nessun percorso** |
|
||||
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account, **CRUD stazioni e assegnazioni ricette** |
|
||||
|
||||
---
|
||||
|
||||
@@ -195,39 +266,61 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
|
||||
|
||||
```
|
||||
TieMeasureFlow/
|
||||
├── server/ # FastAPI Backend
|
||||
│ ├── main.py # Entry point, lifespan, middleware, 10 router
|
||||
│ ├── config.py # Settings (pydantic_settings.BaseSettings)
|
||||
│ ├── database.py # SQLAlchemy 2.0 async engine
|
||||
│ ├── models/ # ORM: User, Recipe, RecipeVersion, RecipeTask,
|
||||
│ │ # RecipeSubtask, Measurement, AccessLog,
|
||||
│ │ # SystemSetting, RecipeVersionAudit
|
||||
│ ├── schemas/ # Pydantic v2 per validazione I/O API
|
||||
│ ├── routers/ # auth, users, recipes, tasks, measurements,
|
||||
│ │ # files, settings, statistics, reports, setup
|
||||
│ ├── services/ # recipe_service, measurement_service,
|
||||
│ │ # spc_service, report_service, auth_service
|
||||
│ ├── middleware/ # api_key, rate_limit, security_headers, logging
|
||||
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
||||
│ └── templates/ # Template HTML pagina setup
|
||||
├── client/ # Flask Frontend
|
||||
│ ├── app.py # Factory pattern, CSRF, Babel
|
||||
│ ├── blueprints/ # auth, maker, measure, statistics, admin
|
||||
│ ├── services/ # APIClient singleton (proxy verso FastAPI)
|
||||
│ ├── templates/ # Jinja2 + Alpine.js
|
||||
│ ├── static/
|
||||
│ │ ├── css/ # TailwindCSS compilato
|
||||
│ │ └── js/ # numpad, caliper, barcode, csv-export,
|
||||
│ │ # spc-charts, annotation-editor/viewer
|
||||
│ └── translations/ # Flask-Babel .po/.mo IT/EN
|
||||
├── nginx/ # Configurazione Nginx (dev)
|
||||
├── docs/ # Documentazione tecnica
|
||||
│ ├── API.md # Riferimento API REST
|
||||
│ ├── DEPLOYMENT.md # Guida deployment VPS (Traefik, SSL, DNS)
|
||||
│ └── USER_GUIDE.md # Manuale utente per ruolo
|
||||
├── docker-compose.yml # Produzione (Traefik, SSL)
|
||||
├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80)
|
||||
└── .env.example # Template variabili d'ambiente
|
||||
├── pyproject.toml # Dipendenze monorepo (uv)
|
||||
├── uv.lock # Lockfile riproducibile
|
||||
├── .python-version # 3.11
|
||||
├── Dockerfile # Backend (uv + uvicorn)
|
||||
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
|
||||
├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80)
|
||||
├── docker-compose.yml # Produzione (Traefik, SSL)
|
||||
├── nginx/ # Config Nginx (dev)
|
||||
├── uploads/ # Volume Docker file caricati
|
||||
├── scripts/ # Script del progetto (seed ricette di collaudo)
|
||||
├── docs/ # Documentazione (vedi indice docs/README.md)
|
||||
└── src/
|
||||
├── backend/ # FastAPI Backend
|
||||
│ ├── main.py # Entry point, lifespan, middleware, 11 router
|
||||
│ ├── config.py # Settings (pydantic_settings.BaseSettings)
|
||||
│ ├── database.py # SQLAlchemy 2.0 async engine
|
||||
│ ├── api/
|
||||
│ │ ├── routers/ # auth, users, recipes, tasks, measurements,
|
||||
│ │ │ # files, settings, statistics, reports,
|
||||
│ │ │ # setup, stations
|
||||
│ │ └── middleware/ # api_key, rate_limit, security_headers, logging
|
||||
│ ├── models/
|
||||
│ │ ├── orm/ # SQLAlchemy: User, Recipe, RecipeVersion,
|
||||
│ │ │ # RecipeTask, RecipeSubtask, Measurement,
|
||||
│ │ │ # AccessLog, SystemSetting,
|
||||
│ │ │ # RecipeVersionAudit, Station,
|
||||
│ │ │ # StationRecipeAssignment,
|
||||
│ │ │ # ProductionRun, ProductionEvent
|
||||
│ │ └── api/ # Pydantic v2 schemas request/response
|
||||
│ ├── services/ # recipe_service, measurement_service,
|
||||
│ │ # spc_service, report_service,
|
||||
│ │ # auth_service, station_service,
|
||||
│ │ # production_service,
|
||||
│ │ # production_export_service
|
||||
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
||||
│ ├── templates/ # Pagina setup (Jinja2)
|
||||
│ └── tests/ # pytest + httpx + aiosqlite
|
||||
└── frontend/
|
||||
└── flask_app/ # Flask Frontend
|
||||
├── app.py # Factory + ProxyFix + CSRF + Babel
|
||||
├── config.py # STATION_CODE, API_SERVER_URL, ecc.
|
||||
├── compile_translations.py
|
||||
├── blueprints/ # auth, maker, measure, statistics, admin
|
||||
├── services/ # APIClient (proxy verso FastAPI con XFF)
|
||||
├── templates/ # Jinja2 + Alpine.js
|
||||
├── static/
|
||||
│ ├── css/ # TailwindCSS compilato + themes.css
|
||||
│ │ # (cornice pagina, temi, scrollbar)
|
||||
│ ├── js/ # numpad, caliper, barcode, csv-export,
|
||||
│ │ # spc-charts, annotation-editor/viewer,
|
||||
│ │ # production-clock, rich-text
|
||||
│ └── vendor/ # Alpine, Plotly, PDF.js (+worker), Fabric,
|
||||
│ # font woff2 — vedi VERSIONS.md
|
||||
├── translations/ # Flask-Babel .po/.mo IT/EN
|
||||
└── tests/
|
||||
```
|
||||
|
||||
---
|
||||
@@ -238,8 +331,9 @@ TieMeasureFlow/
|
||||
|
||||
| Comando | Descrizione |
|
||||
|---|---|
|
||||
| `docker compose -f docker-compose.dev.yml up -d` | Avvia servizi in sviluppo |
|
||||
| `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 |
|
||||
@@ -248,47 +342,153 @@ TieMeasureFlow/
|
||||
|
||||
### Alembic (migrations)
|
||||
|
||||
Nota: `alembic.ini` si trova dentro `server/migrations/`, è richiesto il flag `-c`.
|
||||
`alembic.ini` si trova in `src/backend/migrations/`, è richiesto il flag `-c`. `env.py` aggiunge la project root a `sys.path` per risolvere `src.backend.*`.
|
||||
|
||||
```bash
|
||||
cd server
|
||||
alembic -c migrations/alembic.ini upgrade head # Applica migrazioni
|
||||
alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione
|
||||
alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima
|
||||
uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Applica migrazioni
|
||||
uv run alembic -c src/backend/migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera
|
||||
uv run alembic -c src/backend/migrations/alembic.ini downgrade -1 # Rollback ultima
|
||||
```
|
||||
|
||||
Via Docker:
|
||||
|
||||
```bash
|
||||
docker compose exec server alembic -c migrations/alembic.ini upgrade head
|
||||
docker compose exec server uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||
```
|
||||
|
||||
### i18n (Traduzioni)
|
||||
|
||||
```bash
|
||||
cd client
|
||||
pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai stringhe
|
||||
pybabel update -i translations/messages.pot -d translations # Aggiorna catalogo
|
||||
pybabel compile -d translations # Compila .po → .mo
|
||||
cd src/frontend/flask_app
|
||||
uv run --project ../../.. pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai
|
||||
uv run --project ../../.. pybabel update -i translations/messages.pot -d translations # Aggiorna
|
||||
uv run --project ../../.. pybabel compile -d translations # Compila .po → .mo
|
||||
```
|
||||
|
||||
### Gestione dipendenze (uv)
|
||||
|
||||
```bash
|
||||
uv add <pacchetto> # core (entrambi)
|
||||
uv add --optional server <pacchetto> # solo backend
|
||||
uv add --optional client <pacchetto> # solo frontend
|
||||
uv add --optional dev <pacchetto> # solo dev/test
|
||||
uv sync --extra server --extra client --extra dev # reinstalla
|
||||
uv lock # rigenera uv.lock
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Il server usa SQLite in-memory tramite `aiosqlite`: i test girano senza MySQL installato.
|
||||
Il backend usa SQLite in-memory tramite `aiosqlite`: i test girano senza MySQL installato.
|
||||
|
||||
```bash
|
||||
# Server
|
||||
cd server && pytest # Tutti i test
|
||||
cd server && pytest tests/test_auth.py # Singolo modulo
|
||||
cd server && pytest tests/test_auth.py::test_login_success # Singolo test
|
||||
cd server && pytest --cov # Con copertura
|
||||
# Tutti i test (backend + frontend)
|
||||
uv run pytest
|
||||
|
||||
# Client
|
||||
cd client && pytest
|
||||
# Solo backend
|
||||
uv run pytest src/backend/tests/
|
||||
uv run pytest src/backend/tests/test_auth.py
|
||||
uv run pytest src/backend/tests/test_auth.py::test_login_success
|
||||
uv run pytest --cov src/backend
|
||||
|
||||
# Solo frontend
|
||||
uv run pytest src/frontend/flask_app/tests/
|
||||
```
|
||||
|
||||
Il modulo di visione (`src/vision/`) è un albero a parte, con un vincolo di
|
||||
versione diverso dal resto del monorepo: le librerie di VisionSuite da cui
|
||||
dipende richiedono Python 3.13 o superiore, mentre backend e frontend restano
|
||||
su Python 3.11. Per questo motivo l'esecuzione di `uv run pytest` con
|
||||
l'interprete di default mostra i test di `src/vision/tests/` come *skipped*,
|
||||
non come falliti: il pacchetto viene rilevato ma la sua esecuzione reale
|
||||
richiede un ambiente Python 3.13 dedicato, creato con il proprio extra `uv`.
|
||||
Per eseguirli davvero:
|
||||
|
||||
```bash
|
||||
# Prepara l'ambiente Python 3.13 con le dipendenze di visione
|
||||
uv sync --extra vision --extra dev --python 3.13
|
||||
|
||||
# Esegue i test del runner di visione in quell'ambiente
|
||||
uv run --python 3.13 --extra vision --extra dev pytest src/vision/tests
|
||||
```
|
||||
|
||||
`src/vision_worker/tests/` eredita lo stesso vincolo su Python 3.13, perché
|
||||
il worker dipende da `src.vision.runner`, ma aggiunge dipendenze proprie -
|
||||
FastAPI, Uvicorn, Pillow, python-multipart - dichiarate in un extra
|
||||
separato, `vision-worker`, tenuto distinto da `vision` apposta: un
|
||||
consumatore che incorpora solo il runner (come un futuro agente di stazione,
|
||||
che non è un servizio web) non deve trascinarsi dietro un server che non gli
|
||||
serve. `vision-worker` include comunque `vision`, quindi un solo extra basta
|
||||
per avere un worker funzionante:
|
||||
|
||||
```bash
|
||||
uv run --python 3.13 --extra vision-worker --extra dev pytest src/vision_worker/tests
|
||||
```
|
||||
|
||||
Per eseguire entrambe le suite insieme, nominando esplicitamente entrambi
|
||||
gli extra:
|
||||
|
||||
```bash
|
||||
uv run --python 3.13 --extra vision --extra vision-worker --extra dev pytest src/vision/tests src/vision_worker/tests
|
||||
```
|
||||
|
||||
### Il worker di visione (`Dockerfile.vision`)
|
||||
|
||||
Il worker gira in un container separato dal server FastAPI principale, così
|
||||
che l'immagine dell'API resti leggera e un aggiornamento di VisionSuite non
|
||||
richieda di riavviare il traffico di produzione. Il container non pubblica
|
||||
porte verso l'esterno: lo raggiunge solo il server, all'indirizzo interno
|
||||
`http://vision:8100` sulla rete `tmflow-net`.
|
||||
|
||||
Ogni misura riporta il commit di VisionSuite che l'ha prodotta
|
||||
(`engine_version`), perché una stazione che misura con un motore diverso da
|
||||
quello atteso deve poter essere identificata. Il container, però, non ha
|
||||
accesso al repository Git del progetto principale — `vendor/visionsuite` è un
|
||||
submodule, e la copia `.git` che lo collega al repository ospitante non
|
||||
viene inclusa nel contesto di build — quindi la versione non può essere
|
||||
scoperta al volo dentro l'immagine. Va invece **stampata al momento del
|
||||
build**, leggendo il commit dalla macchina che esegue `docker compose build`,
|
||||
dove il repository Git è presente per intero:
|
||||
|
||||
```bash
|
||||
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||
docker compose -f docker-compose.dev.yml build vision
|
||||
```
|
||||
|
||||
La variabile viene passata come build argument (`ARG VISION_ENGINE_VERSION`
|
||||
in `Dockerfile.vision`) e fissata nell'immagine come variabile d'ambiente, in
|
||||
modo che il worker la trovi già pronta a ogni avvio senza doverla ricalcolare.
|
||||
Fuori da un container, in un checkout di sviluppo locale, la stessa funzione
|
||||
ricade su `git rev-parse` se la variabile non è impostata — comodo per
|
||||
lavorare sul runner senza Docker. Ma se un'immagine viene costruita senza
|
||||
passare `VISION_ENGINE_VERSION`, quel ripiego non ha nulla su cui appoggiarsi:
|
||||
il `.git` del submodule non arriva nel contesto di build, e il worker
|
||||
risponde con un errore esplicito su `/health` invece di indovinare una
|
||||
versione o restituire `"unknown"`. La build va quindi sempre lanciata con la
|
||||
variabile impostata, sia in sviluppo sia in produzione, con lo stesso comando
|
||||
mostrato sopra (sostituendo `docker-compose.dev.yml` con `docker-compose.yml`
|
||||
in produzione).
|
||||
|
||||
Stato corrente su `V3.0.0`, con `uv run --extra server --extra client --extra
|
||||
dev pytest` su Python 3.11 e senza l'extra `vision` (un conteggio senza il
|
||||
suo ambiente non dice nulla, regola che vale anche qui): **622 pass, 4
|
||||
skip, nessun fallimento** — 410 in `src/backend/tests/`, 212 pass + 2 skip
|
||||
in `src/frontend/flask_app/tests/` (geometria propria di
|
||||
`test_layout_shell.py`, non legati alla visione), più 2 skip in
|
||||
`src/vision/tests/` e `src/vision_worker/tests/` per il vincolo su Python
|
||||
3.13 descritto sopra. Eseguendo questi ultimi nel loro ambiente dedicato
|
||||
(comando sotto) i test collegati alla visione passano tutti: **16 pass**.
|
||||
|
||||
Alcuni test frontend non renderizzano niente e leggono i sorgenti, perché guardano
|
||||
proprietà che sopravvivono solo se qualcuno le controlla:
|
||||
|
||||
| File | Cosa impedisce |
|
||||
|---|---|
|
||||
| `test_offline.py` | Una libreria caricata dalla rete, un worker PDF.js lasciato sul CDN, una libreria sostituita senza aggiornare l'impronta |
|
||||
| `test_layout_shell.py` | Una vista che torna a dichiararsi la propria larghezza |
|
||||
| `test_template_js_syntax.py` | Una traduzione con l'apostrofo dentro una stringa JS a virgolette singole, che spegne Alpine su tutta la pagina |
|
||||
|
||||
---
|
||||
|
||||
## Variabili d'Ambiente
|
||||
@@ -304,26 +504,116 @@ Copia `.env.example` in `.env` e configura:
|
||||
| `SERVER_CORS_ORIGINS` | Origini CORS ammesse |
|
||||
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
||||
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
||||
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, relativo a `server/`) |
|
||||
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload |
|
||||
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
|
||||
| `VISION_WORKER_URL` | Indirizzo interno del worker di visione (default: `http://vision:8100`, mai esposto fuori da `tmflow-net`) |
|
||||
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
|
||||
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
|
||||
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
|
||||
| `RATE_LIMIT_GENERAL` | Richieste req/min/IP (default 300, per-tablet) |
|
||||
| `NGINX_PORT`, `NGINX_SSL_PORT` | Porte Nginx (solo compose dev) |
|
||||
| `SETUP_PASSWORD` | Password pagina setup (vuota = endpoint disabilitato) |
|
||||
| `SSL_CERTFILE`, `SSL_KEYFILE` | Certificato SSL (solo setup manuale) |
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
|
||||
---
|
||||
|
||||
## Pacchetto Windows
|
||||
|
||||
Per i PC senza Docker (per esempio il PC della stazione in fiera) TieMeasureFlow si distribuisce come programma di installazione per Windows, `TieMeasureFlow-setup-<versione>.exe`. Funziona completamente offline, non richiede Docker né MySQL, e non contiene codice sorgente Python: il codice del prodotto è compilato con Nuitka. Il programma parte solo con una licenza valida, legata al computer.
|
||||
|
||||
### Installazione
|
||||
|
||||
1. Eseguire `TieMeasureFlow-setup-<versione>.exe` (serve un account amministratore; per una prova senza amministratore si può lanciare il setup con `/CURRENTUSER`). Per un'installazione silenziosa: `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART`.
|
||||
2. Dal menu Start aprire «Impronta di questo computer» e comunicare l'impronta a Tielogic. L'impronta viene salvata anche in `C:\ProgramData\Tielogic\licenze\impronta-tiemeasureflow.txt`.
|
||||
3. Copiare il file di licenza ricevuto in `C:\ProgramData\Tielogic\licenze\tiemeasureflow.json`. La licenza non è inclusa nel setup e non va mai inserita nel repository.
|
||||
4. Avviare «TieMeasureFlow» dal menu Start o dal desktop. Senza una licenza valida (mancante, scaduta, di un altro computer o di un altro prodotto) il programma mostra il motivo e l'impronta ed esce.
|
||||
|
||||
### Primo avvio
|
||||
|
||||
Il programma gira in un solo processo, senza finestra, e apre il browser. Ascolta solo sul computer locale (`127.0.0.1`); per raggiungere l'interfaccia da altri dispositivi della rete si avvia l'exe con `--host 0.0.0.0` (per esempio da un collegamento personalizzato).
|
||||
|
||||
| Componente | Indirizzo |
|
||||
|---|---|
|
||||
| Interfaccia web (Flask) | http://127.0.0.1:8780/ |
|
||||
| Backend API (FastAPI) | http://127.0.0.1:8000/ |
|
||||
| Worker di visione | http://127.0.0.1:8100/ |
|
||||
| Camera della stazione | http://127.0.0.1:8765/ (`/camera/sources`) |
|
||||
|
||||
Al primo avvio vengono create la cartella dati `C:\ProgramData\Tielogic\TieMeasureFlow` e il file `config.env` con le chiavi segrete generate (`SERVER_SECRET_KEY`, `CLIENT_SECRET_KEY`, `SETUP_PASSWORD`, `DB_PASSWORD`); al riavvio vengono rilette, quindi le sessioni e le password restano valide. Il database è un file SQLite (`tmf.db`); le migrazioni si applicano da sole a ogni avvio.
|
||||
|
||||
Se il database non contiene ancora utenti, il browser si apre direttamente sulla pagina di setup `http://127.0.0.1:8000/api/setup`. La pagina chiede la `SETUP_PASSWORD`, che si legge in `config.env`, e permette di inizializzare il database e creare il primo amministratore (vedi «Setup Iniziale»). Terminato il setup si accede all'interfaccia su `http://127.0.0.1:8780/`.
|
||||
|
||||
Per le camere della stazione si compila nella schermata Stazioni `camera_url` = `http://127.0.0.1:8765` e `camera_source` (`camera:<seriale>` oppure `camera:folder`). Con `camera:folder` il servizio restituisce a rotazione le immagini che si copiano in `C:\ProgramData\Tielogic\TieMeasureFlow\camera`. Per le camere GigE/GenTL il driver (producer `.cti`) è quello installato dal produttore della camera.
|
||||
|
||||
Altre informazioni utili:
|
||||
|
||||
- I log sono in `C:\ProgramData\Tielogic\TieMeasureFlow\log\tiemeasureflow.log`.
|
||||
- Una seconda esecuzione mentre il programma è già attivo apre soltanto il browser.
|
||||
- Per fermare il programma si usa «Arresta TieMeasureFlow» nel menu Start (chiude il processo).
|
||||
- La disinstallazione non cancella licenze, database, file caricati e `config.env`: sono dati del cliente.
|
||||
- I report PDF usano le librerie grafiche incluse nel pacchetto e un browser Chrome o Edge già installato sul computer per i grafici.
|
||||
|
||||
### Come si costruisce
|
||||
|
||||
Il setup lo produce la CI Gitea (`.gitea/workflows/rilascio-windows.yml`) quando si crea un tag `v*`, su un runner con etichetta `windows`; il file compare come allegato della release. In locale, dalla radice del repository con il sottomodulo `vendor/visionsuite` inizializzato:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 -Versione 3.0.0
|
||||
```
|
||||
|
||||
Servono `uv`, Node.js, Visual Studio con il carico C++ e Inno Setup 6. Il risultato è `dist\TieMeasureFlow-setup-3.0.0.exe`. La prima compilazione richiede decine di minuti. I test dell'avvio si lanciano con l'ambiente Python 3.13 del pacchetto: `uv run --python 3.13 --extra server --extra client --extra vision-worker --group windows --with pytest pytest packaging/windows/tests -q`.
|
||||
|
||||
---
|
||||
|
||||
## Documentazione
|
||||
|
||||
Indice completo: [`docs/README.md`](docs/README.md).
|
||||
|
||||
### Stato e direzione
|
||||
|
||||
| Documento | Contenuto |
|
||||
|---|---|
|
||||
| [docs/API.md](docs/API.md) | Riferimento completo API REST (endpoint, parametri, schemi) |
|
||||
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
|
||||
| [docs/USER_GUIDE.md](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
|
||||
| [`TieMeasureFlow_modifiche_2026-07-28.md`](TieMeasureFlow_modifiche_2026-07-28.md) | I quindici punti del 28/07: cosa deve cambiare, dove intervenire, decisioni in attesa del cliente (D-1…D-9) |
|
||||
| [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) | Snapshot V2.0.0: cosa funziona oggi, test status, decisioni architetturali |
|
||||
| [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md) | Cosa resta da fare (Fasi 2-7 rev04, decisioni cliente aperte, stime) |
|
||||
|
||||
### Riferimenti operativi
|
||||
|
||||
| Documento | Contenuto |
|
||||
|---|---|
|
||||
| [`docs/API.md`](docs/API.md) | Riferimento completo API REST (endpoint, parametri, schemi) |
|
||||
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
|
||||
| [`docs/USER_GUIDE.md`](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
|
||||
| [`docs/I18N_SETUP.md`](docs/I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js) |
|
||||
| [`docs/architecture/LAYOUT.md`](docs/architecture/LAYOUT.md) | La cornice delle pagine: perché il layout si spostava e la regola che lo tiene fermo |
|
||||
| [`src/frontend/flask_app/static/vendor/VERSIONS.md`](src/frontend/flask_app/static/vendor/VERSIONS.md) | Librerie di terze parti in locale: versioni, impronte, come aggiornarle |
|
||||
|
||||
### Piani dettagliati
|
||||
|
||||
| Documento | Contenuto |
|
||||
|---|---|
|
||||
| [`docs/superpowers/plans/2026-04-17-rev04-master-roadmap.md`](docs/superpowers/plans/2026-04-17-rev04-master-roadmap.md) | Master plan rev04 (M1 + M2, decisioni aperte, stime) |
|
||||
| [`docs/superpowers/plans/2026-04-17-rev04-phase1-stations.md`](docs/superpowers/plans/2026-04-17-rev04-phase1-stations.md) | Piano TDD Fase 1 stazioni (completato) |
|
||||
|
||||
---
|
||||
|
||||
## Licenza
|
||||
|
||||
Proprietary - Tielogic. All rights reserved.
|
||||
Proprietary — Tielogic. All rights reserved.
|
||||
|
||||
Questo software è di proprietà esclusiva di Tielogic ed è protetto dalle leggi sul copyright. Non è consentita la distribuzione, modifica o utilizzo senza autorizzazione scritta.
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
# TieMeasureFlow — modifiche da apportare
|
||||
|
||||
**Data:** 28 luglio 2026 (agg. serale: architettura d'installazione, punti 7-8 precisati, punti 14-15 nuovi, timer bidirezionale nel punto 3)
|
||||
**Riferimento codice:** branch `V2.0.0`, commit `2a56632`
|
||||
**Destinatari:** Parte 1 leggibile da cliente e rivenditore · Parte 2 per chi mette mano al codice
|
||||
|
||||
---
|
||||
|
||||
## Come leggere questo documento
|
||||
|
||||
La **Parte 1** dice *cosa deve fare* il sistema e *perché*: si può mandare a Ricerca e
|
||||
Misure e a Tràfilo. La **Parte 2** dice *dove si interviene*, con file e criteri di
|
||||
completamento. La **Parte 3** elenca le domande la cui risposta non dipende da noi.
|
||||
|
||||
Ogni punto porta lo **stato verificato sul codice**, non riferito: `già fatto`,
|
||||
`parziale`, `da fare`. Serve a evitare l'equivoco emerso in questi giorni — diverse
|
||||
funzioni risultano "già implementate" perché **l'interfaccia c'è**, ma dietro non
|
||||
succede nulla.
|
||||
|
||||
**Fonti:** specifica *Schema sviluppo SW TieFlow rev. 04-2026*; *MODIFICHE TIEMEASURE
|
||||
v260519*; call del 28/07/2026; rettifica di Marco Menoncin del 28/07; lettura diretta
|
||||
del codice.
|
||||
|
||||
---
|
||||
|
||||
## Il punto che regge tutti gli altri
|
||||
|
||||
**Lo stato della produzione oggi vive dentro una pagina del browser.**
|
||||
|
||||
Il timer, il conteggio dei cicli, il flag "produzione avviata" e le misure in corso
|
||||
sono variabili del componente Alpine di `task_execute.html`. La navigazione fra un
|
||||
task e l'altro è un **ricaricamento completo di pagina** (`window.location.href`).
|
||||
|
||||
Conseguenza diretta: **cambiando task si perde tutto.** Il timer smette di esistere,
|
||||
il conteggio dei cicli riparte, "produzione avviata" si dimentica.
|
||||
|
||||
Questo spiega, senza bisogno di altre ipotesi:
|
||||
|
||||
- perché il loop di misura non regge quando la ricetta ha altri task dopo la misura;
|
||||
- perché con più task di misura non si sa a quale tornare allo scadere del timer;
|
||||
- perché *fermo linea* e *fine produzione* non hanno nulla da fermare o da chiudere;
|
||||
- perché non esiste storico di cosa è successo durante una produzione.
|
||||
|
||||
**Va risolto per primo**: i punti 2, 3, 6 e 8 dipendono da questa scelta, e affrontarli
|
||||
prima significa rifarli dopo.
|
||||
|
||||
---
|
||||
|
||||
## Architettura d'installazione (decisa il 28/07)
|
||||
|
||||
Chiude la domanda D-3 lato Tielogic. L'installazione a Tràfilo è così composta:
|
||||
|
||||
```
|
||||
SERVER (uno, del cliente) STAZIONE (una per PC, installata in locale)
|
||||
┌─────────────────────────┐ ┌──────────────────────────────────────┐
|
||||
│ MySQL │◄──API──│ App di stazione: │
|
||||
│ Backend dati (API) │ │ · interfaccia operatore (frontend) │
|
||||
│ File dei disegni │ │ · agente hardware: calibro USB, │
|
||||
│ Migrazioni dello schema │ │ driver camera, colonnina/cicalino │
|
||||
│ Adattatore GAIA │ │ · elaborazione visione (futura: │
|
||||
└─────────────────────────┘ │ XF compare, pattern matching) │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Sul server vive tutto ciò che è stato**: il database, i file dei disegni, le
|
||||
migrazioni dello schema e l'unico punto di contatto col gestionale GAIA.
|
||||
Aggiornare la logica dati = un deploy, in un posto solo.
|
||||
- **Sulla stazione vive tutto ciò che tocca l'hardware**: calibro, telecamera,
|
||||
segnalazione luminosa/acustica, e in prospettiva l'elaborazione visione — che
|
||||
così non carica il server (rilevante per il dimensionamento, D-4).
|
||||
L'app di stazione è **senza stato**: non tocca il database direttamente, parla
|
||||
col backend dati via API e gli manda risultati e immagini.
|
||||
- **L'identità della stazione è data dall'installazione** (configurata sul PC),
|
||||
non più dal container sul server: il vincolo "una stazione = un container"
|
||||
decade.
|
||||
- La **licenza per postazione** ha l'aggancio naturale nell'app di stazione
|
||||
installata.
|
||||
|
||||
**Correzione a quanto detto in call:** i disegni **non stanno nel database** —
|
||||
stanno su filesystem (`uploads/`), serviti dal backend. Con questa architettura
|
||||
restano sul server, in un posto solo: nessuna cartella da sincronizzare fra i PC.
|
||||
|
||||
Conseguenza per lo sviluppo: lo stato della produzione (punto 1) va nel database
|
||||
**per obbligo architetturale**, non per scelta — con 20 app di stazione non esiste
|
||||
altro posto dove possa vivere.
|
||||
|
||||
---
|
||||
|
||||
# Parte 1 — Cosa deve fare il sistema
|
||||
|
||||
## Quadro: cosa già funziona
|
||||
|
||||
Da mettere a verbale, perché in parte era dato per mancante:
|
||||
|
||||
| Funzione | Stato |
|
||||
|---|---|
|
||||
| Stazioni e assegnazione ricette alle stazioni | ✅ presente |
|
||||
| Ruolo **capoturno** con autorizzazione a login | ✅ presente |
|
||||
| Timer d'intervallo misura con **conto alla rovescia a video** e cicalino | ✅ presente |
|
||||
| Logout automatico per inattività, configurabile | ✅ presente |
|
||||
| Tolleranze con soglie di attenzione (UTL/UWL/LWL/LTL) ed esito per quota | ✅ presente |
|
||||
| Versionamento delle ricette con storico delle modifiche | ✅ presente |
|
||||
| Registrazione del **tempo di inserimento** di ogni misura | ✅ presente (28/07) |
|
||||
| Pulsanti *Fermo linea*, *Fine produzione*, *Avvio produzione* | ⚠️ solo interfaccia |
|
||||
|
||||
L'ultima riga è la più importante: i tre pulsanti esistono, chiedono correttamente
|
||||
l'autorizzazione del capoturno, **e poi non fanno nulla**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Memoria della produzione in corso `da fare` · prerequisito
|
||||
|
||||
**Oggi:** lo stato di una produzione esiste solo finché l'operatore resta sulla stessa
|
||||
schermata.
|
||||
|
||||
**Deve:** la produzione diventa un'entità con una vita propria — si apre quando parte,
|
||||
registra ciò che accade (avvio, cicli di misura, fermo linea, ripresa, chiusura) e si
|
||||
chiude quando il capoturno la chiude. Se l'operatore cambia task, esce e rientra, o il
|
||||
tablet si riavvia, la produzione è ancora lì con il suo timer.
|
||||
|
||||
**Perché conta:** senza questo, *fermo linea* e *fine produzione* non possono
|
||||
funzionare, il loop di misura non è realizzabile e non esiste il dato storico su cui
|
||||
poggiano sia la statistica sia l'integrazione col gestionale.
|
||||
|
||||
---
|
||||
|
||||
## 2. Tipo di task esplicito `da fare`
|
||||
|
||||
**Oggi:** il sistema distingue un task di misura da un task documentale **deducendolo**:
|
||||
se ha delle quote è una misura, altrimenti è una nota. Non esiste un tipo dichiarato.
|
||||
|
||||
**Deve:** ogni task nasce con un tipo scelto da chi crea la ricetta — **nota**,
|
||||
**misura**, **disegno** — predisposto per i tipi futuri (confronto profilo/DXF, misura
|
||||
con camera).
|
||||
|
||||
**Perché conta:** è la richiesta n. 1 di Menoncin, ed è ciò a cui si agganciano timer e
|
||||
loop. Con la deduzione attuale un task di misura a cui non sono ancora state inserite le
|
||||
quote viene trattato come una nota: il sistema si comporta in modo diverso a seconda di
|
||||
quanto è completa la ricetta.
|
||||
|
||||
---
|
||||
|
||||
## 3. Loop di misura e ripetizione `parziale`
|
||||
|
||||
**Oggi:** allo scadere del timer suona il cicalino e il ciclo si riazzera — ma solo se
|
||||
l'operatore è rimasto su quella schermata.
|
||||
|
||||
**Deve:**
|
||||
- l'operatore esegue i task in sequenza fino al primo task di misura;
|
||||
- da lì **resta in loop sulla misura** finché la produzione non viene chiusa;
|
||||
- il conto alla rovescia di **quanto manca alla prossima misura** è sempre visibile;
|
||||
- **arrivato a zero, il contatore riparte nell'altro senso**: mostra da quanto tempo
|
||||
si è **oltre** l'intervallo di misura, in evidenza — così il ritardo si vede, non
|
||||
si deduce (richiesta dell'operatore, 28/07);
|
||||
- allo scadere del timer la misura **si ripropone** ovunque si trovi l'operatore;
|
||||
- se la ricetta ha **più task di misura**, il timer riparte alla fine dell'**ultimo**;
|
||||
- dev'essere possibile **girare il pezzo e rimisurare** senza chiudere il ciclo.
|
||||
|
||||
**Perché conta:** è la seconda richiesta di Menoncin e il comportamento descritto al
|
||||
punto 4.10 della specifica del 19/05. La logica c'è già in gran parte: manca che
|
||||
sopravviva al cambio di schermata (punto 1).
|
||||
|
||||
---
|
||||
|
||||
## 4. Limite di tentativi prima del capoturno `da fare`
|
||||
|
||||
**Oggi:** l'operatore può ripetere la misura quante volte vuole.
|
||||
|
||||
**Deve:** dopo un numero di tentativi definito nella ricetta, per proseguire serve
|
||||
l'autorizzazione del capoturno.
|
||||
|
||||
**Perché conta:** è nella specifica ed è la contromisura al caso in cui si ripete finché
|
||||
non "viene bene". Il meccanismo di autorizzazione esiste già: manca il contatore e la
|
||||
soglia.
|
||||
|
||||
---
|
||||
|
||||
## 5. Avanzamento solo se in tolleranza `da verificare`
|
||||
|
||||
**Deve:** si passa alla quota successiva in autonomia **solo se la quota è in
|
||||
tolleranza**; per confermare una quota fuori tolleranza serve il capoturno.
|
||||
|
||||
**Nota:** il gate del capoturno per il fuori tolleranza è implementato. Va verificato
|
||||
sul campo che **blocchi davvero l'avanzamento** e non sia solo una richiesta di conferma.
|
||||
Se blocca, il punto si chiude senza sviluppo.
|
||||
|
||||
---
|
||||
|
||||
## 6. Fermo linea e Fine produzione: dare effetto `parziale`
|
||||
|
||||
**Oggi:** entrambi chiedono l'autorizzazione del capoturno, poi non succede niente.
|
||||
|
||||
**Deve:**
|
||||
- **Fermo linea** — sospende il timer e la produzione; il capoturno può riattivarla;
|
||||
- **Fine produzione** — chiude la produzione, ferma il timer definitivamente e **invia
|
||||
i dati di misura dell'intera produzione** al file di statistica.
|
||||
|
||||
**Perché conta:** il comportamento verso il gestionale è ancora da definire (Parte 3),
|
||||
ma **tutto ciò che sta prima del gestionale si può e si deve fare adesso**: sospendere,
|
||||
riprendere, chiudere, registrare. Consegnare i pulsanti funzionanti senza il gestionale
|
||||
è possibile; il contrario no.
|
||||
|
||||
---
|
||||
|
||||
## 7. Gestione delle stazioni `parziale`
|
||||
|
||||
**Deve:**
|
||||
- la lista stazioni mostra, oltre a codice e postazione, **le ricette collegate**;
|
||||
- esiste un **reset della stazione**, con un pulsante **per riga** nella lista:
|
||||
la stazione torna senza ricette associate e si riassegna (precisazione
|
||||
dell'operatore, 28/07);
|
||||
- la **stazione corrente si può cambiare al volo**, per poter provare più stazioni da un
|
||||
solo computer senza riconfigurare l'installazione.
|
||||
|
||||
**Perché conta:** è la terza richiesta di Menoncin. Il cambio al volo non è un vezzo da
|
||||
sviluppatori: senza, la sessione di collaudo con Menoncin richiede tanti PC quante sono
|
||||
le stazioni da provare.
|
||||
|
||||
---
|
||||
|
||||
## 8. Tracciabilità obbligatoria `parziale`
|
||||
|
||||
**Oggi:** numero di lotto e numero seriale sono facoltativi e si inseriscono nella lista
|
||||
task, cioè dopo aver iniziato.
|
||||
|
||||
**Deve:** l'obbligatorietà di lotto e seriale si **decide alla creazione della
|
||||
ricetta** (obbligatori sì/no); l'operatore li inserisce **alla selezione della
|
||||
ricetta**, e finché mancano il pulsante *Avvia* **non si attiva** (precisazione
|
||||
dell'operatore, 28/07).
|
||||
|
||||
**Perché conta:** una misura senza lotto non è tracciabile a posteriori, e la
|
||||
tracciabilità è metà del valore del sistema in un audit.
|
||||
|
||||
---
|
||||
|
||||
## 9. Blocco dell'inserimento manuale `da fare`
|
||||
|
||||
**Oggi:** il sistema registra **come** è stata inserita una misura (calibro o tastiera),
|
||||
ma accetta sempre entrambi.
|
||||
|
||||
**Deve:** un'impostazione della ricetta consente o vieta l'inserimento manuale, **con
|
||||
divieto come impostazione predefinita**: si misura col calibro.
|
||||
|
||||
**Perché conta:** è il punto sollevato in call — senza questo vincolo un valore in
|
||||
tolleranza si può digitare. Il dato su *come* è stata inserita c'è già: manca la regola
|
||||
che lo impedisce.
|
||||
|
||||
---
|
||||
|
||||
## 10. Interfaccia operatore: sequenza e conferme `da fare`
|
||||
|
||||
Richieste del 19/05, tutte di interfaccia:
|
||||
|
||||
- un pulsante che **avvia i task in sequenza**, senza sceglierli a uno a uno;
|
||||
- la lista completa dei task retrocessa a **secondo livello**, per tornare a vedere i
|
||||
task precedenti;
|
||||
- «inizia misure» rinominato **«visualizza singolo TASK»**;
|
||||
- dentro il task, «Riepilogo» sostituito da **«Completato»** per passare al successivo;
|
||||
- un task lasciato a metà **resta incompiuto** e si vede;
|
||||
- «fine ciclo misura» **cliccabile solo quando tutte le quote hanno un valore**.
|
||||
|
||||
---
|
||||
|
||||
## 11. Formattazione delle descrizioni `da fare`
|
||||
|
||||
**Deve:** le descrizioni dei task accettano andate a capo e grassetto.
|
||||
|
||||
**Perché conta:** chi crea le ricette fa **copia e incolla dal PDF della scheda
|
||||
tecnica**; oggi il testo arriva appiattito e va risistemato a mano ogni volta.
|
||||
|
||||
---
|
||||
|
||||
## 12. Funzionamento senza internet `da fare` · bloccante per l'installazione
|
||||
|
||||
**Oggi:** l'applicazione **non funziona senza collegamento a internet**. Cinque librerie
|
||||
vengono scaricate al volo da servizi esterni ogni volta che si apre una pagina.
|
||||
|
||||
**In una rete di produzione isolata — la norma in fabbrica — il risultato è una pagina
|
||||
bianca.** Non un degrado: l'interfaccia non parte proprio, e senza le altre non si vedono
|
||||
i disegni tecnici né i grafici statistici.
|
||||
|
||||
**Deve:** tutte le librerie sono incluse nell'installazione e l'applicazione funziona a
|
||||
rete staccata.
|
||||
|
||||
**Perché conta ora:** l'installazione a Tràfilo è on-premise e prevista per settembre.
|
||||
È poco lavoro, ma va fatto **prima**, non in fabbrica il giorno dell'installazione.
|
||||
|
||||
**Beneficio collaterale non ovvio:** oggi una delle librerie è agganciata a una versione
|
||||
"qualunque della serie 3" — cioè **l'applicazione cambia da sola** quando gli autori
|
||||
pubblicano un aggiornamento, senza che nessuno l'abbia validata. Per un sistema che
|
||||
produce evidenze per audit ISO 9001 / IATF 16949 questo è di per sé un problema.
|
||||
Includendo le librerie le versioni si congelano: da difetto diventa argomento di vendita.
|
||||
|
||||
---
|
||||
|
||||
## 13. Generazione dei task dalla scheda tecnica con l'AI `fuori offerta`
|
||||
|
||||
**Richiesta:** leggere il PDF della scheda tecnica e **creare un task per blocco**,
|
||||
invece del copia-incolla manuale.
|
||||
|
||||
**Storia:** posta il **19/05/2026** nel documento delle modifiche, rimasta senza
|
||||
risposta; **rilanciata da Tràfilo il 28/07** come elaborazione massiva iniziale delle
|
||||
schede, «senza installare agenti nel sistema».
|
||||
|
||||
**Stato:** non è in nessuna offerta. Prima di quotare servono tre informazioni: quante
|
||||
sono le schede, se il formato è standard, e se l'elaborazione è una-tantum in fase di
|
||||
avviamento o una funzione permanente del prodotto. Sono domande da fare, non da
|
||||
supporre — vedi Parte 3.
|
||||
|
||||
---
|
||||
|
||||
## 14. Stabilità del layout `da fare` · da circoscrivere
|
||||
|
||||
**Oggi:** le dimensioni delle viste **cambiano a seconda del menu**: passando da una
|
||||
schermata all'altra la finestra non mantiene proporzioni stabili.
|
||||
|
||||
**Deve:** il layout resta stabile nel passaggio fra le viste.
|
||||
|
||||
**Nota:** segnalazione dell'operatore del 28/07, non ancora circoscritta sul codice —
|
||||
prima di intervenire va riprodotta e va stilato l'elenco delle viste interessate.
|
||||
|
||||
---
|
||||
|
||||
## 15. Statistica: si registra sempre, si consulta a parte `già fatto` · da confermare sul campo
|
||||
|
||||
**Richiesta (28/07):** a fine misura l'operatore **non va portato nella pagina della
|
||||
statistica**. I dati **entrano comunque in statistica**: cambia solo chi la consulta —
|
||||
serve l'**utente con il ruolo adeguato**, che apre la pagina dedicata.
|
||||
|
||||
**Verificato sul codice:** è già così. Tutte le pagine di statistica richiedono il
|
||||
ruolo **Metrologo** (`role_required("Metrologist")` su ogni route), e a fine ciclo
|
||||
l'operatore viene portato al **riepilogo**, non alla statistica.
|
||||
|
||||
**Resta da fare:** niente sviluppo; il requisito entra come **criterio di collaudo**
|
||||
(l'operatore non deve poter raggiungere la statistica da nessun percorso) e va tenuto
|
||||
fermo quando il punto 10 ridisegna la navigazione a fine task.
|
||||
|
||||
---
|
||||
|
||||
# Parte 2 — Dove si interviene
|
||||
|
||||
Riferimenti al branch `V2.0.0`, commit `2a56632`.
|
||||
|
||||
## Ordine consigliato
|
||||
|
||||
```
|
||||
12 (offline) ──────────────► indipendente, si può fare subito
|
||||
bloccante per l'installazione
|
||||
|
||||
1 (stato produzione) ──┬───► 3 (loop misura)
|
||||
├───► 6 (fermo linea / fine produzione)
|
||||
└───► 4 (limite tentativi)
|
||||
|
||||
2 (tipo task) ─────────────► 3, 10
|
||||
|
||||
7 (stazioni) · 8 (tracciabilità) · 9 (inserimento manuale) · 11 (formattazione)
|
||||
indipendenti fra loro
|
||||
|
||||
5 (avanzamento in tolleranza) ──► prima verificare, forse è già a posto
|
||||
|
||||
14 (layout) ────────────────► prima riprodurre e circoscrivere le viste
|
||||
|
||||
15 (statistica riservata) ──► già a posto: solo criterio di collaudo,
|
||||
da non rompere lavorando sul punto 10
|
||||
```
|
||||
|
||||
La separazione **backend dati sul server / app di stazione in locale** (vedi
|
||||
*Architettura d'installazione*) non è un punto di questa lista: è il contesto in
|
||||
cui i punti 1, 3, 6 e 7 vanno progettati. In pratica: API senza stato, stato solo
|
||||
nel database, niente dipendenze dal container per l'identità della stazione.
|
||||
|
||||
## 1 · Stato della produzione lato server
|
||||
|
||||
**Problema tecnico:** `task_execute.html` tiene in variabili Alpine
|
||||
(`timerActive`, `timerRemaining`, `_timerInterval`, `cycleCount`, `cycleConfirmed`,
|
||||
`productionStarted`, `measurements`) uno stato che deve sopravvivere alla pagina.
|
||||
`goToNextTask()` fa `window.location.href` → il componente viene distrutto.
|
||||
Lato server la sessione conserva soltanto `lot_number` e `serial_number`.
|
||||
|
||||
**Intervento:**
|
||||
- nuove tabelle `production_runs` e `production_events` (avvio, ciclo completato, fermo
|
||||
linea, ripresa, chiusura), con `station_id`, `recipe_version_id`, `operator_id`,
|
||||
`supervisor_id` dove serve, timestamp;
|
||||
- endpoint REST per aprire, interrogare e aggiornare la produzione corrente della
|
||||
stazione;
|
||||
- il frontend legge lo stato all'apertura di ogni pagina invece di tenerlo in memoria;
|
||||
il conto alla rovescia si **ricalcola dall'orario di scadenza** salvato lato server,
|
||||
non da un contatore locale;
|
||||
- gli endpoint vanno progettati **senza stato in memoria di processo**: con l'app di
|
||||
stazione installata su ogni PC (vedi *Architettura d'installazione*) il database è
|
||||
l'unico posto condiviso.
|
||||
|
||||
**File:** `src/backend/models/orm/` (nuovo modulo), `src/backend/migrations/versions/`
|
||||
(migrazione 005), `src/backend/api/routers/`, `src/frontend/flask_app/blueprints/measure.py`,
|
||||
`src/frontend/flask_app/templates/measure/task_execute.html`.
|
||||
|
||||
**Fatto quando:** avviata una produzione, si naviga fra i task, si esce e si rientra, e
|
||||
il timer prosegue coerente; il riavvio del browser non azzera nulla.
|
||||
|
||||
---
|
||||
|
||||
## 2 · Tipo di task
|
||||
|
||||
**Problema tecnico:** `RecipeTask` non ha campo tipo; il frontend decide con
|
||||
`subtasks.length > 0` (`task_execute.html`, `task_list.html`).
|
||||
|
||||
**Intervento:** campo `type` su `recipe_tasks` con valori `note | measure | drawing`
|
||||
(predisposto per `xf_compare`, `camera_measure`), migrazione con valorizzazione dei dati
|
||||
esistenti secondo la regola attuale, selezione del tipo nell'editor ricetta,
|
||||
sostituzione dei controlli su `subtasks.length` con il tipo.
|
||||
|
||||
**File:** `src/backend/models/orm/task.py`, nuova migrazione,
|
||||
`src/backend/models/api/`, `src/frontend/flask_app/templates/maker/task_editor.html`,
|
||||
`templates/measure/task_execute.html`, `templates/measure/task_list.html`.
|
||||
|
||||
**Fatto quando:** una ricetta con un task di misura ancora privo di quote si comporta da
|
||||
task di misura.
|
||||
|
||||
---
|
||||
|
||||
## 3 · Loop di misura
|
||||
|
||||
**Base già presente:** `confirmCycle()`, `startMeasurementTimer()`, `onTimerExpired()`,
|
||||
`timerDisplay`, `playBuzzer()` in `task_execute.html` (righe ~955-1050). La logica è
|
||||
corretta; il problema è la persistenza (punto 1) e il fatto che allo scadere non si può
|
||||
riportare l'operatore sul task giusto.
|
||||
|
||||
**Intervento:** spostare la scadenza sul server; alla scadenza, **redirezione al task di
|
||||
misura** della produzione corrente; con più task di misura far ripartire il timer al
|
||||
completamento dell'**ultimo**; aggiungere «rimisura» che riapre il ciclo senza chiuderlo.
|
||||
|
||||
**Timer bidirezionale:** oggi il contatore **solo decrementa e si ferma a zero**
|
||||
(`timerRemaining--`, poi `onTimerExpired()`); `timerDisplay` formatta minuti:secondi
|
||||
dal residuo. Va esteso: sotto zero il valore continua **in negativo** e la
|
||||
visualizzazione passa a "oltre da m:s", con stile in evidenza. Calcolando dal
|
||||
timestamp di scadenza lato server (come sopra), il ritardo è coerente su qualunque
|
||||
schermata e sopravvive al ricaricamento.
|
||||
|
||||
**Fatto quando:** con una ricetta a due task di misura e task documentali in coda, allo
|
||||
scadere del timer l'operatore viene riportato alla misura da qualunque schermata.
|
||||
|
||||
---
|
||||
|
||||
## 4 · Limite di tentativi
|
||||
|
||||
**Problema tecnico:** nessun `max_retries` nel codice.
|
||||
|
||||
**Intervento:** campo sulla ricetta (accanto a `measurement_interval_minutes`, che segue
|
||||
lo stesso schema), contatore per quota nella produzione corrente, superata la soglia
|
||||
riuso del modale capoturno esistente con motivo `max_retries`.
|
||||
|
||||
**File:** `src/backend/models/orm/recipe.py`, migrazione,
|
||||
`templates/maker/recipe_editor.html` (accanto al timer), `task_execute.html`.
|
||||
|
||||
---
|
||||
|
||||
## 5 · Avanzamento in tolleranza — prima verificare
|
||||
|
||||
Il modale capoturno gestisce già `out_of_tolerance` (`task_execute.html`,
|
||||
`openSupervisorModal`, `validateSupervisor`, endpoint
|
||||
`measure.validate_supervisor`). **Prima di sviluppare, provare**: se il rifiuto blocca
|
||||
l'avanzamento, il punto è chiuso. Se è solo una conferma, va reso vincolante.
|
||||
|
||||
---
|
||||
|
||||
## 6 · Fermo linea e Fine produzione
|
||||
|
||||
**Problema tecnico:** in `task_execute.html` i due pulsanti aprono il modale e poi
|
||||
ricadono su un commento: `fermo_linea and fine_produzione are handled by GAIA
|
||||
integration (future)`. `startProduction()` è un `TODO` con la chiamata commentata.
|
||||
|
||||
**Intervento (senza gestionale):** scrivere gli eventi su `production_events`,
|
||||
sospendere e riprendere il timer, chiudere la produzione, ed **emettere il file di
|
||||
statistica** con tutte le misure della produzione. L'invio al gestionale resta un
|
||||
adattatore separato da riempire quando il protocollo sarà definito (Parte 3): va
|
||||
previsto il punto d'innesto, non l'implementazione.
|
||||
|
||||
**Fatto quando:** *fermo linea* congela il timer e solo il capoturno lo riattiva; *fine
|
||||
produzione* chiude e produce il file.
|
||||
|
||||
---
|
||||
|
||||
## 7 · Stazioni
|
||||
|
||||
**Base presente:** `Station` e `StationRecipeAssignment` (`models/orm/station.py`,
|
||||
migrazione 002).
|
||||
|
||||
**Intervento:** ricette collegate nella lista stazioni; pulsante di reset **per riga**
|
||||
che rimuove le assegnazioni; selezione della stazione corrente da parametro URL con
|
||||
ricaduta sulla variabile d'ambiente, per il collaudo da una sola macchina.
|
||||
|
||||
**Nota (28/07, chiude il dubbio che era in D-3):** in produzione l'identità della
|
||||
stazione è data dall'**installazione locale** dell'app di stazione, non dal container.
|
||||
Il cambio al volo via URL resta come strumento di **collaudo**.
|
||||
|
||||
---
|
||||
|
||||
## 8 · Tracciabilità
|
||||
|
||||
**Base presente:** `lot_number` e `serial_number` su `Measurement`, salvataggio in
|
||||
sessione (`measure.save_traceability`), inserimento in `task_list.html`.
|
||||
|
||||
**Intervento:** flag sulla ricetta (`richiede lotto`, `richiede seriale`); spostare
|
||||
l'inserimento sull'avvio produzione; *Avvia* disabilitato finché mancano.
|
||||
|
||||
---
|
||||
|
||||
## 9 · Inserimento manuale
|
||||
|
||||
**Base presente:** `Measurement.input_method` (`manual | usb_caliper`), valorizzato dal
|
||||
frontend.
|
||||
|
||||
**Intervento:** flag sulla ricetta `consente inserimento manuale`, **predefinito falso**;
|
||||
validazione **lato server** — un flag solo nel frontend non protegge da nulla; tastierino
|
||||
nascosto quando vietato.
|
||||
|
||||
---
|
||||
|
||||
## 10 · Interfaccia operatore
|
||||
|
||||
Interventi su `templates/measure/task_list.html` e `task_execute.html`: pulsante di
|
||||
avvio in sequenza, retrocessione della lista a secondo livello, rinomina dei due
|
||||
pulsanti, stato «incompiuto» sui task abbandonati, «fine ciclo misura» abilitato solo a
|
||||
quote complete.
|
||||
|
||||
---
|
||||
|
||||
## 11 · Formattazione descrizioni
|
||||
|
||||
`RecipeTask.description` è già `Text`. Serve un editor minimale (grassetto e a capo) e
|
||||
la resa corrispondente in esecuzione, con **sanificazione dell'HTML** in ingresso.
|
||||
|
||||
**File:** `templates/maker/task_editor.html`, `templates/measure/task_execute.html`.
|
||||
|
||||
---
|
||||
|
||||
## 12 · Funzionamento senza internet
|
||||
|
||||
**Verificato sul codice.** Cinque librerie esterne in sei template:
|
||||
|
||||
| Libreria | Dove | Senza rete si perde |
|
||||
|---|---|---|
|
||||
| Alpine.js `3.x.x` | `base.html` | **tutta l'interfaccia** |
|
||||
| Plotly `2.32.0` | `statistics/dashboard.html` | carte di controllo e istogrammi |
|
||||
| PDF.js `3.11.174` | `task_execute`, `recipe_preview`, `task_drawing`, `task_editor` | visualizzazione dei disegni |
|
||||
| Fabric.js `5.3.1` | `maker/task_drawing.html` | editor delle annotazioni |
|
||||
| Google Fonts | `base.html` | estetica e attese al caricamento |
|
||||
|
||||
**Intervento:** scaricare le librerie in `src/frontend/flask_app/static/vendor/` (la
|
||||
cartella **esiste già ed è vuota**), ripuntare i tag, chiudere la policy di sicurezza in
|
||||
`security_headers.py` da elenco-di-CDN a solo-origine-locale.
|
||||
|
||||
⚠️ **Trappola da non mancare:** PDF.js ha una **seconda** referenza al CDN,
|
||||
`pdfjsLib.GlobalWorkerOptions.workerSrc`, presente in **quattro file**. Ripuntando solo
|
||||
lo script principale la libreria si carica in locale **e il worker continua a cercare
|
||||
internet**: sembra funzionare finché non si apre un disegno.
|
||||
|
||||
**Fatto quando:** con la rete staccata si percorre login → scelta ricetta → esecuzione
|
||||
task → annotazione disegno → statistiche → report, senza errori in console.
|
||||
|
||||
---
|
||||
|
||||
# Parte 3 — Decisioni che non dipendono da noi
|
||||
|
||||
Da chiudere **prima** che i punti collegati entrino in sviluppo. Vanno girate a Tràfilo
|
||||
tramite Menoncin.
|
||||
|
||||
| # | Domanda | Blocca | Chi risponde |
|
||||
|---|---|---|---|
|
||||
| **D-1** | **Protocollo del gestionale GAIA**: come si scambiano i dati — servizi web, database condiviso, file? | avvio produzione, fermo linea, fine produzione verso il gestionale; lettura dei codici articolo per stazione | IT Tràfilo + fornitore GAIA |
|
||||
| **D-2** | **Rete e credenziali** per raggiungere GAIA dal server dove sarà installato | come sopra | IT Tràfilo |
|
||||
| ~~**D-3**~~ | ~~Una applicazione per stazione o una sola per tutte?~~ **Decisa il 28/07** lato Tielogic: backend dati sul server, app di stazione installata su ogni PC — vedi *Architettura d'installazione*. Resta la validazione con l'IT di Tràfilo (macchina e rete) | — | chiusa (noi); validazione in D-4 |
|
||||
| **D-4** | **Server**: quale macchina, quanto spazio disco. Sul server stanno database **e file dei disegni** (`uploads/`); l'elaborazione visione **non** è sul server (sta sull'app di stazione), quindi pesa lo spazio disco, non la potenza di calcolo | installazione | IT Tràfilo |
|
||||
| **D-5** | **Cicalino**: basta il suono del browser o serve una segnalazione luminosa? La specifica chiede luce **e** suono accesi per tutta la misura, visibili da lontano. Con l'architettura del 28/07 l'app di stazione **può pilotare una colonnina**: la domanda diventa *quale hardware* | punto 3 | Tràfilo |
|
||||
| **D-6** | **Autorizzazione capoturno**: username e password come oggi, o PIN rapido / badge? Venti volte al giorno la password è un attrito | punti 4, 5, 6 | Tràfilo |
|
||||
| **D-7** | **Numero di tentativi** consentiti prima del capoturno: quanti, e uguali per tutte le ricette? | punto 4 | Tràfilo |
|
||||
| **D-8** | **Schede tecniche**: quante sono, il formato è standard, e serve una conversione una-tantum o una funzione permanente? | punto 13 e la sua quotazione | Tràfilo |
|
||||
| **D-9** | Modificare i parametri di una ricetta (timer, tentativi) **crea una nuova versione** o no? Sono parametri di esercizio, non di prodotto | punti 4, 3 | noi, con conferma cliente |
|
||||
|
||||
**Nota su D-1 e D-2:** finché non hanno risposta, del gestionale si può solo predisporre
|
||||
il punto d'innesto. Tutto il resto del punto 6 — sospendere, riprendere, chiudere,
|
||||
registrare, produrre il file — **si fa comunque e va fatto adesso**.
|
||||
|
||||
---
|
||||
|
||||
## Appendice — Come è stato verificato
|
||||
|
||||
Ogni «già fatto» e ogni «da fare» viene dalla lettura del codice al commit `2a56632`,
|
||||
non dai documenti. In particolare:
|
||||
|
||||
- il **tipo di task** è dedotto da `subtasks.length > 0` in `task_execute.html:117` e
|
||||
`task_list.html:169`;
|
||||
- il **ruolo capoturno** esiste come `Supervisor`
|
||||
(`api/middleware/api_key.py:71`, `blueprints/measure.py:350`);
|
||||
- **fermo linea / fine produzione / avvio produzione** hanno interfaccia e gate ma
|
||||
nessun effetto (`task_execute.html:1017-1021`, `1118`);
|
||||
- il **timer** è un `setInterval` locale alla pagina (`task_execute.html:968-995`) e la
|
||||
navigazione fra task è un ricaricamento (`task_execute.html:1054`);
|
||||
- **`max_retries`** e **`production_events`** non compaiono in nessun file;
|
||||
- le **cinque librerie da CDN** sono ai riferimenti citati al punto 12, e
|
||||
`static/vendor/` contiene solo `.gitkeep`;
|
||||
- i **disegni stanno su filesystem**, non nel database: cartella `uploads/` servita
|
||||
dal backend (`api/routers/files.py:201`, `FileResponse`) — verifica del 28/07 sera,
|
||||
corregge quanto detto in call;
|
||||
- la **statistica è già riservata**: ogni route di `blueprints/statistics.py` porta
|
||||
`@role_required("Metrologist")`; a fine ciclo il frontend va al riepilogo
|
||||
(`task_execute.html:1126`), non alla statistica;
|
||||
- il **timer si ferma a zero**: decremento in `task_execute.html:974-975` con uscita
|
||||
su `onTimerExpired()` — il conteggio del ritardo (punto 3) oggi non esiste;
|
||||
- lo stack attuale è a 4 container (`docker-compose.yml`): MySQL 8, backend FastAPI,
|
||||
frontend Flask, nginx — base della sezione *Architettura d'installazione*.
|
||||
|
||||
Il punto 14 (layout) è l'unico **non verificato sul codice**: è una segnalazione
|
||||
dell'operatore del 28/07, da riprodurre.
|
||||
@@ -1,25 +0,0 @@
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
# Installa Node.js per Tailwind CSS build
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
||||
|
||||
COPY . .
|
||||
|
||||
# Install and build Tailwind CSS
|
||||
RUN npm install tailwindcss@3 && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
||||
|
||||
# Compile Flask-Babel translations
|
||||
RUN pybabel compile -d translations
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["gunicorn", "--workers", "2", "--bind", "0.0.0.0:5000", "app:create_app()"]
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
"""TieMeasureFlow Client - Flask Entry Point."""
|
||||
import json
|
||||
import os
|
||||
|
||||
from flask import Flask, redirect, url_for, session, request
|
||||
from flask_babel import Babel
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from markupsafe import Markup
|
||||
|
||||
from config import Config
|
||||
|
||||
|
||||
def get_locale():
|
||||
"""Get user's preferred language from session or Accept-Language header."""
|
||||
# 1. User preference in session
|
||||
if "language" in session:
|
||||
return session["language"]
|
||||
# 2. Browser Accept-Language
|
||||
return request.accept_languages.best_match(
|
||||
Config.LANGUAGES.keys(), default="it"
|
||||
)
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
"""Application factory."""
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
|
||||
# Initialize CSRF protection
|
||||
csrf = CSRFProtect(app)
|
||||
|
||||
# Initialize Flask-Babel
|
||||
Babel(app, locale_selector=get_locale)
|
||||
|
||||
# Register blueprints
|
||||
from blueprints.auth import auth_bp
|
||||
from blueprints.measure import measure_bp
|
||||
from blueprints.maker import maker_bp
|
||||
from blueprints.statistics import statistics_bp
|
||||
from blueprints.admin import admin_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(measure_bp, url_prefix="/measure")
|
||||
app.register_blueprint(maker_bp, url_prefix="/maker")
|
||||
app.register_blueprint(statistics_bp, url_prefix="/statistics")
|
||||
app.register_blueprint(admin_bp, url_prefix="/admin")
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
"""Root redirect to login or dashboard based on session."""
|
||||
if "user" in session:
|
||||
return redirect(url_for("measure.select_recipe"))
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
@app.route("/set-language/<lang>")
|
||||
def set_language(lang):
|
||||
"""Set user's preferred language and store in session."""
|
||||
if lang in Config.LANGUAGES:
|
||||
session["language"] = lang
|
||||
return redirect(request.referrer or url_for("auth.login"))
|
||||
|
||||
@app.template_filter("tojson_attr")
|
||||
def tojson_attr_filter(value):
|
||||
"""JSON encode safe for HTML attributes (x-data, etc.).
|
||||
|
||||
Unlike |tojson, this escapes double quotes to " so the output
|
||||
can be safely embedded inside double-quoted HTML attributes.
|
||||
The browser decodes the entities before Alpine.js evaluates them.
|
||||
"""
|
||||
rv = json.dumps(value, ensure_ascii=False)
|
||||
rv = (
|
||||
rv.replace("&", "\\u0026")
|
||||
.replace("<", "\\u003c")
|
||||
.replace(">", "\\u003e")
|
||||
.replace("'", "\\u0027")
|
||||
.replace('"', """)
|
||||
)
|
||||
return Markup(rv)
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
"""Inject global variables into all templates."""
|
||||
return {
|
||||
"current_user": session.get("user"),
|
||||
"current_theme": session.get("theme", "light"),
|
||||
"current_language": get_locale(),
|
||||
"languages": Config.LANGUAGES,
|
||||
"company_logo": session.get("company_logo"),
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = create_app()
|
||||
app.run(
|
||||
host=os.getenv("CLIENT_HOST", "0.0.0.0"),
|
||||
port=int(os.getenv("CLIENT_PORT", "5000")),
|
||||
debug=True,
|
||||
)
|
||||
@@ -1,160 +0,0 @@
|
||||
"""Authentication blueprint - login, logout, profile."""
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, session, url_for
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from services.api_client import api_client
|
||||
|
||||
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||
|
||||
|
||||
def login_required(f):
|
||||
"""Decorator to require login for protected routes."""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not session.get("api_key"):
|
||||
flash(_("Effettua il login per continuare"), "warning")
|
||||
return redirect(url_for("auth.login"))
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def role_required(*roles):
|
||||
"""Decorator to require one of the specified roles.
|
||||
|
||||
Usage:
|
||||
@role_required("MeasurementTec", "Metrologist")
|
||||
def my_view(): ...
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
user = session.get("user", {})
|
||||
user_roles = user.get("roles", [])
|
||||
if not any(r in user_roles for r in roles):
|
||||
abort(403)
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
return decorator
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
"""Login page and form handler."""
|
||||
# Se già loggato, redirect a home
|
||||
if session.get("api_key"):
|
||||
try:
|
||||
return redirect(url_for("measure.select_recipe"))
|
||||
except Exception:
|
||||
return redirect(url_for("auth.profile"))
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
|
||||
if not username or not password:
|
||||
flash(_("Inserisci username e password"), "error")
|
||||
return render_template("auth/login.html")
|
||||
|
||||
try:
|
||||
response = api_client.post("/api/auth/login", data={"username": username, "password": password})
|
||||
|
||||
if response.get("error"):
|
||||
flash(response.get("detail", _("Credenziali non valide")), "error")
|
||||
return render_template("auth/login.html")
|
||||
|
||||
# Salva in sessione
|
||||
session["api_key"] = response["api_key"]
|
||||
session["user"] = response["user"]
|
||||
session["user_id"] = response["user"]["id"]
|
||||
|
||||
# Applica preferenze utente
|
||||
user = response["user"]
|
||||
if user.get("language_pref"):
|
||||
session["language"] = user["language_pref"]
|
||||
if user.get("theme_pref"):
|
||||
session["theme"] = user["theme_pref"]
|
||||
|
||||
# Carica logo aziendale dalle impostazioni di sistema
|
||||
settings_resp = api_client.get("/api/settings")
|
||||
if not settings_resp.get("error"):
|
||||
logo_path = settings_resp.get("company_logo_path")
|
||||
if logo_path:
|
||||
session["company_logo"] = logo_path
|
||||
|
||||
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
|
||||
|
||||
# Redirect dopo login
|
||||
try:
|
||||
return redirect(url_for("measure.select_recipe"))
|
||||
except Exception:
|
||||
return redirect(url_for("auth.profile"))
|
||||
|
||||
except Exception as e:
|
||||
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
|
||||
return render_template("auth/login.html")
|
||||
|
||||
return render_template("auth/login.html")
|
||||
|
||||
|
||||
@auth_bp.route("/logout", methods=["GET", "POST"])
|
||||
def logout():
|
||||
"""Clear session and redirect to login."""
|
||||
# Invalida il token sul server
|
||||
if session.get("api_key"):
|
||||
try:
|
||||
api_client.post("/api/auth/logout")
|
||||
except Exception:
|
||||
pass # Ignora errori durante logout
|
||||
|
||||
session.clear()
|
||||
flash(_("Logout effettuato"), "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@auth_bp.route("/profile", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def profile():
|
||||
"""User profile page - change display name, language, theme."""
|
||||
if request.method == "POST":
|
||||
display_name = request.form.get("display_name", "").strip()
|
||||
language_pref = request.form.get("language_pref", "").strip()
|
||||
theme_pref = request.form.get("theme_pref", "").strip()
|
||||
|
||||
try:
|
||||
data = {}
|
||||
if display_name:
|
||||
data["display_name"] = display_name
|
||||
if language_pref:
|
||||
data["language_pref"] = language_pref
|
||||
if theme_pref:
|
||||
data["theme_pref"] = theme_pref
|
||||
|
||||
response = api_client.put("/api/auth/me", data=data)
|
||||
|
||||
if response.get("error"):
|
||||
flash(response.get("detail", _("Errore durante l'aggiornamento del profilo")), "error")
|
||||
else:
|
||||
# Aggiorna sessione
|
||||
session["user"] = response
|
||||
if language_pref:
|
||||
session["language"] = language_pref
|
||||
if theme_pref:
|
||||
session["theme"] = theme_pref
|
||||
|
||||
flash(_("Profilo aggiornato con successo"), "success")
|
||||
return redirect(url_for("auth.profile"))
|
||||
|
||||
except Exception as e:
|
||||
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
|
||||
|
||||
# GET - carica dati profilo
|
||||
user_data = api_client.get("/api/auth/me")
|
||||
if user_data.get("error"):
|
||||
flash(_("Errore nel caricamento del profilo: %(error)s", error=user_data.get("detail", "")), "error")
|
||||
user_data = session.get("user", {})
|
||||
else:
|
||||
session["user"] = user_data
|
||||
|
||||
return render_template("auth/profile.html", user=user_data)
|
||||
@@ -1,426 +0,0 @@
|
||||
"""Maker blueprint - recipe creation and editing."""
|
||||
import requests as http_requests
|
||||
|
||||
from flask import Blueprint, Response, flash, jsonify, redirect, render_template, request, session, url_for
|
||||
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
|
||||
|
||||
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PAGINE (GET)
|
||||
# ============================================================================
|
||||
|
||||
@maker_bp.route("/recipes")
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def recipe_list():
|
||||
"""List all recipes with filters."""
|
||||
page = request.args.get("page", 1, type=int)
|
||||
per_page = request.args.get("per_page", 100, type=int)
|
||||
search = request.args.get("search", "", type=str)
|
||||
|
||||
params = {"page": page, "per_page": per_page}
|
||||
if search:
|
||||
params["search"] = search
|
||||
|
||||
resp = api_client.get("/api/recipes", params=params)
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
flash(_("Errore nel caricamento delle ricette: %(error)s", error=resp.get("detail", "")), "error")
|
||||
recipes = []
|
||||
total = 0
|
||||
pages = 0
|
||||
else:
|
||||
recipes = resp.get("items", []) if isinstance(resp, dict) else resp
|
||||
total = resp.get("total", 0) if isinstance(resp, dict) else len(recipes)
|
||||
pages = resp.get("pages", 1) if isinstance(resp, dict) else 1
|
||||
|
||||
return render_template(
|
||||
"maker/recipe_list.html",
|
||||
recipes=recipes,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total=total,
|
||||
pages=pages,
|
||||
search=search
|
||||
)
|
||||
|
||||
|
||||
@maker_bp.route("/recipes/new")
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def recipe_new():
|
||||
"""Create new recipe."""
|
||||
return render_template("maker/recipe_editor.html", recipe=None)
|
||||
|
||||
|
||||
@maker_bp.route("/recipes/<int:recipe_id>/edit")
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def recipe_edit(recipe_id: int):
|
||||
"""Edit existing recipe."""
|
||||
# Carica recipe con dettagli completi
|
||||
resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
|
||||
if resp.get("error"):
|
||||
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
|
||||
return redirect(url_for("maker.recipe_list"))
|
||||
|
||||
recipe = resp
|
||||
|
||||
# Carica anche le versioni
|
||||
versions_resp = api_client.get(f"/api/recipes/{recipe_id}/versions")
|
||||
if isinstance(versions_resp, list):
|
||||
recipe["versions"] = versions_resp
|
||||
else:
|
||||
recipe["versions"] = []
|
||||
|
||||
return render_template("maker/recipe_editor.html", recipe=recipe)
|
||||
|
||||
|
||||
@maker_bp.route("/recipes/<int:recipe_id>/tasks")
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def task_editor(recipe_id: int):
|
||||
"""Task/subtask editor with tolerances."""
|
||||
# Carica recipe con task/subtask
|
||||
resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
|
||||
if resp.get("error"):
|
||||
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
|
||||
return redirect(url_for("maker.recipe_list"))
|
||||
|
||||
recipe = resp
|
||||
|
||||
# Carica tasks con subtasks
|
||||
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||
if isinstance(tasks_resp, list):
|
||||
recipe["tasks"] = tasks_resp
|
||||
else:
|
||||
recipe["tasks"] = []
|
||||
|
||||
return render_template("maker/task_editor.html", recipe=recipe)
|
||||
|
||||
|
||||
@maker_bp.route("/recipes/<int:recipe_id>/tasks/<int:task_id>/drawing")
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def task_drawing(recipe_id: int, task_id: int):
|
||||
"""Annotation editor for a specific task."""
|
||||
# Load recipe for breadcrumb context
|
||||
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
if recipe_resp.get("error"):
|
||||
flash(_("Errore nel caricamento della ricetta: %(error)s", error=recipe_resp.get("detail", "")), "error")
|
||||
return redirect(url_for("maker.recipe_list"))
|
||||
|
||||
# Load task details
|
||||
task_resp = api_client.get(f"/api/tasks/{task_id}")
|
||||
if task_resp.get("error"):
|
||||
flash(_("Task non trovato: %(error)s", error=task_resp.get("detail", "")), "error")
|
||||
return redirect(url_for("maker.task_editor", recipe_id=recipe_id))
|
||||
|
||||
return render_template(
|
||||
"maker/task_drawing.html",
|
||||
recipe=recipe_resp,
|
||||
task=task_resp,
|
||||
)
|
||||
|
||||
|
||||
@maker_bp.route("/recipes/<int:recipe_id>/preview")
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def recipe_preview(recipe_id: int):
|
||||
"""Preview recipe as MeasurementTec would see it."""
|
||||
# Carica recipe completo
|
||||
resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
|
||||
if resp.get("error"):
|
||||
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
|
||||
return redirect(url_for("maker.recipe_list"))
|
||||
|
||||
recipe = resp
|
||||
|
||||
# Carica tasks con subtasks
|
||||
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||
if isinstance(tasks_resp, list):
|
||||
recipe["tasks"] = tasks_resp
|
||||
else:
|
||||
recipe["tasks"] = []
|
||||
|
||||
return render_template("maker/recipe_preview.html", recipe=recipe)
|
||||
|
||||
|
||||
@maker_bp.route("/recipes/<int:recipe_id>/versions")
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def version_history(recipe_id: int):
|
||||
"""Version history with diff."""
|
||||
# Carica recipe base
|
||||
resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
|
||||
if resp.get("error"):
|
||||
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
|
||||
return redirect(url_for("maker.recipe_list"))
|
||||
|
||||
recipe = resp
|
||||
|
||||
# Carica versioni
|
||||
versions_resp = api_client.get(f"/api/recipes/{recipe_id}/versions")
|
||||
if isinstance(versions_resp, list):
|
||||
versions = versions_resp
|
||||
else:
|
||||
versions = []
|
||||
flash(_("Errore nel caricamento delle versioni: %(error)s",
|
||||
error=versions_resp.get("detail", "") if isinstance(versions_resp, dict) else ""), "warning")
|
||||
|
||||
return render_template("maker/version_history.html", recipe=recipe, versions=versions)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# API PROXY AJAX (JSON)
|
||||
# ============================================================================
|
||||
|
||||
@maker_bp.route("/api/recipes", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_create_recipe():
|
||||
"""Proxy: Create new recipe."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
resp = api_client.post("/api/recipes", data=data)
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 201
|
||||
|
||||
|
||||
@maker_bp.route("/api/recipes/<int:recipe_id>", methods=["PUT"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_update_recipe(recipe_id: int):
|
||||
"""Proxy: Update recipe (creates new version)."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
resp = api_client.put(f"/api/recipes/{recipe_id}", data=data)
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@maker_bp.route("/api/recipes/<int:recipe_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_delete_recipe(recipe_id: int):
|
||||
"""Proxy: Delete recipe (soft delete)."""
|
||||
resp = api_client.delete(f"/api/recipes/{recipe_id}")
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TASK API PROXY
|
||||
# ============================================================================
|
||||
|
||||
@maker_bp.route("/api/recipes/<int:recipe_id>/tasks", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_create_task(recipe_id: int):
|
||||
"""Proxy: Create new task."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
resp = api_client.post(f"/api/recipes/{recipe_id}/tasks", data=data)
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 201
|
||||
|
||||
|
||||
@maker_bp.route("/api/tasks/<int:task_id>", methods=["PUT"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_update_task(task_id: int):
|
||||
"""Proxy: Update task."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
resp = api_client.put(f"/api/tasks/{task_id}", data=data)
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@maker_bp.route("/api/tasks/<int:task_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_delete_task(task_id: int):
|
||||
"""Proxy: Delete task."""
|
||||
resp = api_client.delete(f"/api/tasks/{task_id}")
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
# 204 No Content returns empty dict
|
||||
return jsonify(resp), 204
|
||||
|
||||
|
||||
@maker_bp.route("/api/tasks/reorder", methods=["PUT"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_reorder_tasks():
|
||||
"""Proxy: Reorder tasks."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
resp = api_client.put("/api/tasks/reorder", data=data)
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SUBTASK API PROXY
|
||||
# ============================================================================
|
||||
|
||||
@maker_bp.route("/api/tasks/<int:task_id>/subtasks", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_create_subtask(task_id: int):
|
||||
"""Proxy: Create new subtask."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
resp = api_client.post(f"/api/tasks/{task_id}/subtasks", data=data)
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 201
|
||||
|
||||
|
||||
@maker_bp.route("/api/subtasks/<int:subtask_id>", methods=["PUT"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_update_subtask(subtask_id: int):
|
||||
"""Proxy: Update subtask."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
resp = api_client.put(f"/api/subtasks/{subtask_id}", data=data)
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@maker_bp.route("/api/subtasks/<int:subtask_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_delete_subtask(subtask_id: int):
|
||||
"""Proxy: Delete subtask."""
|
||||
resp = api_client.delete(f"/api/subtasks/{subtask_id}")
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
# 204 No Content returns empty dict
|
||||
return jsonify(resp), 204
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FILE API PROXY
|
||||
# ============================================================================
|
||||
|
||||
@maker_bp.route("/api/upload", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_upload_file():
|
||||
"""Proxy: Upload file (multipart forward)."""
|
||||
# Controlla se c'è un file
|
||||
if "file" not in request.files:
|
||||
return jsonify({"error": True, "detail": _("Nessun file caricato")}), 400
|
||||
|
||||
file = request.files["file"]
|
||||
if file.filename == "":
|
||||
return jsonify({"error": True, "detail": _("Nome file vuoto")}), 400
|
||||
|
||||
# Prepara multipart data
|
||||
recipe_id = request.form.get("recipe_id")
|
||||
version_id = request.form.get("version_id")
|
||||
|
||||
files = {"file": (file.filename, file.stream, file.content_type)}
|
||||
data = {}
|
||||
if recipe_id:
|
||||
data["recipe_id"] = recipe_id
|
||||
if version_id:
|
||||
data["version_id"] = version_id
|
||||
|
||||
# Forward multipart request
|
||||
resp = api_client.post("/api/files/upload", data=data, files=files)
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 201
|
||||
|
||||
|
||||
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
|
||||
@login_required
|
||||
def api_get_file(file_path: str):
|
||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||
api_key = session.get("api_key", "")
|
||||
base_url = Config.API_SERVER_URL.rstrip("/")
|
||||
resp = http_requests.get(
|
||||
f"{base_url}/api/files/{file_path}",
|
||||
headers={"X-API-Key": api_key},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return Response(resp.text, status=resp.status_code)
|
||||
return Response(
|
||||
resp.content,
|
||||
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||
)
|
||||
|
||||
|
||||
@maker_bp.route("/api/files/<path:file_path>", methods=["DELETE"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_delete_file(file_path: str):
|
||||
"""Proxy: Delete file."""
|
||||
resp = api_client.delete(f"/api/files/{file_path}")
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
# 204 No Content returns empty dict
|
||||
return jsonify(resp), 204
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# VERSION API PROXY
|
||||
# ============================================================================
|
||||
|
||||
@maker_bp.route("/api/recipes/<int:recipe_id>/versions/<int:version_number>/measurement-count", methods=["GET"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_get_measurement_count(recipe_id: int, version_number: int):
|
||||
"""Proxy: Get measurement count for a specific version."""
|
||||
resp = api_client.get(f"/api/recipes/{recipe_id}/versions/{version_number}/measurement-count")
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 200
|
||||
@@ -1,343 +0,0 @@
|
||||
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
||||
import requests as http_requests
|
||||
|
||||
from flask import (
|
||||
Blueprint, Response, flash, jsonify, redirect, render_template,
|
||||
request, session, url_for,
|
||||
)
|
||||
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
|
||||
|
||||
measure_bp = Blueprint("measure", __name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: Recipe selection
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/select")
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def select_recipe():
|
||||
"""Recipe selection page with search and barcode support."""
|
||||
# Fail-fast if STATION_CODE is not configured
|
||||
if not Config.STATION_CODE:
|
||||
return render_template("errors/station_not_configured.html"), 503
|
||||
|
||||
# Load recipes filtered by station
|
||||
try:
|
||||
resp = api_client.get_station_recipes(Config.STATION_CODE)
|
||||
except Exception as e:
|
||||
return render_template(
|
||||
"errors/station_not_configured.html", error=str(e),
|
||||
), 502
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
flash(
|
||||
_("Errore nel caricamento delle ricette: %(detail)s",
|
||||
detail=resp.get("detail", "")),
|
||||
"error",
|
||||
)
|
||||
recipes = []
|
||||
else:
|
||||
# API may return paginated envelope or plain list
|
||||
recipes = resp.get("items", resp) if isinstance(resp, dict) else resp
|
||||
|
||||
# Auto-fill from query params
|
||||
auto_recipe_code = request.args.get("recipe", "")
|
||||
auto_lot = request.args.get("lot", session.get("lot_number", ""))
|
||||
auto_serial = request.args.get("serial", session.get("serial_number", ""))
|
||||
|
||||
return render_template(
|
||||
"measure/select_recipe.html",
|
||||
recipes=recipes,
|
||||
station_code=Config.STATION_CODE,
|
||||
auto_recipe_code=auto_recipe_code,
|
||||
auto_lot=auto_lot,
|
||||
auto_serial=auto_serial,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: Task list for a recipe
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/tasks/<int:recipe_id>")
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def task_list(recipe_id: int):
|
||||
"""Task list for selected recipe."""
|
||||
# Persist lot/serial from query params into session
|
||||
lot_number = request.args.get(
|
||||
"lot_number", session.get("lot_number", ""),
|
||||
)
|
||||
serial_number = request.args.get(
|
||||
"serial_number", session.get("serial_number", ""),
|
||||
)
|
||||
if lot_number:
|
||||
session["lot_number"] = lot_number
|
||||
if serial_number:
|
||||
session["serial_number"] = serial_number
|
||||
|
||||
# Load recipe details
|
||||
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
if recipe_resp.get("error"):
|
||||
flash(
|
||||
_("Ricetta non trovata: %(detail)s",
|
||||
detail=recipe_resp.get("detail", "")),
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("measure.select_recipe"))
|
||||
|
||||
# Load tasks for this recipe
|
||||
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||
if isinstance(tasks_resp, dict) and tasks_resp.get("error"):
|
||||
flash(
|
||||
_("Errore nel caricamento dei task: %(detail)s",
|
||||
detail=tasks_resp.get("detail", "")),
|
||||
"error",
|
||||
)
|
||||
tasks = []
|
||||
else:
|
||||
tasks = tasks_resp if isinstance(tasks_resp, list) else tasks_resp.get("items", [])
|
||||
|
||||
return render_template(
|
||||
"measure/task_list.html",
|
||||
recipe=recipe_resp,
|
||||
tasks=tasks,
|
||||
lot_number=lot_number,
|
||||
serial_number=serial_number,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: Task execution (measurement input)
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/execute/<int:task_id>")
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def task_execute(task_id: int):
|
||||
"""Execute measurements for a task."""
|
||||
# Load task + subtasks
|
||||
task_resp = api_client.get(f"/api/tasks/{task_id}")
|
||||
if task_resp.get("error"):
|
||||
flash(
|
||||
_("Task non trovato: %(detail)s",
|
||||
detail=task_resp.get("detail", "")),
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("measure.select_recipe"))
|
||||
|
||||
lot_number = session.get("lot_number", "")
|
||||
serial_number = session.get("serial_number", "")
|
||||
|
||||
# Load all task IDs for this recipe (ordered) for auto-advance
|
||||
recipe_id = task_resp.get("recipe_id")
|
||||
all_task_ids = []
|
||||
if recipe_id:
|
||||
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||
if isinstance(tasks_resp, list):
|
||||
sorted_tasks = sorted(tasks_resp, key=lambda t: t.get("order_index", 0))
|
||||
all_task_ids = [t["id"] for t in sorted_tasks]
|
||||
|
||||
return render_template(
|
||||
"measure/task_execute.html",
|
||||
task=task_resp,
|
||||
lot_number=lot_number,
|
||||
serial_number=serial_number,
|
||||
all_task_ids=all_task_ids,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: Task completion summary
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/complete/<int:recipe_id>")
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def task_complete(recipe_id: int):
|
||||
"""Task completion summary with all measurements."""
|
||||
# Retrieve version_id from query params
|
||||
version_id = request.args.get("version_id")
|
||||
|
||||
# Load recipe for context
|
||||
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
if recipe_resp.get("error"):
|
||||
flash(
|
||||
_("Ricetta non trovata: %(detail)s",
|
||||
detail=recipe_resp.get("detail", "")),
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("measure.select_recipe"))
|
||||
|
||||
# Load tasks+subtasks for this recipe to build subtask and task lookup
|
||||
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||
subtask_map = {}
|
||||
subtask_task_map = {} # subtask_id → task info
|
||||
if isinstance(tasks_resp, list):
|
||||
for task in tasks_resp:
|
||||
for st in task.get("subtasks", []):
|
||||
subtask_map[st["id"]] = st
|
||||
subtask_task_map[st["id"]] = {
|
||||
"id": task["id"],
|
||||
"title": task.get("title", ""),
|
||||
"order_index": task.get("order_index", 0),
|
||||
}
|
||||
|
||||
# Load measurements if version_id provided
|
||||
measurements = []
|
||||
if version_id:
|
||||
meas_resp = api_client.get(
|
||||
"/api/measurements",
|
||||
params={"version_id": version_id, "per_page": 500},
|
||||
)
|
||||
if not (isinstance(meas_resp, dict) and meas_resp.get("error")):
|
||||
raw = (
|
||||
meas_resp if isinstance(meas_resp, list)
|
||||
else meas_resp.get("items", [])
|
||||
)
|
||||
# Enrich each measurement with nested subtask data
|
||||
for m in raw:
|
||||
st = subtask_map.get(m.get("subtask_id"), {})
|
||||
m["subtask"] = st
|
||||
m["task_info"] = subtask_task_map.get(m.get("subtask_id"), {})
|
||||
# Compute deviation if not present
|
||||
if m.get("deviation") is None and st.get("nominal") is not None:
|
||||
try:
|
||||
m["deviation"] = m["value"] - st["nominal"]
|
||||
except (TypeError, KeyError):
|
||||
m["deviation"] = 0.0
|
||||
measurements = raw
|
||||
|
||||
lot_number = session.get("lot_number", "")
|
||||
serial_number = session.get("serial_number", "")
|
||||
|
||||
return render_template(
|
||||
"measure/task_complete.html",
|
||||
recipe=recipe_resp,
|
||||
measurements=measurements,
|
||||
lot_number=lot_number,
|
||||
serial_number=serial_number,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: Barcode lookup (AJAX)
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/lookup-barcode", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def lookup_barcode():
|
||||
"""Look up a recipe by barcode/code. Returns JSON for AJAX calls."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
code = data.get("code", "").strip()
|
||||
|
||||
if not code:
|
||||
return jsonify({"error": True, "detail": _("Codice non fornito")}), 400
|
||||
|
||||
resp = api_client.get(f"/api/recipes/code/{code}")
|
||||
if resp.get("error"):
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": resp.get("detail", _("Ricetta non trovata")),
|
||||
}), 404
|
||||
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: Save lot/serial to session (AJAX)
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/save-traceability", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def save_traceability():
|
||||
"""Save lot_number and serial_number to session."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
lot = data.get("lot_number", "").strip()
|
||||
serial = data.get("serial_number", "").strip()
|
||||
|
||||
if lot:
|
||||
session["lot_number"] = lot
|
||||
if serial:
|
||||
session["serial_number"] = serial
|
||||
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: Save measurement (AJAX proxy to FastAPI)
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/save-measurement", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def save_measurement():
|
||||
"""Save a single measurement value via API proxy.
|
||||
|
||||
Expects JSON body:
|
||||
subtask_id: int
|
||||
task_id: int
|
||||
value: float
|
||||
pass_fail: str ('pass' | 'warning' | 'fail')
|
||||
deviation: float
|
||||
lot_number: str (optional)
|
||||
serial_number: str (optional)
|
||||
|
||||
Returns JSON with the created measurement or error.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
# Validate required fields
|
||||
subtask_id = data.get("subtask_id")
|
||||
version_id = data.get("version_id")
|
||||
value = data.get("value")
|
||||
|
||||
if subtask_id is None or version_id is None or value is None:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Dati mancanti: subtask_id, version_id e value sono obbligatori"),
|
||||
}), 400
|
||||
|
||||
# Build payload for the FastAPI backend
|
||||
payload = {
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": value,
|
||||
"lot_number": data.get("lot_number", session.get("lot_number", "")),
|
||||
"serial_number": data.get("serial_number", session.get("serial_number", "")),
|
||||
"input_method": data.get("input_method", "manual"),
|
||||
}
|
||||
|
||||
resp = api_client.post("/api/measurements", data=payload)
|
||||
|
||||
if resp.get("error"):
|
||||
status_code = resp.get("status_code", 500)
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": resp.get("detail", _("Errore nel salvataggio")),
|
||||
}), status_code if status_code >= 400 else 500
|
||||
|
||||
return jsonify(resp), 201
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: File proxy (browser can't send X-API-Key directly)
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/api/files/<path:file_path>", methods=["GET"])
|
||||
@login_required
|
||||
def api_get_file(file_path: str):
|
||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||
api_key = session.get("api_key", "")
|
||||
base_url = Config.API_SERVER_URL.rstrip("/")
|
||||
resp = http_requests.get(
|
||||
f"{base_url}/api/files/{file_path}",
|
||||
headers={"X-API-Key": api_key},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return Response(resp.text, status=resp.status_code)
|
||||
return Response(
|
||||
resp.content,
|
||||
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||
)
|
||||
@@ -1,32 +0,0 @@
|
||||
"""TieMeasureFlow Client Configuration."""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
||||
|
||||
|
||||
class Config:
|
||||
"""Flask client configuration."""
|
||||
|
||||
# Flask
|
||||
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", "change-this-to-another-random-secret-key")
|
||||
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
|
||||
|
||||
# API Server connection
|
||||
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
|
||||
|
||||
# Session
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
SESSION_COOKIE_SECURE = not DEBUG # Only secure cookies in production (HTTPS)
|
||||
PERMANENT_SESSION_LIFETIME = 28800 # 8 hours
|
||||
WTF_CSRF_TIME_LIMIT = 3600 # 1 hour
|
||||
|
||||
# Station identity: each deployed client container sets this to the station
|
||||
# code it belongs to. Empty/None means "not configured".
|
||||
STATION_CODE: str | None = os.getenv("STATION_CODE") or None
|
||||
|
||||
# Babel i18n
|
||||
BABEL_DEFAULT_LOCALE = "it"
|
||||
BABEL_DEFAULT_TIMEZONE = "Europe/Rome"
|
||||
LANGUAGES = {"it": "Italiano", "en": "English"}
|
||||
@@ -1,4 +0,0 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_functions = test_*
|
||||
@@ -1,15 +0,0 @@
|
||||
# Flask
|
||||
flask>=3.0.0
|
||||
flask-babel>=4.0.0
|
||||
flask-wtf>=1.2.0
|
||||
|
||||
# HTTP Client (to call FastAPI server)
|
||||
requests>=2.31.0
|
||||
urllib3>=2.0.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Testing
|
||||
pytest>=8.0.0
|
||||
coverage>=7.0.0
|
||||
@@ -1,27 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<!-- Stylized Caliper Icon for Favicon -->
|
||||
|
||||
<!-- Caliper outer frame -->
|
||||
<rect x="2" y="3" width="5" height="26" rx="1.5" fill="#2563EB"/>
|
||||
<rect x="2" y="3" width="19" height="5" rx="1.5" fill="#2563EB"/>
|
||||
<rect x="2" y="24" width="19" height="5" rx="1.5" fill="#2563EB"/>
|
||||
|
||||
<!-- Sliding jaw -->
|
||||
<rect x="12" y="8" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
|
||||
<rect x="12" y="17.5" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
|
||||
|
||||
<!-- Depth rod -->
|
||||
<rect x="4" y="14" width="12" height="3.5" rx="1" fill="#3B82F6" opacity="0.55"/>
|
||||
|
||||
<!-- Scale markings -->
|
||||
<rect x="8" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
||||
<rect x="11" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
||||
<rect x="14" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
||||
<rect x="17" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
||||
|
||||
<!-- Flow arrow -->
|
||||
<path d="M22 16 C25 16, 26.5 11, 29 11"
|
||||
stroke="#3B82F6" stroke-width="2.2" stroke-linecap="round" fill="none"/>
|
||||
<path d="M27 8.5 L29.5 11 L27 13.5"
|
||||
stroke="#3B82F6" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,41 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 44" fill="none">
|
||||
<!-- Stylized Caliper Icon -->
|
||||
<g transform="translate(2, 2)">
|
||||
<!-- Caliper outer frame -->
|
||||
<rect x="0" y="4" width="6" height="32" rx="1.5" fill="#2563EB"/>
|
||||
<rect x="0" y="4" width="24" height="6" rx="1.5" fill="#2563EB"/>
|
||||
<rect x="0" y="30" width="24" height="6" rx="1.5" fill="#2563EB"/>
|
||||
|
||||
<!-- Caliper sliding jaw -->
|
||||
<rect x="14" y="10" width="5" height="8" rx="1" fill="#1E40AF"/>
|
||||
<rect x="14" y="22" width="5" height="8" rx="1" fill="#1E40AF"/>
|
||||
|
||||
<!-- Caliper depth rod -->
|
||||
<rect x="2" y="18" width="16" height="4" rx="1" fill="#3B82F6" opacity="0.6"/>
|
||||
|
||||
<!-- Scale markings -->
|
||||
<rect x="7" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
||||
<rect x="10" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
||||
<rect x="13" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
||||
<rect x="16" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
||||
<rect x="19" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
||||
|
||||
<!-- Flow arrow (smooth curve) -->
|
||||
<path d="M26 20 C30 20, 32 14, 36 14 C40 14, 40 20, 36 20"
|
||||
stroke="#2563EB" stroke-width="2.5" stroke-linecap="round" fill="none"/>
|
||||
<path d="M34 17 L37 20 L34 23" stroke="#2563EB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</g>
|
||||
|
||||
<!-- Text: TieMeasureFlow -->
|
||||
<g transform="translate(48, 0)">
|
||||
<!-- "Tie" in bold primary -->
|
||||
<text x="0" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
|
||||
letter-spacing="-0.5">Tie</text>
|
||||
<!-- "Measure" in medium dark -->
|
||||
<text x="32" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="500" fill="#1E40AF"
|
||||
letter-spacing="-0.5">Measure</text>
|
||||
<!-- "Flow" in bold primary -->
|
||||
<text x="120" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
|
||||
letter-spacing="-0.5">Flow</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,218 +0,0 @@
|
||||
/**
|
||||
* Numpad Component - Alpine.js component for touch-friendly numeric input
|
||||
* Used for measurement data entry in task_execute.html
|
||||
*/
|
||||
|
||||
function numpad() {
|
||||
return {
|
||||
// State
|
||||
value: '', // String representation of the current value
|
||||
negative: false, // Whether the value is negative
|
||||
hasDecimal: false, // Whether a decimal point has been entered
|
||||
unit: 'mm', // Unit of measurement (can be set externally)
|
||||
maxIntDigits: 6, // Maximum integer digits
|
||||
maxDecDigits: 6, // Maximum decimal digits
|
||||
|
||||
// HID burst detection (USB caliper vs manual typing)
|
||||
_lastKeyTime: 0, // Timestamp of last keystroke
|
||||
_burstCount: 0, // Consecutive fast keystrokes
|
||||
|
||||
/**
|
||||
* Get the display value with sign
|
||||
*/
|
||||
get displayValue() {
|
||||
if (!this.value) return '';
|
||||
return (this.negative ? '-' : '') + this.value;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the numeric value as a number
|
||||
*/
|
||||
get numericValue() {
|
||||
if (!this.value) return null;
|
||||
const v = parseFloat(this.value);
|
||||
return this.negative ? -v : v;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a valid value has been entered
|
||||
*/
|
||||
get hasValue() {
|
||||
return this.value.length > 0 && this.value !== '.';
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a digit to the current value
|
||||
* @param {string} d - The digit to add (0-9)
|
||||
*/
|
||||
addDigit(d) {
|
||||
// Validate: don't exceed max digits
|
||||
const parts = this.value.split('.');
|
||||
|
||||
if (this.hasDecimal) {
|
||||
// Check decimal part length
|
||||
if (parts[1] && parts[1].length >= this.maxDecDigits) return;
|
||||
} else {
|
||||
// Check integer part length
|
||||
if (parts[0] && parts[0].length >= this.maxIntDigits) return;
|
||||
}
|
||||
|
||||
// Prevent leading zeros (except "0.")
|
||||
if (this.value === '0' && d !== '.') {
|
||||
this.value = d;
|
||||
return;
|
||||
}
|
||||
|
||||
this.value += d;
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a decimal point to the current value
|
||||
*/
|
||||
addDecimal() {
|
||||
// Don't add decimal if one already exists
|
||||
if (this.hasDecimal) return;
|
||||
|
||||
// If value is empty, start with "0."
|
||||
if (!this.value) this.value = '0';
|
||||
|
||||
this.value += '.';
|
||||
this.hasDecimal = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle the sign of the current value
|
||||
*/
|
||||
toggleSign() {
|
||||
if (!this.value) return;
|
||||
this.negative = !this.negative;
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove the last character from the current value
|
||||
*/
|
||||
backspace() {
|
||||
if (!this.value) return;
|
||||
|
||||
const removed = this.value.charAt(this.value.length - 1);
|
||||
|
||||
// If removing decimal point, update flag
|
||||
if (removed === '.') this.hasDecimal = false;
|
||||
|
||||
this.value = this.value.slice(0, -1);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all entered data
|
||||
*/
|
||||
clearAll() {
|
||||
this.value = '';
|
||||
this.negative = false;
|
||||
this.hasDecimal = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Confirm the current value and dispatch event
|
||||
*/
|
||||
confirm() {
|
||||
if (!this.hasValue) return;
|
||||
|
||||
const val = this.numericValue;
|
||||
|
||||
// Determine input method: 3+ fast keystrokes = USB caliper burst
|
||||
const inputMethod = this._burstCount >= 3 ? 'usb_caliper' : 'manual';
|
||||
|
||||
// Dispatch custom event for parent component to handle
|
||||
this.$dispatch('numpad-confirm', { value: val, inputMethod: inputMethod });
|
||||
|
||||
// Reset after confirmation
|
||||
this.clearAll();
|
||||
this._burstCount = 0;
|
||||
this._lastKeyTime = 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set a value programmatically (e.g., from USB caliper)
|
||||
* @param {number} val - The numeric value to set
|
||||
*/
|
||||
setValue(val) {
|
||||
this.clearAll();
|
||||
|
||||
// Handle negative values
|
||||
if (val < 0) {
|
||||
this.negative = true;
|
||||
val = Math.abs(val);
|
||||
}
|
||||
|
||||
this.value = val.toString();
|
||||
|
||||
// Update decimal flag if value contains decimal point
|
||||
if (this.value.includes('.')) this.hasDecimal = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the unit of measurement
|
||||
* @param {string} newUnit - The unit to set (e.g., 'mm', 'cm', 'in')
|
||||
*/
|
||||
setUnit(newUnit) {
|
||||
this.unit = newUnit;
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle keyboard input for physical keyboard support
|
||||
* @param {KeyboardEvent} e - The keyboard event
|
||||
*/
|
||||
handleKeydown(e) {
|
||||
// Number keys
|
||||
if (e.key >= '0' && e.key <= '9') {
|
||||
e.preventDefault();
|
||||
this._trackBurst();
|
||||
this.addDigit(e.key);
|
||||
}
|
||||
// Decimal point (both . and ,)
|
||||
else if (e.key === '.' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
this._trackBurst();
|
||||
this.addDecimal();
|
||||
}
|
||||
// Backspace
|
||||
else if (e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
this.backspace();
|
||||
}
|
||||
// Escape - clear all
|
||||
else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.clearAll();
|
||||
}
|
||||
// Enter - confirm
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.confirm();
|
||||
}
|
||||
// Minus sign - toggle sign
|
||||
else if (e.key === '-') {
|
||||
e.preventDefault();
|
||||
this.toggleSign();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Track keystroke timing for HID burst detection.
|
||||
* USB calipers send digits in rapid succession (<80ms apart).
|
||||
* Human typing is much slower (>100ms between keys).
|
||||
*/
|
||||
_trackBurst() {
|
||||
const now = performance.now();
|
||||
const gap = now - this._lastKeyTime;
|
||||
|
||||
if (this._lastKeyTime > 0 && gap < 80) {
|
||||
this._burstCount++;
|
||||
} else {
|
||||
this._burstCount = 1;
|
||||
}
|
||||
|
||||
this._lastKeyTime = now;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Login — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<!-- Card -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-xl p-8">
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-8">
|
||||
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}"
|
||||
alt="TieMeasureFlow Logo"
|
||||
class="mx-auto h-16 w-auto mb-4"
|
||||
onerror="this.style.display='none'">
|
||||
<h2 class="text-3xl font-bold text-slate-900 dark:text-white mb-2">
|
||||
TieMeasureFlow
|
||||
</h2>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
{{ _('Accedi al sistema') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<!-- Username -->
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ _('Username') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
required
|
||||
autofocus
|
||||
placeholder="{{ _('Username') }}"
|
||||
class="block w-full pl-10 pr-3 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ _('Password') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
placeholder="{{ _('Password') }}"
|
||||
class="block w-full pl-10 pr-3 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div>
|
||||
<button type="submit"
|
||||
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-semibold text-white bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200">
|
||||
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
{{ _('Accedi') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Help Text -->
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">
|
||||
{{ _('Hai dimenticato la password?') }}
|
||||
<br>
|
||||
{{ _('Contatta l\'amministratore') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center text-xs text-slate-500 dark:text-slate-400">
|
||||
<p>TieMeasureFlow © 2025 - {{ _('Sistema di misurazione industriale') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,307 +0,0 @@
|
||||
<!-- TieMeasureFlow Navbar -->
|
||||
<nav class="sticky top-0 z-40 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm transition-colors duration-300"
|
||||
x-data="{ mobileOpen: false, userDropdown: false }">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-14">
|
||||
|
||||
<!-- Left: Logo -->
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ url_for('index') }}" class="flex items-center gap-2.5 group">
|
||||
{% if company_logo %}
|
||||
<img src="{{ url_for('static', filename='img/' ~ company_logo) }}" alt="Logo" class="h-8 w-auto"
|
||||
onerror="this.src='{{ url_for('static', filename='img/tmflow-logo.svg') }}'">
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}"
|
||||
alt="TieMeasureFlow"
|
||||
class="h-8 w-auto">
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Center: Navigation Links (Desktop) -->
|
||||
{% if current_user %}
|
||||
<div class="hidden md:flex items-center gap-1">
|
||||
|
||||
{# MeasurementTec: Misure #}
|
||||
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
|
||||
<a href="{{ url_for('measure.select_recipe') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint and request.endpoint.startswith('measure.') %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Clipboard Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
<span>{{ _('Misure') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Maker: Ricette #}
|
||||
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
|
||||
<a href="{{ url_for('maker.recipe_list') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint and request.endpoint.startswith('maker.') %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Edit/Pencil Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
<span>{{ _('Ricette') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Metrologist: Statistiche #}
|
||||
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
|
||||
<a href="{{ url_for('statistics.dashboard') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint and request.endpoint.startswith('statistics.') %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Chart Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
<span>{{ _('Statistiche') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Admin: Utenti #}
|
||||
{% if current_user.get('is_admin') %}
|
||||
<a href="{{ url_for('admin.user_list') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint == 'admin.user_list' %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Users Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
|
||||
</svg>
|
||||
<span>{{ _('Utenti') }}</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.station_list') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint == 'admin.station_list' %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Station / Workstation Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
|
||||
</svg>
|
||||
<span>{{ _('Stazioni') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Right: Controls -->
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
<!-- Language Toggle -->
|
||||
<div class="flex items-center border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||
<a href="{{ url_for('set_language', lang='it') }}"
|
||||
class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200
|
||||
{% if current_language == 'it' %}
|
||||
bg-primary text-white
|
||||
{% else %}
|
||||
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
|
||||
{% endif %}">
|
||||
IT
|
||||
</a>
|
||||
<a href="{{ url_for('set_language', lang='en') }}"
|
||||
class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200
|
||||
{% if current_language == 'en' %}
|
||||
bg-primary text-white
|
||||
{% else %}
|
||||
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
|
||||
{% endif %}">
|
||||
EN
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<button @click="$store.theme.toggle()"
|
||||
class="p-2 rounded-lg text-[var(--text-secondary)] hover:text-primary
|
||||
hover:bg-[var(--bg-secondary)] transition-colors duration-200"
|
||||
:title="$store.theme.dark ? 'Tema chiaro' : 'Tema scuro'">
|
||||
<!-- Sun (shown in dark mode) -->
|
||||
<svg x-show="$store.theme.dark" x-cloak class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
|
||||
</svg>
|
||||
<!-- Moon (shown in light mode) -->
|
||||
<svg x-show="!$store.theme.dark" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- User Menu (when logged in) -->
|
||||
{% if current_user %}
|
||||
<div class="relative" x-data="{ open: false }" @click.outside="open = false">
|
||||
<button @click="open = !open"
|
||||
class="flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-lg
|
||||
hover:bg-[var(--bg-secondary)] transition-colors duration-200">
|
||||
<!-- Avatar -->
|
||||
<div class="w-7 h-7 rounded-full bg-primary/10 border border-primary/20
|
||||
flex items-center justify-center text-primary font-semibold text-xs">
|
||||
{{ current_user.get('display_name', current_user.get('username', '?'))[0]|upper }}
|
||||
</div>
|
||||
<span class="hidden sm:block text-sm font-medium text-[var(--text-primary)] max-w-[120px] truncate">
|
||||
{{ current_user.get('display_name', current_user.get('username', '')) }}
|
||||
</span>
|
||||
<svg class="w-3.5 h-3.5 text-[var(--text-secondary)] transition-transform duration-200"
|
||||
:class="{ 'rotate-180': open }"
|
||||
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- User Dropdown -->
|
||||
<div x-show="open"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 scale-95 -translate-y-1"
|
||||
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute right-0 mt-1 w-52 rounded-lg bg-[var(--bg-card)] border border-[var(--border-color)]
|
||||
shadow-lg py-1 z-50">
|
||||
<!-- User Info Header -->
|
||||
<div class="px-4 py-2.5 border-b border-[var(--border-color)]">
|
||||
<p class="text-sm font-semibold text-[var(--text-primary)] truncate">
|
||||
{{ current_user.get('display_name', current_user.get('username', '')) }}
|
||||
</p>
|
||||
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
|
||||
{{ current_user.get('roles', [])|join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('auth.profile') }}"
|
||||
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--text-secondary)]
|
||||
hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
</svg>
|
||||
{{ _('Profilo') }}
|
||||
</a>
|
||||
|
||||
<div class="border-t border-[var(--border-color)] my-1"></div>
|
||||
|
||||
<a href="{{ url_for('auth.logout') }}"
|
||||
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-measure-fail
|
||||
hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
{{ _('Logout') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Mobile Hamburger (when logged in) -->
|
||||
{% if current_user %}
|
||||
<button @click="mobileOpen = !mobileOpen"
|
||||
class="md:hidden p-2 rounded-lg text-[var(--text-secondary)]
|
||||
hover:bg-[var(--bg-secondary)] transition-colors duration-200">
|
||||
<svg x-show="!mobileOpen" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
</svg>
|
||||
<svg x-show="mobileOpen" x-cloak class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
{% if current_user %}
|
||||
<div x-show="mobileOpen"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
x-cloak
|
||||
class="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-card)]">
|
||||
<div class="px-4 py-3 space-y-1">
|
||||
|
||||
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
|
||||
<a href="{{ url_for('measure.select_recipe') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
{{ _('Misure') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
|
||||
<a href="{{ url_for('maker.recipe_list') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
{{ _('Ricette') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
|
||||
<a href="{{ url_for('statistics.dashboard') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
{{ _('Statistiche') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Admin: Utenti + Stazioni #}
|
||||
{% if current_user.get('is_admin') %}
|
||||
<a href="{{ url_for('admin.user_list') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
|
||||
</svg>
|
||||
{{ _('Utenti') }}
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.station_list') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
|
||||
</svg>
|
||||
{{ _('Stazioni') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
@@ -1,708 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ task.title or 'Task' }} — {{ _('Misure') }} — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
/* Annotation viewer container */
|
||||
.annotation-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
.annotation-container canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Tolerance bar gradient background */
|
||||
.tolerance-bar-bg {
|
||||
background: linear-gradient(to right,
|
||||
rgba(220, 38, 38, 0.15) 0%,
|
||||
rgba(217, 119, 6, 0.15) 15%,
|
||||
rgba(5, 150, 105, 0.15) 30%,
|
||||
rgba(5, 150, 105, 0.15) 70%,
|
||||
rgba(217, 119, 6, 0.15) 85%,
|
||||
rgba(220, 38, 38, 0.15) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Pulse animation for active marker */
|
||||
@keyframes markerPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.4); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(37, 99, 235, 0); }
|
||||
}
|
||||
.marker-active-pulse {
|
||||
animation: markerPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Left sidebar scrollbar */
|
||||
.sidebar-markers::-webkit-scrollbar { width: 3px; }
|
||||
.sidebar-markers::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
|
||||
|
||||
/* Right panel scrollbar */
|
||||
.right-panel::-webkit-scrollbar { width: 4px; }
|
||||
.right-panel::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 4px; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<script>
|
||||
window.__taskData = {{ task|tojson }};
|
||||
window.__taskSubtasks = {{ task.subtasks|tojson if task.subtasks else '[]' }};
|
||||
window.__taskAnnotations = {{ task.annotations_json|default('null')|tojson }};
|
||||
window.__allTaskIds = {{ all_task_ids|tojson }};
|
||||
</script>
|
||||
|
||||
<div class="h-screen flex flex-col overflow-hidden"
|
||||
x-data="taskExecute()"
|
||||
x-init="init()"
|
||||
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
|
||||
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)">
|
||||
|
||||
{# ================================================================
|
||||
HEADER — Compact task info bar
|
||||
================================================================ #}
|
||||
<div class="shrink-0 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm z-20">
|
||||
<div class="px-3 py-2 flex items-center gap-3">
|
||||
|
||||
{# Back button #}
|
||||
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
|
||||
class="shrink-0 p-1.5 rounded-lg hover:bg-[var(--bg-secondary)] transition-colors text-[var(--text-muted)] hover:text-[var(--text-primary)]">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
{# Task badge + title #}
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span class="shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-bold
|
||||
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
|
||||
border border-primary-200 dark:border-primary-800">
|
||||
Task {{ (task.order_index or 0) + 1 }}
|
||||
</span>
|
||||
<h1 class="text-sm font-bold text-[var(--text-primary)] truncate">
|
||||
{{ task.title or _('Task di misurazione') }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{# Lot + Serial badges #}
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
{% if lot_number %}
|
||||
<div class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs
|
||||
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
|
||||
text-amber-800 dark:text-amber-200">
|
||||
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||
</svg>
|
||||
<span class="font-mono font-semibold">{{ lot_number }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if serial_number %}
|
||||
<div class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs
|
||||
bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800
|
||||
text-indigo-800 dark:text-indigo-200">
|
||||
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||
</svg>
|
||||
<span class="font-mono font-semibold">{{ serial_number }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Caliper status #}
|
||||
{% include "components/caliper_status.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
MAIN — 3-column layout
|
||||
================================================================ #}
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
|
||||
{# ──────────────────────────────────────────────
|
||||
LEFT SIDEBAR — Marker list (vertical)
|
||||
────────────────────────────────────────────── #}
|
||||
<div class="shrink-0 w-14 md:w-16 bg-[var(--bg-card)] border-r border-[var(--border-color)] flex flex-col sidebar-markers overflow-y-auto">
|
||||
<template x-for="(st, idx) in subtasks" :key="st.id">
|
||||
<button @click="goToSubtask(idx)"
|
||||
class="relative flex flex-col items-center justify-center py-2.5 px-1 border-b border-[var(--border-color)] transition-all duration-200"
|
||||
:class="idx === currentIndex
|
||||
? 'bg-primary-50 dark:bg-primary-900/20 border-l-[3px] border-l-primary'
|
||||
: 'hover:bg-[var(--bg-secondary)] border-l-[3px] border-l-transparent'">
|
||||
|
||||
{# Marker circle #}
|
||||
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full text-xs font-bold transition-all"
|
||||
:class="idx === currentIndex
|
||||
? 'bg-primary text-white shadow-md marker-active-pulse'
|
||||
: getMeasurementStatus(st.id) === 'pass'
|
||||
? 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40'
|
||||
: getMeasurementStatus(st.id) === 'fail'
|
||||
? 'bg-red-100 dark:bg-red-900/30 text-measure-fail border border-measure-fail/40'
|
||||
: getMeasurementStatus(st.id) === 'warning'
|
||||
? 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40'
|
||||
: 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]'"
|
||||
x-text="st.marker_number"></span>
|
||||
|
||||
{# Status icon below circle #}
|
||||
<template x-if="getMeasurementStatus(st.id) === 'pass'">
|
||||
<svg class="w-3 h-3 mt-0.5 text-measure-pass" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="getMeasurementStatus(st.id) === 'fail'">
|
||||
<svg class="w-3 h-3 mt-0.5 text-measure-fail" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="getMeasurementStatus(st.id) === 'warning'">
|
||||
<svg class="w-3 h-3 mt-0.5 text-measure-warning" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
|
||||
</svg>
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# ──────────────────────────────────────────────
|
||||
CENTER — Image area
|
||||
────────────────────────────────────────────── #}
|
||||
<div class="flex-1 flex flex-col overflow-hidden bg-[var(--bg-secondary)]">
|
||||
|
||||
{# Main image area #}
|
||||
<div class="flex-1 overflow-hidden relative">
|
||||
|
||||
{# Option A: Subtask has its own image → plain <img> #}
|
||||
<div x-show="showSubtaskImage" class="absolute inset-0 flex items-center justify-center p-2">
|
||||
<img :src="'/measure/api/files/' + currentSubtaskImage"
|
||||
alt="{{ _('Immagine dettaglio misura') }}"
|
||||
class="max-w-full max-h-full object-contain rounded-lg shadow-sm">
|
||||
</div>
|
||||
|
||||
{# Option B: Task image with annotations → annotation-viewer (kept in DOM via x-show) #}
|
||||
<div x-show="showTaskImage" class="absolute inset-0">
|
||||
{% if task.file_path %}
|
||||
<div class="annotation-container"
|
||||
x-data="annotationViewer()"
|
||||
x-init="
|
||||
imageUrl = '/measure/api/files/{{ task.file_path }}';
|
||||
annotations = window.__taskAnnotations;
|
||||
$nextTick(() => init());
|
||||
"
|
||||
x-effect="setActiveMarker(currentSubtask?.marker_number || 0)">
|
||||
<canvas x-ref="annotationCanvas"
|
||||
@click="handleClick($event)"
|
||||
class="cursor-pointer"></canvas>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Option C: No image → placeholder #}
|
||||
<div x-show="!showSubtaskImage && !showTaskImage" class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<svg class="w-16 h-16 mx-auto text-[var(--text-muted)] mb-3 opacity-30" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<p class="text-sm font-medium text-[var(--text-muted)]">{{ _('Nessuna immagine allegata') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ──────────────────────────────────────────────
|
||||
RIGHT PANEL — Info + tolerances + numpad
|
||||
────────────────────────────────────────────── #}
|
||||
<div class="shrink-0 w-72 lg:w-80 bg-[var(--bg-card)] border-l border-[var(--border-color)] flex flex-col right-panel overflow-y-auto">
|
||||
|
||||
{# ---- Subtask header ---- #}
|
||||
<div class="p-3 border-b border-[var(--border-color)]" x-show="currentSubtask">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full
|
||||
bg-primary text-white font-bold text-sm shadow marker-active-pulse"
|
||||
x-text="currentSubtask?.marker_number"></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="font-semibold text-[var(--text-primary)] text-sm leading-tight truncate"
|
||||
x-text="currentSubtask?.description || '{{ _('Misurazione') }}'"></h2>
|
||||
<span class="text-[11px] text-[var(--text-muted)]"
|
||||
x-text="(currentSubtask?.measurement_type || '{{ _('Misura') }}') + ' (' + (currentSubtask?.unit || 'mm') + ')'"></span>
|
||||
</div>
|
||||
<span class="shrink-0 text-[11px] font-mono text-[var(--text-muted)] bg-[var(--bg-secondary)] px-1.5 py-0.5 rounded">
|
||||
<span x-text="currentIndex + 1"></span>/<span x-text="totalSubtasks"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---- Tolerance parameters (compact grid) ---- #}
|
||||
<div class="px-3 py-2 border-b border-[var(--border-color)]" x-show="currentSubtask">
|
||||
{# Nominal row #}
|
||||
<div class="flex items-center justify-between py-1 px-2 rounded bg-primary-50 dark:bg-primary-900/20 border border-primary-100 dark:border-primary-800 mb-1.5">
|
||||
<span class="text-[10px] font-medium text-primary-700 dark:text-primary-300 uppercase tracking-wider">{{ _('Nominale') }}</span>
|
||||
<span class="font-mono font-bold text-primary text-sm"
|
||||
x-text="currentSubtask?.nominal?.toFixed(3) || '—'"></span>
|
||||
</div>
|
||||
|
||||
{# 2x2 tolerance grid #}
|
||||
<div class="grid grid-cols-2 gap-1 text-[11px]">
|
||||
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-red-50/50 dark:bg-red-900/10">
|
||||
<span class="text-measure-fail font-medium">UTL</span>
|
||||
<span class="font-mono font-semibold text-measure-fail" x-text="currentSubtask?.utl?.toFixed(3) || '—'"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-red-50/50 dark:bg-red-900/10">
|
||||
<span class="text-measure-fail font-medium">LTL</span>
|
||||
<span class="font-mono font-semibold text-measure-fail" x-text="currentSubtask?.ltl?.toFixed(3) || '—'"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-amber-50/50 dark:bg-amber-900/10">
|
||||
<span class="text-measure-warning font-medium">UWL</span>
|
||||
<span class="font-mono font-semibold text-measure-warning" x-text="currentSubtask?.uwl?.toFixed(3) || '—'"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-amber-50/50 dark:bg-amber-900/10">
|
||||
<span class="text-measure-warning font-medium">LWL</span>
|
||||
<span class="font-mono font-semibold text-measure-warning" x-text="currentSubtask?.lwl?.toFixed(3) || '—'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---- Tolerance bar (compact) ---- #}
|
||||
<div class="px-3 py-2 border-b border-[var(--border-color)]" x-show="currentSubtask">
|
||||
<div class="tolerance-bar-bg h-5 rounded-full relative overflow-hidden border border-[var(--border-color)]">
|
||||
{# Zone labels #}
|
||||
<div class="absolute inset-0 flex items-center justify-between px-1.5 text-[8px] font-mono text-[var(--text-muted)]">
|
||||
<span x-text="currentSubtask?.ltl?.toFixed(2)"></span>
|
||||
<span x-text="currentSubtask?.nominal?.toFixed(2)"></span>
|
||||
<span x-text="currentSubtask?.utl?.toFixed(2)"></span>
|
||||
</div>
|
||||
{# Center line #}
|
||||
<div class="absolute top-0 bottom-0 w-px bg-[var(--text-muted)]/30" style="left: 50%;"></div>
|
||||
{# Value needle #}
|
||||
<div x-show="currentValue !== null"
|
||||
x-transition
|
||||
class="absolute top-0 bottom-0 w-1 rounded-full transition-all duration-300"
|
||||
:class="{
|
||||
'bg-measure-pass': passFailStatus === 'pass',
|
||||
'bg-measure-warning': passFailStatus === 'warning',
|
||||
'bg-measure-fail': passFailStatus === 'fail'
|
||||
}"
|
||||
:style="'left: calc(' + progressWidth + '% - 2px)'">
|
||||
<div class="absolute -top-0.5 left-1/2 -translate-x-1/2 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-800 shadow"
|
||||
:class="{
|
||||
'bg-measure-pass': passFailStatus === 'pass',
|
||||
'bg-measure-warning': passFailStatus === 'warning',
|
||||
'bg-measure-fail': passFailStatus === 'fail'
|
||||
}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---- Already measured indicator ---- #}
|
||||
<div x-show="isMeasured(currentSubtask?.id)"
|
||||
class="mx-3 mt-2 p-2 rounded-lg bg-measure-pass/10 border border-measure-pass/20">
|
||||
<div class="flex items-center gap-1.5 text-xs text-measure-pass font-medium">
|
||||
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<span>{{ _('Registrata') }}:</span>
|
||||
<span class="font-mono font-bold" x-text="getMeasuredValue(currentSubtask?.id)?.toFixed(3) || '—'"></span>
|
||||
<span x-text="currentSubtask?.unit || 'mm'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---- Measurement feedback ---- #}
|
||||
<div class="px-3 pt-2" x-data="{ get nominal() { return currentSubtask?.nominal || 0; },
|
||||
get utl() { return currentSubtask?.utl || 0; },
|
||||
get uwl() { return currentSubtask?.uwl || 0; },
|
||||
get lwl() { return currentSubtask?.lwl || 0; },
|
||||
get ltl() { return currentSubtask?.ltl || 0; },
|
||||
get unit() { return currentSubtask?.unit || 'mm'; } }">
|
||||
{% include "components/measurement_feedback.html" %}
|
||||
</div>
|
||||
|
||||
{# ---- Numpad ---- #}
|
||||
<div class="px-3 py-2 relative flex-1">
|
||||
{# Saving overlay #}
|
||||
<div x-show="saving"
|
||||
x-transition
|
||||
class="absolute inset-0 z-10 bg-white/70 dark:bg-slate-900/70 flex items-center justify-center backdrop-blur-sm rounded">
|
||||
<div class="flex items-center gap-2 text-primary font-medium text-sm">
|
||||
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{{ _('Salvataggio...') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "components/numpad.html" %}
|
||||
</div>
|
||||
|
||||
{# ---- Next measurement indicator ---- #}
|
||||
<div class="px-3 pb-2">
|
||||
{% include "components/next_measurement.html" %}
|
||||
</div>
|
||||
|
||||
{# ---- Error message ---- #}
|
||||
<div x-show="errorMessage"
|
||||
x-transition
|
||||
x-cloak
|
||||
class="mx-3 mb-2 p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-xs text-measure-fail">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span x-text="errorMessage"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
FOOTER — Progress bar + navigation
|
||||
================================================================ #}
|
||||
<div class="shrink-0 bg-[var(--bg-card)] border-t border-[var(--border-color)] shadow-[0_-2px_10px_rgba(0,0,0,0.05)] z-20">
|
||||
<div class="px-3 py-2 flex items-center gap-3">
|
||||
|
||||
{# Left: Back #}
|
||||
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
|
||||
class="btn btn-secondary text-xs shrink-0 gap-1 py-1.5 px-2.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{{ _('Task') }}</span>
|
||||
</a>
|
||||
|
||||
{# Center: Progress bar #}
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<span class="text-xs font-medium text-[var(--text-secondary)] shrink-0 font-mono">
|
||||
<span x-text="completedCount"></span>/<span x-text="totalSubtasks"></span>
|
||||
</span>
|
||||
<div class="flex-1 h-2 rounded-full bg-[var(--bg-secondary)] border border-[var(--border-color)] overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-500 ease-out"
|
||||
:class="isComplete ? 'bg-measure-pass' : 'bg-primary'"
|
||||
:style="'width: ' + progressPercent + '%'"></div>
|
||||
</div>
|
||||
<span class="text-xs font-bold shrink-0"
|
||||
:class="isComplete ? 'text-measure-pass' : 'text-primary'"
|
||||
x-text="Math.round(progressPercent) + '%'"></span>
|
||||
</div>
|
||||
|
||||
{# Right: Summary button #}
|
||||
<button x-show="isComplete"
|
||||
x-transition
|
||||
@click="goToSummary()"
|
||||
class="btn btn-primary text-xs shrink-0 gap-1 py-1.5 px-2.5 shadow-md">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ _('Riepilogo') }}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
COMPLETION OVERLAY
|
||||
================================================================ #}
|
||||
<div x-show="showCompletionOverlay"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-8 max-w-sm mx-4 text-center"
|
||||
x-transition:enter="transition ease-out duration-300 delay-100"
|
||||
x-transition:enter-start="opacity-0 scale-90"
|
||||
x-transition:enter-end="opacity-100 scale-100">
|
||||
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-measure-pass/10 mb-4">
|
||||
<svg class="w-8 h-8 text-measure-pass" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-bold text-[var(--text-primary)] mb-1">{{ _('Misurazioni Complete') }}</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-2">
|
||||
{{ _('Tutte le') }} <span class="font-bold font-mono" x-text="totalSubtasks"></span> {{ _('misurazioni sono state registrate.') }}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center gap-4 mb-5">
|
||||
<div class="text-center">
|
||||
<div class="text-xl font-bold font-mono text-measure-pass" x-text="passCount"></div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Conformi') }}</div>
|
||||
</div>
|
||||
<div class="text-center" x-show="warningCount > 0">
|
||||
<div class="text-xl font-bold font-mono text-measure-warning" x-text="warningCount"></div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Attenzione') }}</div>
|
||||
</div>
|
||||
<div class="text-center" x-show="failCount > 0">
|
||||
<div class="text-xl font-bold font-mono text-measure-fail" x-text="failCount"></div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Non Conf.') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="goToSummary()"
|
||||
class="btn btn-primary w-full justify-center gap-2">
|
||||
{{ _('Vai al Riepilogo') }}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* Task Execute - Main Alpine.js component
|
||||
* Manages measurement workflow state, save logic, and navigation.
|
||||
* Layout: 3-column (marker sidebar | image center | info+numpad right)
|
||||
*/
|
||||
function taskExecute() {
|
||||
return {
|
||||
// ---- Data from server ----
|
||||
task: window.__taskData,
|
||||
subtasks: window.__taskSubtasks,
|
||||
lotNumber: '{{ lot_number or '' }}',
|
||||
serialNumber: '{{ serial_number or '' }}',
|
||||
// ---- Measurement state ----
|
||||
currentIndex: 0,
|
||||
measurements: [], // [{subtask_id, value, pass_fail, deviation}, ...]
|
||||
saving: false,
|
||||
errorMessage: '',
|
||||
showCompletionOverlay: false,
|
||||
|
||||
// ---- Value from numpad / caliper ----
|
||||
currentValue: null,
|
||||
|
||||
// ---- Image switching logic ----
|
||||
get currentSubtaskImage() {
|
||||
return this.currentSubtask?.image_path || null;
|
||||
},
|
||||
get showSubtaskImage() {
|
||||
return !!this.currentSubtaskImage;
|
||||
},
|
||||
get showTaskImage() {
|
||||
return !this.currentSubtaskImage && !!this.task.file_path;
|
||||
},
|
||||
|
||||
// ---- Computed properties ----
|
||||
get currentSubtask() {
|
||||
return this.subtasks[this.currentIndex] || null;
|
||||
},
|
||||
|
||||
get nextSubtask() {
|
||||
return this.subtasks[this.currentIndex + 1] || null;
|
||||
},
|
||||
|
||||
get totalSubtasks() {
|
||||
return this.subtasks.length;
|
||||
},
|
||||
|
||||
get completedCount() {
|
||||
return this.measurements.length;
|
||||
},
|
||||
|
||||
get isComplete() {
|
||||
return this.completedCount >= this.totalSubtasks;
|
||||
},
|
||||
|
||||
get progressPercent() {
|
||||
return this.totalSubtasks > 0
|
||||
? (this.completedCount / this.totalSubtasks * 100)
|
||||
: 0;
|
||||
},
|
||||
|
||||
// ---- Pass/fail logic ----
|
||||
get passFailStatus() {
|
||||
if (this.currentValue === null || !this.currentSubtask) return null;
|
||||
const v = this.currentValue;
|
||||
const s = this.currentSubtask;
|
||||
if (s.utl != null && v > s.utl) return 'fail';
|
||||
if (s.ltl != null && v < s.ltl) return 'fail';
|
||||
if (s.uwl != null && v > s.uwl) return 'warning';
|
||||
if (s.lwl != null && v < s.lwl) return 'warning';
|
||||
return 'pass';
|
||||
},
|
||||
|
||||
get deviation() {
|
||||
if (this.currentValue === null || !this.currentSubtask) return null;
|
||||
return this.currentValue - (this.currentSubtask.nominal || 0);
|
||||
},
|
||||
|
||||
get progressWidth() {
|
||||
if (!this.currentSubtask || this.currentValue === null) return 50;
|
||||
const s = this.currentSubtask;
|
||||
if (s.utl == null || s.ltl == null) return 50;
|
||||
const range = s.utl - s.ltl;
|
||||
if (range <= 0) return 50;
|
||||
const pos = (this.currentValue - s.ltl) / range * 100;
|
||||
return Math.max(0, Math.min(100, pos));
|
||||
},
|
||||
|
||||
// ---- Summary counts ----
|
||||
get passCount() {
|
||||
return this.measurements.filter(m => m.pass_fail === 'pass').length;
|
||||
},
|
||||
get warningCount() {
|
||||
return this.measurements.filter(m => m.pass_fail === 'warning').length;
|
||||
},
|
||||
get failCount() {
|
||||
return this.measurements.filter(m => m.pass_fail === 'fail').length;
|
||||
},
|
||||
|
||||
// ---- Init ----
|
||||
init() {
|
||||
this.subtasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
||||
},
|
||||
|
||||
// ---- Check if a subtask has been measured ----
|
||||
isMeasured(subtaskId) {
|
||||
return this.measurements.some(m => m.subtask_id === subtaskId);
|
||||
},
|
||||
|
||||
getMeasuredValue(subtaskId) {
|
||||
const m = this.measurements.find(m => m.subtask_id === subtaskId);
|
||||
return m ? m.value : null;
|
||||
},
|
||||
|
||||
getMeasurementStatus(subtaskId) {
|
||||
const m = this.measurements.find(m => m.subtask_id === subtaskId);
|
||||
return m ? m.pass_fail : null;
|
||||
},
|
||||
|
||||
// ---- Handle numpad confirm ----
|
||||
async handleMeasurement(value, inputMethod) {
|
||||
if (!this.currentSubtask || this.saving) return;
|
||||
|
||||
this.currentValue = value;
|
||||
this.errorMessage = '';
|
||||
|
||||
const pf = this.passFailStatus;
|
||||
const dev = this.deviation;
|
||||
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||
|
||||
const response = await fetch('{{ url_for("measure.save_measurement") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subtask_id: this.currentSubtask.id,
|
||||
version_id: this.task.version_id,
|
||||
value: value,
|
||||
lot_number: this.lotNumber,
|
||||
serial_number: this.serialNumber,
|
||||
input_method: inputMethod || 'manual',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || result.error) {
|
||||
this.errorMessage = result.detail || '{{ _("Errore nel salvataggio della misurazione") }}';
|
||||
this.saving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Record measurement locally
|
||||
const existingIdx = this.measurements.findIndex(m => m.subtask_id === this.currentSubtask.id);
|
||||
const mEntry = { subtask_id: this.currentSubtask.id, value, pass_fail: pf, deviation: dev };
|
||||
if (existingIdx !== -1) {
|
||||
this.measurements.splice(existingIdx, 1, mEntry);
|
||||
} else {
|
||||
this.measurements.push(mEntry);
|
||||
}
|
||||
|
||||
this.saving = false;
|
||||
|
||||
// Pause to show result feedback
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Check if all done
|
||||
if (this.completedCount >= this.totalSubtasks) {
|
||||
const taskIds = window.__allTaskIds || [];
|
||||
const currentIdx = taskIds.indexOf(this.task.id);
|
||||
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
|
||||
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]);
|
||||
} else {
|
||||
this.showCompletionOverlay = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.advanceToNext();
|
||||
|
||||
} catch (err) {
|
||||
console.error('Save measurement error:', err);
|
||||
this.errorMessage = '{{ _("Errore di rete. Riprovare.") }}';
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Advance to next unmeasured subtask ----
|
||||
advanceToNext() {
|
||||
this.currentValue = null;
|
||||
|
||||
for (let i = this.currentIndex + 1; i < this.totalSubtasks; i++) {
|
||||
if (!this.isMeasured(this.subtasks[i].id)) {
|
||||
this.currentIndex = i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < this.currentIndex; i++) {
|
||||
if (!this.isMeasured(this.subtasks[i].id)) {
|
||||
this.currentIndex = i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Navigation ----
|
||||
goToSubtask(index) {
|
||||
if (index >= 0 && index < this.totalSubtasks) {
|
||||
this.currentIndex = index;
|
||||
this.currentValue = null;
|
||||
this.errorMessage = '';
|
||||
}
|
||||
},
|
||||
|
||||
goToSubtaskByMarker(markerNumber) {
|
||||
const idx = this.subtasks.findIndex(s => s.marker_number === markerNumber);
|
||||
if (idx !== -1) {
|
||||
this.goToSubtask(idx);
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Go to summary ----
|
||||
goToSummary() {
|
||||
const recipeId = this.task.recipe_id || 0;
|
||||
window.location.href = '{{ url_for("measure.task_complete", recipe_id=0) }}'.replace('/0', '/' + recipeId) +
|
||||
'?version_id=' + encodeURIComponent(this.task.version_id || '');
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,247 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ recipe.name }} — {{ _('Task') }} — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-5xl">
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-6" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||
<li>
|
||||
<a href="{{ url_for('measure.select_recipe') }}"
|
||||
class="hover:text-primary transition-colors inline-flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
{{ _('Misure') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</li>
|
||||
<li class="font-medium text-[var(--text-primary)]">
|
||||
{{ recipe.name }}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Recipe Info Card -->
|
||||
<div class="tmf-card mb-8">
|
||||
<div class="p-5 sm:p-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<!-- Left: Recipe Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 mb-3 flex-wrap">
|
||||
<!-- Code Badge -->
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-md text-sm font-semibold
|
||||
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
|
||||
border border-primary-200 dark:border-primary-800 font-mono tracking-wide">
|
||||
{{ recipe.code }}
|
||||
</span>
|
||||
|
||||
<!-- Version Badge -->
|
||||
{% if recipe.current_version or recipe.version %}
|
||||
<span class="badge badge-neutral">
|
||||
v{{ recipe.current_version.version_number if recipe.current_version else recipe.version }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)] mb-2">
|
||||
{{ recipe.name }}
|
||||
</h1>
|
||||
|
||||
{% if recipe.description %}
|
||||
<p class="text-sm text-[var(--text-secondary)] leading-relaxed max-w-2xl">
|
||||
{{ recipe.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right: Traceability Badges -->
|
||||
<div class="flex flex-col gap-2 sm:items-end shrink-0">
|
||||
{% if lot_number %}
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
||||
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
|
||||
text-amber-800 dark:text-amber-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||
</svg>
|
||||
<span class="font-medium">{{ _('Lotto') }}:</span>
|
||||
<span class="font-mono font-semibold">{{ lot_number }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if serial_number %}
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
||||
bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800
|
||||
text-indigo-800 dark:text-indigo-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||
</svg>
|
||||
<span class="font-medium">{{ _('Seriale') }}:</span>
|
||||
<span class="font-mono font-semibold">{{ serial_number }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Count Header -->
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--text-secondary)]" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||
</svg>
|
||||
{{ _('Task da eseguire') }}
|
||||
</h2>
|
||||
<span class="badge badge-neutral">
|
||||
{{ tasks|length }} task
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Task Cards -->
|
||||
{% if tasks %}
|
||||
<div class="space-y-4">
|
||||
{% for task in tasks %}
|
||||
<div class="tmf-card hover:border-primary/30 transition-all duration-200 group">
|
||||
<div class="p-5 sm:p-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
|
||||
<!-- Task Number Circle -->
|
||||
<div class="flex items-center justify-center w-12 h-12 rounded-full shrink-0
|
||||
bg-primary-50 dark:bg-primary-900/30 border-2 border-primary-200 dark:border-primary-700
|
||||
text-primary-700 dark:text-primary-300 font-bold text-lg">
|
||||
{{ loop.index }}
|
||||
</div>
|
||||
|
||||
<!-- Task Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Task Header -->
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">
|
||||
Task {{ loop.index }} {{ _('di') }} {{ tasks|length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Task Title -->
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-1">
|
||||
{{ task.title or task.name or (_('Task') ~ ' ' ~ loop.index) }}
|
||||
</h3>
|
||||
|
||||
<!-- Directive -->
|
||||
{% if task.directive or task.description %}
|
||||
<p class="text-sm text-[var(--text-secondary)] leading-relaxed mb-3 line-clamp-2">
|
||||
{{ task.directive or task.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Meta Row -->
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<!-- Subtask Count -->
|
||||
{% if task.subtask_count is defined or task.subtasks %}
|
||||
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<span class="font-medium">
|
||||
{% if task.subtask_count is defined %}
|
||||
{{ task.subtask_count }}
|
||||
{% elif task.subtasks %}
|
||||
{{ task.subtasks|length }}
|
||||
{% endif %}
|
||||
{{ _('misurazioni') }}
|
||||
</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- File Attachment -->
|
||||
{% if task.file_path %}
|
||||
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||
{% if task.file_path.endswith('.pdf') %}
|
||||
<svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
<span class="font-medium">{{ _('Allegato') }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Button -->
|
||||
<div class="shrink-0 sm:ml-4">
|
||||
<a href="{{ url_for('measure.task_execute', task_id=task.id) }}"
|
||||
class="btn btn-primary gap-2 w-full sm:w-auto justify-center
|
||||
group-hover:shadow-md transition-shadow duration-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ _('Inizia Misure') }}
|
||||
<svg class="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="text-center py-16">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full
|
||||
bg-[var(--bg-secondary)] mb-4">
|
||||
<svg class="w-8 h-8 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-1">
|
||||
{{ _('Nessun task disponibile') }}
|
||||
</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] max-w-md mx-auto">
|
||||
{{ _('Questa ricetta non ha ancora task definiti.') }}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Bottom Navigation -->
|
||||
<div class="mt-8 flex items-center justify-between">
|
||||
<a href="{{ url_for('measure.select_recipe') }}"
|
||||
class="btn btn-secondary gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
{{ _('Seleziona altra ricetta') }}
|
||||
</a>
|
||||
|
||||
{% if tasks %}
|
||||
<div class="text-sm text-[var(--text-secondary)]">
|
||||
{{ tasks|length }} task ·
|
||||
{% set total_subs = namespace(count=0) %}
|
||||
{% for t in tasks %}
|
||||
{% if t.subtask_count is defined %}
|
||||
{% set total_subs.count = total_subs.count + t.subtask_count %}
|
||||
{% elif t.subtasks %}
|
||||
{% set total_subs.count = total_subs.count + t.subtasks|length %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if total_subs.count > 0 %}
|
||||
{{ total_subs.count }} {{ _('misurazioni totali') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,134 +0,0 @@
|
||||
"""Tests for the admin Stations management UI and proxy endpoints."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_admin_api():
|
||||
"""Patch api_client used by the admin blueprint."""
|
||||
mock = MagicMock()
|
||||
with patch("blueprints.admin.api_client", mock):
|
||||
yield mock
|
||||
|
||||
|
||||
def test_station_list_page_requires_admin(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.get.return_value = []
|
||||
resp = logged_in_client.get("/admin/stations")
|
||||
assert resp.status_code == 200
|
||||
assert b"Gestione Stazioni" in resp.data or b"stations" in resp.data.lower()
|
||||
|
||||
|
||||
def test_station_list_page_calls_correct_endpoints(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, "created_by": 1, "created_at": "2026-04-25T10:00:00"}],
|
||||
[{"id": 1, "code": "REC-001", "name": "Test Recipe", "active": True}],
|
||||
]
|
||||
resp = logged_in_client.get("/admin/stations")
|
||||
assert resp.status_code == 200
|
||||
calls = [c.args[0] for c in mock_admin_api.get.call_args_list]
|
||||
assert "/api/stations" in calls
|
||||
assert "/api/recipes" in calls
|
||||
|
||||
|
||||
def test_create_station_proxy(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.post.return_value = {
|
||||
"id": 5, "code": "ST-005", "name": "Nuova",
|
||||
"location": None, "notes": None, "active": True,
|
||||
"created_by": 1, "created_at": "2026-04-25T10:00:00",
|
||||
}
|
||||
resp = logged_in_client.post(
|
||||
"/admin/api/stations",
|
||||
json={"code": "ST-005", "name": "Nuova", "active": True},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.get_json()
|
||||
assert body["code"] == "ST-005"
|
||||
mock_admin_api.post.assert_called_once_with(
|
||||
"/api/stations",
|
||||
data={"code": "ST-005", "name": "Nuova", "active": True},
|
||||
)
|
||||
|
||||
|
||||
def test_create_station_propagates_error(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.post.return_value = {
|
||||
"error": True, "status_code": 409, "detail": "code already exists",
|
||||
}
|
||||
resp = logged_in_client.post(
|
||||
"/admin/api/stations", json={"code": "ST-001", "name": "x"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert resp.get_json()["detail"] == "code already exists"
|
||||
|
||||
|
||||
def test_update_station_proxy(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.put.return_value = {
|
||||
"id": 3, "code": "ST-003", "name": "Aggiornata",
|
||||
"location": "Reparto B", "notes": None, "active": False,
|
||||
"created_by": 1, "created_at": "2026-04-25T10:00:00",
|
||||
}
|
||||
resp = logged_in_client.put(
|
||||
"/admin/api/stations/3",
|
||||
json={"name": "Aggiornata", "location": "Reparto B", "active": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
mock_admin_api.put.assert_called_once_with(
|
||||
"/api/stations/3",
|
||||
data={"name": "Aggiornata", "location": "Reparto B", "active": False},
|
||||
)
|
||||
|
||||
|
||||
def test_delete_station_proxy(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.delete.return_value = {}
|
||||
resp = logged_in_client.delete("/admin/api/stations/7")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"deleted": True}
|
||||
mock_admin_api.delete.assert_called_once_with("/api/stations/7")
|
||||
|
||||
|
||||
def test_assign_recipe_proxy(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.post.return_value = {
|
||||
"id": 1, "station_id": 2, "recipe_id": 10,
|
||||
"assigned_by": 1, "assigned_at": "2026-04-25T10:00:00",
|
||||
}
|
||||
resp = logged_in_client.post(
|
||||
"/admin/api/stations/2/recipes", json={"recipe_id": 10},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
mock_admin_api.post.assert_called_once_with(
|
||||
"/api/stations/2/recipes", data={"recipe_id": 10},
|
||||
)
|
||||
|
||||
|
||||
def test_unassign_recipe_proxy(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.delete.return_value = {}
|
||||
resp = logged_in_client.delete("/admin/api/stations/2/recipes/10")
|
||||
assert resp.status_code == 200
|
||||
mock_admin_api.delete.assert_called_once_with("/api/stations/2/recipes/10")
|
||||
|
||||
|
||||
def test_list_station_recipes_proxy(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.get.return_value = [
|
||||
{"id": 1, "code": "REC-001", "name": "R1", "active": True},
|
||||
]
|
||||
resp = logged_in_client.get("/admin/api/stations/2/recipes")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == [{"id": 1, "code": "REC-001", "name": "R1", "active": True}]
|
||||
mock_admin_api.get.assert_called_once_with("/api/stations/2/recipes")
|
||||
|
||||
|
||||
def test_non_admin_cannot_access(client, mock_admin_api):
|
||||
"""Non-admin user gets redirected away from station management."""
|
||||
with client.session_transaction() as sess:
|
||||
sess["api_key"] = "test-key"
|
||||
sess["user"] = {
|
||||
"id": 2,
|
||||
"username": "operator",
|
||||
"roles": ["MeasurementTec"],
|
||||
"is_admin": False,
|
||||
"active": True,
|
||||
}
|
||||
sess["user_id"] = 2
|
||||
resp = client.get("/admin/stations", follow_redirects=False)
|
||||
assert resp.status_code in (301, 302)
|
||||
@@ -1,89 +0,0 @@
|
||||
"""Tests for maker blueprint (client/blueprints/maker.py).
|
||||
|
||||
Covers recipe list, recipe creation page, role enforcement, and recipe edit.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
class TestRecipeList:
|
||||
"""GET /maker/recipes tests."""
|
||||
|
||||
def test_recipe_list_renders(self, logged_in_client, mock_api_client):
|
||||
"""Recipe list page renders for Maker role."""
|
||||
mock_api_client.get.return_value = {
|
||||
"items": [
|
||||
{"id": 1, "code": "REC-001", "name": "Recipe A"},
|
||||
],
|
||||
"total": 1,
|
||||
"pages": 1,
|
||||
}
|
||||
|
||||
resp = logged_in_client.get("/maker/recipes")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_recipe_list_requires_login(self, client):
|
||||
"""Unauthenticated user is redirected to login."""
|
||||
resp = client.get("/maker/recipes", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert "/auth/login" in resp.headers["Location"]
|
||||
|
||||
|
||||
class TestRecipeNew:
|
||||
"""GET /maker/recipes/new tests."""
|
||||
|
||||
def test_recipe_new_renders(self, logged_in_client, mock_api_client):
|
||||
"""Recipe creation page renders for Maker role."""
|
||||
resp = logged_in_client.get("/maker/recipes/new")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestRoleRequired:
|
||||
"""Maker role enforcement tests."""
|
||||
|
||||
def test_requires_maker_role(self, flask_app):
|
||||
"""User without Maker role gets 403 on maker endpoints."""
|
||||
with flask_app.test_client() as c:
|
||||
with c.session_transaction() as sess:
|
||||
sess["api_key"] = "test-key"
|
||||
sess["user"] = {
|
||||
"id": 2,
|
||||
"username": "tec_only",
|
||||
"display_name": "Tec Only",
|
||||
"roles": ["MeasurementTec"],
|
||||
"is_admin": False,
|
||||
"language_pref": "en",
|
||||
"theme_pref": "light",
|
||||
"active": True,
|
||||
}
|
||||
sess["user_id"] = 2
|
||||
sess["language"] = "en"
|
||||
sess["theme"] = "light"
|
||||
|
||||
resp = c.get("/maker/recipes")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestRecipeEdit:
|
||||
"""GET /maker/recipes/<id>/edit tests."""
|
||||
|
||||
def test_recipe_edit_renders(self, logged_in_client, mock_api_client):
|
||||
"""Recipe edit page renders with recipe data."""
|
||||
# First call: recipe detail, second call: versions list
|
||||
mock_api_client.get.side_effect = [
|
||||
{"id": 1, "code": "REC-001", "name": "Recipe A"},
|
||||
[{"version_number": 1, "is_current": True}],
|
||||
]
|
||||
|
||||
resp = logged_in_client.get("/maker/recipes/1/edit")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_recipe_edit_not_found(self, logged_in_client, mock_api_client):
|
||||
"""Recipe edit with API error redirects to recipe list."""
|
||||
mock_api_client.get.return_value = {
|
||||
"error": True,
|
||||
"detail": "Not found",
|
||||
}
|
||||
|
||||
resp = logged_in_client.get("/maker/recipes/999/edit", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert "/maker/recipes" in resp.headers["Location"]
|
||||
@@ -1,94 +0,0 @@
|
||||
"""Tests for measure blueprint (client/blueprints/measure.py).
|
||||
|
||||
Covers recipe selection, task list, login requirement, and measurement submission.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
class TestSelectRecipe:
|
||||
"""GET /measure/select tests."""
|
||||
|
||||
def test_select_recipe_renders(self, logged_in_client, mock_api_client, monkeypatch):
|
||||
"""Recipe selection page renders for MeasurementTec role."""
|
||||
monkeypatch.setenv("STATION_CODE", "ST-TEST")
|
||||
import config
|
||||
import importlib
|
||||
importlib.reload(config)
|
||||
import blueprints.measure
|
||||
importlib.reload(blueprints.measure)
|
||||
|
||||
mock_api_client.get_station_recipes.return_value = [
|
||||
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
||||
]
|
||||
|
||||
resp = logged_in_client.get("/measure/select")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_select_recipe_requires_login(self, client):
|
||||
"""Unauthenticated user is redirected to login."""
|
||||
resp = client.get("/measure/select", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert "/auth/login" in resp.headers["Location"]
|
||||
|
||||
|
||||
class TestTaskList:
|
||||
"""GET /measure/tasks/<recipe_id> tests."""
|
||||
|
||||
def test_task_list_renders(self, logged_in_client, mock_api_client):
|
||||
"""Task list page renders with recipe and task data."""
|
||||
# First call: recipe details (dict), second call: tasks list.
|
||||
# The route calls tasks_resp.get("error") so the mock must return
|
||||
# a dict (not a bare list) to avoid AttributeError.
|
||||
mock_api_client.get.side_effect = [
|
||||
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
||||
{
|
||||
"items": [
|
||||
{"id": 1, "title": "Task 1", "order_index": 0},
|
||||
{"id": 2, "title": "Task 2", "order_index": 1},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
resp = logged_in_client.get("/measure/tasks/1")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestSaveMeasurement:
|
||||
"""POST /measure/save-measurement tests."""
|
||||
|
||||
def test_save_measurement_proxy(self, logged_in_client, mock_api_client):
|
||||
"""Save measurement forwards data to API and returns 201."""
|
||||
mock_api_client.post.return_value = {
|
||||
"id": 1,
|
||||
"subtask_id": 10,
|
||||
"task_id": 5,
|
||||
"value": 9.95,
|
||||
"pass_fail": "pass",
|
||||
}
|
||||
|
||||
resp = logged_in_client.post(
|
||||
"/measure/save-measurement",
|
||||
json={
|
||||
"subtask_id": 10,
|
||||
"task_id": 5,
|
||||
"value": 9.95,
|
||||
"pass_fail": "pass",
|
||||
"deviation": 0.05,
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.get_json()
|
||||
assert data["id"] == 1
|
||||
assert data["pass_fail"] == "pass"
|
||||
|
||||
def test_save_measurement_missing_fields(self, logged_in_client, mock_api_client):
|
||||
"""Missing required fields return 400."""
|
||||
resp = logged_in_client.post(
|
||||
"/measure/save-measurement",
|
||||
json={"subtask_id": 10}, # missing task_id and value
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
data = resp.get_json()
|
||||
assert data["error"] is True
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Verify that /measure/select reads STATION_CODE and filters recipes via the server."""
|
||||
import importlib
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
def _reload_measure(monkeypatch, station_code=None):
|
||||
"""Reload config and measure module under the given STATION_CODE env."""
|
||||
if station_code is None:
|
||||
monkeypatch.delenv("STATION_CODE", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("STATION_CODE", station_code)
|
||||
import config
|
||||
importlib.reload(config)
|
||||
import blueprints.measure
|
||||
importlib.reload(blueprints.measure)
|
||||
|
||||
|
||||
def test_select_recipe_calls_station_endpoint(logged_in_client, monkeypatch):
|
||||
_reload_measure(monkeypatch, station_code="ST-TEST")
|
||||
from blueprints import measure as measure_bp_mod
|
||||
with patch.object(measure_bp_mod, "api_client") as mock_api:
|
||||
mock_api.get_station_recipes.return_value = [
|
||||
{"id": 1, "code": "R1", "name": "Recipe 1", "active": True},
|
||||
]
|
||||
resp = logged_in_client.get("/measure/select")
|
||||
assert resp.status_code == 200
|
||||
mock_api.get_station_recipes.assert_called_once()
|
||||
args, kwargs = mock_api.get_station_recipes.call_args
|
||||
assert args[0] == "ST-TEST" or kwargs.get("station_code") == "ST-TEST"
|
||||
|
||||
|
||||
def test_select_recipe_without_station_code_shows_error(logged_in_client, monkeypatch):
|
||||
_reload_measure(monkeypatch, station_code=None)
|
||||
resp = logged_in_client.get("/measure/select")
|
||||
assert resp.status_code == 503
|
||||
body = resp.data.lower()
|
||||
assert b"station_code" in body or b"stazione" in body
|
||||
@@ -1,88 +0,0 @@
|
||||
"""Tests for statistics blueprint (client/blueprints/statistics.py).
|
||||
|
||||
Covers dashboard rendering, role enforcement, and SPC/summary API proxies.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDashboard:
|
||||
"""GET /statistics/dashboard tests."""
|
||||
|
||||
def test_dashboard_renders(self, logged_in_client, mock_api_client):
|
||||
"""Dashboard renders for Metrologist role."""
|
||||
mock_api_client.get.return_value = {
|
||||
"items": [
|
||||
{"id": 1, "code": "REC-001", "name": "Recipe A"},
|
||||
],
|
||||
"total": 1,
|
||||
"pages": 1,
|
||||
}
|
||||
|
||||
resp = logged_in_client.get("/statistics/dashboard")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_dashboard_requires_login(self, client):
|
||||
"""Unauthenticated user is redirected to login."""
|
||||
resp = client.get("/statistics/dashboard", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert "/auth/login" in resp.headers["Location"]
|
||||
|
||||
|
||||
class TestRoleRequired:
|
||||
"""Metrologist role enforcement tests."""
|
||||
|
||||
def test_requires_metrologist_role(self, flask_app):
|
||||
"""User without Metrologist role gets 403 on statistics endpoints."""
|
||||
with flask_app.test_client() as c:
|
||||
with c.session_transaction() as sess:
|
||||
sess["api_key"] = "test-key"
|
||||
sess["user"] = {
|
||||
"id": 3,
|
||||
"username": "maker_only",
|
||||
"display_name": "Maker Only",
|
||||
"roles": ["Maker"],
|
||||
"is_admin": False,
|
||||
"language_pref": "en",
|
||||
"theme_pref": "light",
|
||||
"active": True,
|
||||
}
|
||||
sess["user_id"] = 3
|
||||
sess["language"] = "en"
|
||||
sess["theme"] = "light"
|
||||
|
||||
resp = c.get("/statistics/dashboard")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestSPCProxy:
|
||||
"""SPC data proxy endpoint tests."""
|
||||
|
||||
def test_summary_proxy(self, logged_in_client, mock_api_client):
|
||||
"""Summary API proxy returns JSON from backend."""
|
||||
mock_api_client.get.return_value = {
|
||||
"total": 100,
|
||||
"pass_count": 90,
|
||||
"fail_count": 5,
|
||||
"warning_count": 5,
|
||||
}
|
||||
|
||||
resp = logged_in_client.get(
|
||||
"/statistics/api/summary?recipe_id=1"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["total"] == 100
|
||||
|
||||
def test_capability_proxy(self, logged_in_client, mock_api_client):
|
||||
"""Capability API proxy returns JSON from backend."""
|
||||
mock_api_client.get.return_value = {
|
||||
"cp": 1.33,
|
||||
"cpk": 1.20,
|
||||
}
|
||||
|
||||
resp = logged_in_client.get(
|
||||
"/statistics/api/capability?recipe_id=1&subtask_id=1"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "cp" in data
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,118 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""Verify i18n setup for TieMeasureFlow."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
def check_file(path: Path, description: str) -> bool:
|
||||
"""Check if a file exists and report."""
|
||||
exists = path.exists()
|
||||
status = "[OK]" if exists else "[FAIL]"
|
||||
print(f"{status} {description}: {path}")
|
||||
return exists
|
||||
|
||||
def check_json_valid(path: Path) -> bool:
|
||||
"""Check if JSON file is valid."""
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
json.load(f)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Verify i18n setup."""
|
||||
print("=== TieMeasureFlow i18n Verification ===\n")
|
||||
|
||||
root = Path(__file__).parent
|
||||
all_good = True
|
||||
|
||||
# Check Flask-Babel files
|
||||
print("Flask-Babel (Server-side):")
|
||||
files = [
|
||||
(root / "translations/babel.cfg", "Babel config"),
|
||||
(root / "translations/it/LC_MESSAGES/messages.po", "Italian .po"),
|
||||
(root / "translations/it/LC_MESSAGES/messages.mo", "Italian .mo"),
|
||||
(root / "translations/en/LC_MESSAGES/messages.po", "English .po"),
|
||||
(root / "translations/en/LC_MESSAGES/messages.mo", "English .mo"),
|
||||
]
|
||||
|
||||
for path, desc in files:
|
||||
if not check_file(path, desc):
|
||||
all_good = False
|
||||
|
||||
# Count messages in .po files
|
||||
if (root / "translations/it/LC_MESSAGES/messages.po").exists():
|
||||
with open(root / "translations/it/LC_MESSAGES/messages.po", 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
msgid_count = content.count('msgid "') - 1 # Exclude header
|
||||
print(f" Italian: {msgid_count} messages")
|
||||
|
||||
if (root / "translations/en/LC_MESSAGES/messages.po").exists():
|
||||
with open(root / "translations/en/LC_MESSAGES/messages.po", 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
msgid_count = content.count('msgid "') - 1
|
||||
print(f" English: {msgid_count} messages")
|
||||
|
||||
# Check Alpine.js i18n files
|
||||
print("\nAlpine.js i18n (Client-side):")
|
||||
json_files = [
|
||||
(root / "static/js/locales/it.json", "Italian locale"),
|
||||
(root / "static/js/locales/en.json", "English locale"),
|
||||
]
|
||||
|
||||
for path, desc in json_files:
|
||||
if check_file(path, desc):
|
||||
if not check_json_valid(path):
|
||||
all_good = False
|
||||
else:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
keys = count_keys(data)
|
||||
print(f" {keys} total keys")
|
||||
|
||||
# Check app.py integration
|
||||
print("\nApp Integration:")
|
||||
app_py = root / "app.py"
|
||||
if check_file(app_py, "app.py"):
|
||||
with open(app_py, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
checks = [
|
||||
("from flask_babel import Babel", "Flask-Babel imported"),
|
||||
("def get_locale()", "get_locale() function"),
|
||||
("Babel(app, locale_selector=get_locale)", "Babel initialized"),
|
||||
("def set_language(lang)", "set_language endpoint"),
|
||||
]
|
||||
for check_str, check_desc in checks:
|
||||
found = check_str in content
|
||||
status = "[OK]" if found else "[FAIL]"
|
||||
print(f" {status} {check_desc}")
|
||||
if not found:
|
||||
all_good = False
|
||||
|
||||
# Summary
|
||||
print("\n" + "="*40)
|
||||
if all_good:
|
||||
print("[OK] i18n setup verified successfully!")
|
||||
print("\nNext steps:")
|
||||
print("1. Use _() in Python code and templates")
|
||||
print("2. Use $t() in Alpine.js components")
|
||||
print("3. Test language switching: /set-language/en or /set-language/it")
|
||||
else:
|
||||
print("[FAIL] Some checks failed - review errors above")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
def count_keys(obj, depth=0):
|
||||
"""Recursively count keys in nested dict."""
|
||||
if not isinstance(obj, dict):
|
||||
return 0
|
||||
count = len(obj)
|
||||
for value in obj.values():
|
||||
if isinstance(value, dict):
|
||||
count += count_keys(value, depth + 1)
|
||||
return count
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
+55
-6
@@ -14,6 +14,7 @@ services:
|
||||
--authentication-policy=mysql_native_password
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_unicode_ci
|
||||
--default-time-zone=+00:00
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 10s
|
||||
@@ -24,35 +25,81 @@ services:
|
||||
|
||||
server:
|
||||
build:
|
||||
context: ./server
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: tmflow-server
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
APP_ENV: dev
|
||||
DB_HOST: mysql
|
||||
UPLOAD_DIR: uploads
|
||||
VISION_WORKER_URL: http://vision:8100
|
||||
WEB_CONCURRENCY: ${WEB_CONCURRENCY:-4}
|
||||
TRUSTED_PROXY_COUNT: ${TRUSTED_PROXY_COUNT:-1}
|
||||
volumes:
|
||||
- upload_data:/app/uploads
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
vision:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.vision
|
||||
args:
|
||||
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
||||
container_name: tmflow-vision
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- upload_data:/app/uploads:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
client:
|
||||
build:
|
||||
context: ./client
|
||||
dockerfile: Dockerfile
|
||||
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:
|
||||
- .env
|
||||
environment:
|
||||
API_SERVER_URL: http://server:8000
|
||||
# Nginx qui non termina SSL (vedi nginx/nginx.conf): un'installazione
|
||||
# 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
|
||||
server:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/auth/login', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
@@ -69,8 +116,10 @@ services:
|
||||
# - ./certbot/conf:/etc/letsencrypt:ro
|
||||
# - ./certbot/www:/var/www/certbot:ro
|
||||
depends_on:
|
||||
- server
|
||||
- client
|
||||
server:
|
||||
condition: service_healthy
|
||||
client:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
|
||||
+59
-12
@@ -4,16 +4,17 @@ services:
|
||||
container_name: tmflow-mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root_password_change_me}
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?manca DB_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${DB_NAME:-tiemeasureflow}
|
||||
MYSQL_USER: ${DB_USER:-tmflow}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:-change_me_in_production}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:?manca DB_PASSWORD}
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
command: >
|
||||
--authentication-policy=mysql_native_password
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_unicode_ci
|
||||
--default-time-zone=+00:00
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 10s
|
||||
@@ -24,59 +25,105 @@ services:
|
||||
|
||||
server:
|
||||
build:
|
||||
context: ./server
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: tmflow-server
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
APP_ENV: production
|
||||
DB_HOST: mysql
|
||||
UPLOAD_DIR: uploads
|
||||
VISION_WORKER_URL: http://vision:8100
|
||||
SERVER_SECRET_KEY: ${SERVER_SECRET_KEY:?manca SERVER_SECRET_KEY}
|
||||
WEB_CONCURRENCY: ${WEB_CONCURRENCY:-4}
|
||||
TRUSTED_PROXY_COUNT: ${TRUSTED_PROXY_COUNT:-1}
|
||||
volumes:
|
||||
- upload_data:/app/uploads
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.tmflow-api.rule=Host(`tieflow.tielogic.xyz`) && (PathPrefix(`/api/`) || PathPrefix(`/uploads/`))"
|
||||
- "traefik.http.routers.tmflow-api.rule=Host(`${APP_DOMAIN:?manca APP_DOMAIN}`) && PathPrefix(`/api/`)"
|
||||
- "traefik.http.routers.tmflow-api.entrypoints=web,websecure"
|
||||
- "traefik.http.routers.tmflow-api.tls=true"
|
||||
- "traefik.http.routers.tmflow-api.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.tmflow-api.priority=100"
|
||||
- "traefik.http.services.tmflow-api.loadbalancer.server.port=8000"
|
||||
- "traefik.http.routers.tmflow-api.middlewares=tmflow-headers"
|
||||
networks:
|
||||
- tmflow-net
|
||||
- traefik-net
|
||||
|
||||
vision:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.vision
|
||||
args:
|
||||
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
||||
container_name: tmflow-vision
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# #46: radice delle cartelle ricetta (volume uploads, sola lettura)
|
||||
VISION_RECIPES_ROOT: /app/uploads
|
||||
volumes:
|
||||
- upload_data:/app/uploads:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
client:
|
||||
build:
|
||||
context: ./client
|
||||
dockerfile: Dockerfile
|
||||
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:
|
||||
- .env
|
||||
environment:
|
||||
API_SERVER_URL: http://server:8000
|
||||
CLIENT_SECRET_KEY: ${CLIENT_SECRET_KEY:?manca CLIENT_SECRET_KEY}
|
||||
depends_on:
|
||||
- server
|
||||
server:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/auth/login', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.tmflow-web.rule=Host(`tieflow.tielogic.xyz`)"
|
||||
- "traefik.http.routers.tmflow-web.rule=Host(`${APP_DOMAIN:?manca APP_DOMAIN}`)"
|
||||
- "traefik.http.routers.tmflow-web.entrypoints=web,websecure"
|
||||
- "traefik.http.routers.tmflow-web.tls=true"
|
||||
- "traefik.http.routers.tmflow-web.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.tmflow-web.priority=10"
|
||||
- "traefik.http.services.tmflow-web.loadbalancer.server.port=5000"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.SSLRedirect=true"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.STSSeconds=315360000"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.browserXSSFilter=true"
|
||||
# Traefik v3: SSLRedirect e browserXSSFilter non ci sono più (SSL redirect
|
||||
# si fa a livello di entrypoint, l'XSS Auditor del browser non esiste più).
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.stsSeconds=315360000"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.contentTypeNosniff=true"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.forceSTSHeader=true"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.STSIncludeSubdomains=true"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.STSPreload=true"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.customFrameOptionsValue=SAMEORIGIN"
|
||||
- "traefik.http.routers.tmflow-web.middlewares=tmflow-headers"
|
||||
networks:
|
||||
- tmflow-net
|
||||
@@ -93,4 +140,4 @@ networks:
|
||||
driver: bridge
|
||||
traefik-net:
|
||||
external: true
|
||||
name: root_default
|
||||
name: traefik
|
||||
|
||||
+184
@@ -23,6 +23,7 @@ TieMeasureFlow provides a REST API built with FastAPI for managing measurement t
|
||||
- [Measurements](#measurements)
|
||||
- [Files](#files)
|
||||
- [Settings](#settings)
|
||||
- [Stations](#stations)
|
||||
- [Statistics](#statistics)
|
||||
- [Reports](#reports)
|
||||
7. [Pagination](#pagination)
|
||||
@@ -1101,6 +1102,189 @@ Content-Type: multipart/form-data
|
||||
|
||||
---
|
||||
|
||||
### Stations
|
||||
|
||||
Stations model the physical measurement posts on the shop floor. Each Flask client identifies itself through `STATION_CODE`, and the operator only sees recipes assigned to that station. See the user guide section "Admin Workflow → Station Management" for the operational model.
|
||||
|
||||
#### GET `/stations`
|
||||
|
||||
List stations. **Admin only.**
|
||||
|
||||
Query parameters:
|
||||
- `active_only` (bool, default `false`): if `true`, return only stations where `active = true`.
|
||||
|
||||
Response `200`:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"code": "ST-DEFAULT",
|
||||
"name": "Default Station",
|
||||
"location": "Initial seed - change me",
|
||||
"notes": null,
|
||||
"active": true,
|
||||
"created_by": 5,
|
||||
"created_at": "2026-04-26T10:12:06"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Errors:
|
||||
- 401: Missing or invalid API key
|
||||
- 403: Admin role required
|
||||
|
||||
---
|
||||
|
||||
#### POST `/stations`
|
||||
|
||||
Create a station. **Admin only.**
|
||||
|
||||
Request body:
|
||||
```json
|
||||
{
|
||||
"code": "ST-LINEA-A",
|
||||
"name": "Linea A — Tornitura alberi",
|
||||
"location": "Reparto 2 — Cella 3",
|
||||
"notes": "Provisioned 2026-04-26 by Adriano",
|
||||
"active": true
|
||||
}
|
||||
```
|
||||
|
||||
`code` (1-100 chars) and `name` (1-255 chars) are required. `location` is optional (≤ 255 chars). `active` defaults to `true`.
|
||||
|
||||
Response `201`: same shape as `GET /stations` items.
|
||||
|
||||
Errors:
|
||||
- 400: Invalid payload (missing `code`/`name`, exceeded length)
|
||||
- 403: Admin role required
|
||||
- 409: Station with that code already exists
|
||||
|
||||
---
|
||||
|
||||
#### GET `/stations/{station_id}`
|
||||
|
||||
Get a single station by id. **Admin only.**
|
||||
|
||||
Response `200`: same as the list item shape.
|
||||
|
||||
Errors:
|
||||
- 403: Admin role required
|
||||
- 404: Station not found
|
||||
|
||||
---
|
||||
|
||||
#### PUT `/stations/{station_id}`
|
||||
|
||||
Update a station's editable fields. **Admin only.**
|
||||
|
||||
Request body (all fields optional):
|
||||
```json
|
||||
{
|
||||
"name": "Linea A — riconfigurata",
|
||||
"location": "Reparto 2 — Cella 4",
|
||||
"notes": "Moved cell on 2026-05-12",
|
||||
"active": false
|
||||
}
|
||||
```
|
||||
|
||||
Note: the `code` field is not in the schema. Codes are immutable on purpose (changing the code would orphan every tablet pointing at it).
|
||||
|
||||
Response `200`: the updated station.
|
||||
|
||||
Errors:
|
||||
- 400: Invalid payload
|
||||
- 403: Admin role required
|
||||
- 404: Station not found
|
||||
|
||||
---
|
||||
|
||||
#### DELETE `/stations/{station_id}`
|
||||
|
||||
Delete a station. **Admin only.**
|
||||
|
||||
The deletion cascades to every row in `station_recipe_assignments` for that station. Existing measurements are NOT affected (measurements link to recipe versions, not stations).
|
||||
|
||||
Response `204`: no body.
|
||||
|
||||
Errors:
|
||||
- 403: Admin role required
|
||||
- 404: Station not found
|
||||
|
||||
---
|
||||
|
||||
#### GET `/stations/{station_id}/recipes`
|
||||
|
||||
Admin view of the recipes currently assigned to a station. Returns the same projection used by the assignment modal in `/admin/stations`. **Admin only.**
|
||||
|
||||
Response `200`:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 2,
|
||||
"code": "DEMO-001",
|
||||
"name": "Demo Measurement Recipe",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Errors:
|
||||
- 403: Admin role required
|
||||
- 404: Station not found
|
||||
|
||||
---
|
||||
|
||||
#### GET `/stations/by-code/{code}/recipes`
|
||||
|
||||
**Operator view** (any authenticated user, no admin requirement). Returns the active recipes assigned to the station whose code matches `{code}`. The Flask client calls this endpoint at every page load of `/measure/select`, passing its own `STATION_CODE`.
|
||||
|
||||
Response `200`: same shape as `GET /stations/{station_id}/recipes`.
|
||||
|
||||
Errors:
|
||||
- 401: Missing or invalid API key
|
||||
- 404: Station not found OR station is not active (operator-facing endpoint deliberately treats both cases as 404 to avoid leaking the existence of disabled stations)
|
||||
|
||||
---
|
||||
|
||||
#### POST `/stations/{station_id}/recipes`
|
||||
|
||||
Assign an existing recipe to a station. **Admin only.**
|
||||
|
||||
Request body:
|
||||
```json
|
||||
{ "recipe_id": 2 }
|
||||
```
|
||||
|
||||
Response `201`:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"station_id": 1,
|
||||
"recipe_id": 2,
|
||||
"assigned_by": 5,
|
||||
"assigned_at": "2026-04-26T10:12:06"
|
||||
}
|
||||
```
|
||||
|
||||
Errors:
|
||||
- 403: Admin role required
|
||||
- 404: Station or recipe not found
|
||||
- 409: Recipe already assigned to this station
|
||||
|
||||
---
|
||||
|
||||
#### DELETE `/stations/{station_id}/recipes/{recipe_id}`
|
||||
|
||||
Remove an existing assignment. **Admin only.**
|
||||
|
||||
Response `204`: no body.
|
||||
|
||||
Errors:
|
||||
- 403: Admin role required
|
||||
- 404: Station/recipe not found, or no assignment between them
|
||||
|
||||
---
|
||||
|
||||
### Statistics
|
||||
|
||||
All statistics endpoints **require Metrologist role**.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# Scaletta di collaudo — V3.0.0
|
||||
|
||||
Tredici dei quindici punti del 28/07 sono in esercizio e **nessuno li ha ancora
|
||||
percorsi**. Questa è la scaletta per farlo: ordinata in modo che ogni prova prepari
|
||||
la successiva, e scritta perché si possa spuntare riga per riga.
|
||||
|
||||
Per ogni riga: cosa fare, e **come si vede che è giusto**. Se qualcosa non torna,
|
||||
annotare il testo esatto dell'eventuale errore in console — non «dava errore».
|
||||
|
||||
Totale stimato: **circa 90 minuti**, di cui 30 sul solo punto 3 (il timer, che si
|
||||
misura aspettando).
|
||||
|
||||
---
|
||||
|
||||
## 0 · Preparazione (5 min)
|
||||
|
||||
| | Cosa | Verifica |
|
||||
|---|---|---|
|
||||
| 0.1 | Aprire il sito e fare **ricarica forzata** (`Ctrl+Shift+R`) | CSS e JS sono in cache per 7 giorni: senza questo si collauda la versione di ieri |
|
||||
| 0.2 | Aprire la **console del browser** (`F12`) e lasciarla aperta per tutto il collaudo | Molte prove si leggono lì, non a schermo |
|
||||
| 0.3 | Verificare che esistano le ricette `COLLAUDO-A` e `COLLAUDO-B` | Sono in `/measure/select`. Se una sessione precedente le ha sporcate: `docker compose exec server uv run python /tmp/seed_collaudo.py --replace` (cancella anche le misure fatte su di esse) |
|
||||
|
||||
**Credenziali del capoturno:** `capoturno` / `Collaudo2026!` — da cambiare prima di
|
||||
qualunque uso reale.
|
||||
|
||||
**Le ricette:**
|
||||
|
||||
- **COLLAUDO-A** — lotto obbligatorio, digitazione ammessa, intervallo **2 minuti**.
|
||||
Cinque task: nota → misura (2 quote) → nota → misura (1 quota) → nota.
|
||||
- **COLLAUDO-B** — lotto **e** seriale obbligatori, **solo calibro**, una quota.
|
||||
|
||||
**Le quote e i valori da usare** (tutte con nominale al centro):
|
||||
|
||||
| Quota | Task | Nominale | Conforme | Attenzione | Fuori tolleranza |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 · Diametro esterno | A, misura 1 | 10.00 | `10.00` | `10.30` | `12.00` |
|
||||
| 2 · Spessore parete | A, misura 1 | 25.00 | `25.00` | `25.30` | `27.00` |
|
||||
| 3 · Lunghezza totale | A, misura 2 | 100.00 | `100.00` | `100.70` | `105.00` |
|
||||
|
||||
---
|
||||
|
||||
## 1 · Layout — punto 14 (10 min) · **da monitor da scrivania**
|
||||
|
||||
Va fatto **prima** e **non dal tablet**: in verticale su un tablet quasi tutte le
|
||||
larghezze coincidono e il difetto non si vede. Serve uno schermo largo.
|
||||
|
||||
| | Cosa | Verifica |
|
||||
|---|---|---|
|
||||
| 1.1 | Percorrere Ricette → apri una ricetta → Task → Disegno → Anteprima, e tornare indietro | Il blocco di contenuto **non cambia larghezza** fra una schermata e l'altra |
|
||||
| 1.2 | Su ognuna, guardare il bordo sinistro del contenuto e quello della barra in alto | Sono **allineati**: il contenuto sta dentro la stessa cornice della navbar |
|
||||
| 1.3 | Passare da una pagina lunga (che scorre) a una corta (che non scorre) | Niente **salta di lato** di qualche pixel: lo spazio della barra di scorrimento è riservato sempre |
|
||||
| 1.4 | Entrare in un task di misura e uscirne | Stesso controllo: nessuno spostamento orizzontale |
|
||||
| 1.5 | Admin → Utenti, Stazioni, Impostazioni; poi il proprio Profilo | Utenti e Stazioni sono larghe, Impostazioni e Profilo strette — ma con **gli stessi margini esterni** |
|
||||
| 1.6 | **Dal tablet**, entrare in un task di misura | Il piede con «Fine ciclo misura» e il tastierino è visibile **senza scorrere**, anche quando la barra dell'indirizzo compare e sparisce |
|
||||
|
||||
---
|
||||
|
||||
## 2 · Niente arriva dalla rete — punto 12 (15 min)
|
||||
|
||||
Da fare **adesso**, perché la Content-Security-Policy è appena entrata in vigore: se
|
||||
una libreria avesse bisogno di un permesso non concesso, il sintomo compare ora.
|
||||
|
||||
> **Attenzione al metodo.** Il sistema è oggi su una VPS pubblica: staccare la rete
|
||||
> significherebbe perdere anche il server, quindi non prova niente. La prova
|
||||
> equivalente e più diretta è guardare **dove vanno le richieste**. Il giro a rete
|
||||
> davvero staccata si fa in reparto, sull'installazione on-premise.
|
||||
|
||||
| | Cosa | Verifica |
|
||||
|---|---|---|
|
||||
| 2.1 | Console → scheda **Rete**, spuntare «Disabilita cache», ricaricare | Ordinando per dominio compare **un solo dominio**: `tieflow.tielogic.xyz`. Nessun `cdn`, `jsdelivr`, `cloudflare`, `googleapis`, `plot.ly` |
|
||||
| 2.2 | Ripetere su: login, selezione ricetta, esecuzione task, editor annotazioni (`/maker`), dashboard statistiche | Sempre un dominio solo. La dashboard è quella che carica Plotly, l'editor quella che carica Fabric |
|
||||
| 2.3 | Aprire un task che ha un **disegno PDF** allegato | Il disegno si vede. È la prova del *worker* di PDF.js: se cercasse la rete, tutto sembrerebbe a posto fino a questo momento |
|
||||
| 2.4 | Con la console aperta su ogni pagina del giro | **Zero** messaggi che iniziano con `Refused to load…` o `…violates the following Content Security Policy directive`. Se ce n'è uno, copiarlo per intero: dice quale permesso manca |
|
||||
| 2.5 | Generare un **report PDF** dalle statistiche | Il PDF esce. È generato sul server, non nel browser, ma vale la pena vederlo funzionare nel giro completo |
|
||||
|
||||
---
|
||||
|
||||
## 3 · Le regole della ricetta — punti 8 e 9 (10 min) · **COLLAUDO-B**
|
||||
|
||||
| | Cosa | Verifica |
|
||||
|---|---|---|
|
||||
| 3.1 | `/measure/select`, **senza** compilare lotto e seriale | Sulla scheda di COLLAUDO-B il pulsante è spento e dice **«Compila prima: lotto, seriale»** |
|
||||
| 3.2 | Compilare **solo** il lotto | Il pulsante resta spento e ora chiede solo il **seriale** |
|
||||
| 3.3 | Provare ad andare a mano su `/measure/start/<id>` di COLLAUDO-B senza seriale | Rimanda alla selezione con il messaggio «Compila prima:». La regola non si aggira dall'indirizzo |
|
||||
| 3.4 | Compilare entrambi e avviare | Entra nel task |
|
||||
| 3.5 | Guardare il tastierino | I tasti numerici **non ci sono affatto** — non sono grigi, non ci sono. Restano «cancella» e «conferma» |
|
||||
| 3.6 | Con un **calibro USB** collegato, prendere una lettura | Il valore entra e si salva |
|
||||
| 3.7 | *(facoltativo, per chi vuole la prova dura)* Provare a salvare un valore digitato aggirando lo schermo | Il server risponde **422**: la regola vive lì, non nella pagina |
|
||||
|
||||
---
|
||||
|
||||
## 4 · Il primo ciclo e l'avvio produzione — punti 10 e 11 (10 min) · **COLLAUDO-A**
|
||||
|
||||
> Da qui in avanti si lavora su COLLAUDO-A. **Il timer non parte subito**: prima si
|
||||
> fa un ciclo completo, poi compare «Avvio Produzione». È voluto — il primo pezzo è
|
||||
> l'attrezzaggio, non produzione.
|
||||
|
||||
| | Cosa | Verifica |
|
||||
|---|---|---|
|
||||
| 4.1 | Selezione ricetta → COLLAUDO-A → lotto `LOT-TEST-01` → **AVVIA IN SEQUENZA** | Si apre **il primo task** (la nota «Preparazione del pezzo»), **non** l'elenco dei task |
|
||||
| 4.2 | Leggere la nota | «a sinistra» è in **grassetto**, le righe vanno a capo e la riga vuota si vede (punto 11) |
|
||||
| 4.3 | Guardare i due pulsanti in alto a destra | Sono **«Lista task»** e **«Completato»**. Su una nota «Completato» è attivo |
|
||||
| 4.4 | Premere «Completato» | Passa al task 2, «Misura 1 — diametro e spessore» |
|
||||
| 4.5 | Guardare il pulsante in basso a destra | **«Mancano 2 quote»**, grigio e non cliccabile |
|
||||
| 4.6 | Misurare la quota 1 con `10.00` e confermare | Verde (conforme). Il pulsante in basso ora dice **«Manca 1 quota»** |
|
||||
| 4.7 | **Prova chiave del punto 10**: premere «Lista task» adesso, a metà | Nel'elenco il task «Misura 1» porta **«Incompiuto 1/2»** con il bordo ambra. Gli altri non dicono niente |
|
||||
| 4.8 | Premere **«Visualizza singolo TASK»** su quel task | Ci si torna dentro, con la quota già presa |
|
||||
| 4.9 | Misurare la quota 2 con `25.00` | Il pulsante in basso diventa blu: **«Fine ciclo misura»** |
|
||||
| 4.10 | Premerlo | Compare il verde **«Task successivo»** (non un secondo «Completato») |
|
||||
| 4.11 | Guardare sotto il piede della pagina | Compare il pulsante verde **«Avvio Produzione»** |
|
||||
| 4.12 | Premere «Avvio Produzione» | Parte il conto alla rovescia da **2 minuti**. Da qui in avanti compaiono «Fermo linea» e «Fine produzione» |
|
||||
|
||||
---
|
||||
|
||||
## 5 · Il fuori tolleranza — punto 5 (15 min)
|
||||
|
||||
La parte più importante del collaudo: fino a ieri questo gate era una modale che si
|
||||
poteva chiudere.
|
||||
|
||||
| | Cosa | Verifica |
|
||||
|---|---|---|
|
||||
| 5.1 | Tornare su «Misura 1». Cliccare la **quota 1** in alto per rimisurarla, e inserire `12.00` | Si salva ed è **rossa**. La misura sbagliata resta a registro: è un dato, non un errore da nascondere |
|
||||
| 5.2 | Provare a passare alla **quota 2** e salvare un valore | **Rifiutato.** Compare l'avviso che serve l'autorizzazione |
|
||||
| 5.3 | Guardare «Completato» in alto | È **spento**, e passandoci sopra dice «Serve l'autorizzazione del capoturno» |
|
||||
| 5.4 | **Ricaricare la pagina** (`F5`) | Il blocco **è ancora lì**. Ricaricare non è una via d'uscita |
|
||||
| 5.5 | **La via d'uscita senza capoturno**: cliccare di nuovo la quota 1 e rimisurarla con `10.00` | Il blocco **si scioglie**. È voluto: il calibro scivola, il pezzo si riposiziona, e chiamare il capoturno per questo sarebbe assurdo |
|
||||
| 5.6 | Rifarlo: quota 1 con `12.00`, e stavolta **autorizzare** con `capoturno` / `Collaudo2026!` | Il blocco si scioglie e la misura resta segnata come autorizzata |
|
||||
| 5.7 | Provare ad autorizzare con una password sbagliata | Rifiutato, e il blocco **resta** |
|
||||
| 5.8 | Provare ad autorizzare con un utente **senza** ruolo Supervisor (es. l'operatore stesso) | Rifiutato: non basta essere collegati, serve il ruolo |
|
||||
| 5.9 | Rimettere `10.00` sulla quota 1 e `25.00` sulla 2, chiudere il ciclo | Si prosegue normalmente |
|
||||
|
||||
---
|
||||
|
||||
## 6 · Il ciclo di misura — punto 3 (30 min, quasi tutti di attesa)
|
||||
|
||||
| | Cosa | Verifica |
|
||||
|---|---|---|
|
||||
| 6.1 | Con il conto alla rovescia in corso, **cambiare task** | Il tempo residuo **prosegue**, non riparte da 2 minuti |
|
||||
| 6.2 | **Ricaricare la pagina** (`F5`) | Idem: il tempo è lo stesso. Vive sul server, non nella pagina |
|
||||
| 6.3 | Fermarsi su una **nota** e lasciare scadere l'intervallo | A ~5 secondi dalla scadenza compare l'avviso e **suona il cicalino** |
|
||||
| 6.4 | Non toccare niente | Alla scadenza si viene portati **al primo task di misura** della ricetta, non a quello dove si era |
|
||||
| 6.5 | Fare un ciclo completo: misura 1 (2 quote) → nota → **misura 2** (quota 3, `100.00`) → «Fine ciclo misura» | Chiudendo il ciclo **sull'ultimo task di misura** l'intervallo **riparte da 2 minuti** |
|
||||
| 6.6 | Ripetere ma chiudendo il ciclo sulla **prima** misura | L'intervallo **non** riparte: il ciclo si chiude solo sull'ultimo task di misura |
|
||||
| 6.7 | **La prova più delicata**: stare **dentro** un task di misura, con una quota già presa e una da prendere, e lasciare scadere l'intervallo | **Non** si viene spostati. Il conteggio diventa **rosso** e prosegue **oltre lo zero**, mostrando il ritardo. Chi sta misurando va lasciato finire |
|
||||
| 6.8 | Usare **«Rimisura»** dopo aver completato le quote | Registra una seconda lettura dello stesso pezzo **senza** far ripartire l'intervallo. Girare il pezzo e rimisurare non è un pezzo nuovo |
|
||||
|
||||
---
|
||||
|
||||
## 7 · Fermo linea e fine produzione — punto 6 (10 min)
|
||||
|
||||
| | Cosa | Verifica |
|
||||
|---|---|---|
|
||||
| 7.1 | Dentro un task di misura, premere **«Fermo linea»** → credenziali capoturno | Il conto alla rovescia **si ferma** |
|
||||
| 7.2 | Ricaricare la pagina | È ancora fermo: lo stato è sul server |
|
||||
| 7.3 | Premere **«Ripresa»** → credenziali | Riparte |
|
||||
| 7.4 | Premere **«Fine produzione»** → credenziali | La produzione si chiude |
|
||||
| 7.5 | Sul server: `docker compose exec server ls uploads/statistics/` | C'è un file `production_<id>_<data>.csv` |
|
||||
| 7.6 | Aprire il file | Contiene **tutte** le misure della produzione, compresi i `12.00`. Sulla riga autorizzata le colonne `authorised_by` e `authorised_at` sono **valorizzate**: il file mostra il guasto **e** la decisione |
|
||||
| 7.7 | Aprire il file in Excel | Le accentate si leggono, il separatore è quello configurato |
|
||||
|
||||
---
|
||||
|
||||
## 8 · La statistica resta separata — punto 15 (5 min)
|
||||
|
||||
| | Cosa | Verifica |
|
||||
|---|---|---|
|
||||
| 8.1 | Collegarsi con un utente che ha **solo** il ruolo MeasurementTec | In barra **non c'è** la voce Statistiche |
|
||||
| 8.2 | Percorrere tutto il flusso operatore guardando ogni schermata | Nessun collegamento porta alla statistica, in nessun punto |
|
||||
| 8.3 | Andare a mano su `/statistics/dashboard` | **403**: il ruolo non basta |
|
||||
| 8.4 | Verificare che le misure ci siano comunque | Con un utente Metrologist, le misure appena fatte compaiono in dashboard. Si registra sempre, si consulta a parte |
|
||||
|
||||
---
|
||||
|
||||
## Cosa annotare
|
||||
|
||||
Per ogni riga che non torna:
|
||||
|
||||
1. **Numero della riga** di questa scaletta.
|
||||
2. **Cosa è successo** invece di quello che c'è scritto.
|
||||
3. Se c'è un errore in console, il **testo esatto**, per intero.
|
||||
4. Se è un problema di aspetto, uno **screenshot** e su **quale schermo** (tablet o
|
||||
monitor, e in che orientamento) — per il punto 14 è l'informazione che manca.
|
||||
|
||||
## Cosa questa scaletta non copre
|
||||
|
||||
- Il **punto 4** (numero di tentativi prima del capoturno) non è implementato: è
|
||||
fermo sulle risposte del cliente (D-6, D-7, D-9).
|
||||
- Il **cicalino** è il suono del browser. Se serva una colonnina luminosa è D-5.
|
||||
- Il giro a **rete davvero staccata** si fa in reparto, sull'installazione
|
||||
on-premise: qui si prova l'equivalente (nessuna richiesta esce dal dominio).
|
||||
- Il **carico** con venti tablet insieme non è mai stato misurato.
|
||||
+184
-284
@@ -134,13 +134,27 @@ SSL_KEYFILE=
|
||||
| `CLIENT_HOST` | string | 0.0.0.0 | Flask client bind address |
|
||||
| `CLIENT_PORT` | int | 5000 | Flask client port |
|
||||
| `SERVER_CORS_ORIGINS` | string | http://localhost:5000 | Comma-separated CORS origins |
|
||||
| `UPLOAD_DIR` | string | uploads | Directory for file uploads |
|
||||
| `UPLOAD_DIR` | string | uploads | Directory for file uploads (resolved against the project root) |
|
||||
| `MAX_UPLOAD_SIZE_MB` | int | 50 | Maximum upload file size in MB |
|
||||
| `RATE_LIMIT_LOGIN` | int | 5 | Login requests per minute |
|
||||
| `RATE_LIMIT_GENERAL` | int | 100 | General requests per minute |
|
||||
| `RATE_LIMIT_LOGIN` | int | 5 | Login requests per minute, per real client IP |
|
||||
| `RATE_LIMIT_GENERAL` | int | 300 | General requests per minute, per real client IP (post-V2.0.0; was 100 in V1.0.x) |
|
||||
| `STATION_CODE` | string | (empty) | **Per-tablet** code identifying the station this Flask client serves. Must match a station created in the admin UI. Empty = the client refuses `/measure/select` with HTTP 503 "Stazione non configurata". |
|
||||
| `SSL_CERTFILE` | string | (empty) | Path to SSL certificate (production) |
|
||||
| `SSL_KEYFILE` | string | (empty) | Path to SSL private key (production) |
|
||||
|
||||
Introdotte dalla revisione del 21/09/2026:
|
||||
|
||||
| Variable | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `APP_ENV` | string | dev | Ambiente di esecuzione. `dev`/`test` saltano il controllo sui segreti di default; qualsiasi altro valore lo attiva. `docker-compose.yml` lo imposta a `production`, `docker-compose.dev.yml` a `dev` |
|
||||
| `APP_DOMAIN` | string | *(nessuno, obbligatoria)* | Dominio pubblico dell'installazione, usato nelle rule Traefik di `docker-compose.yml` (solo produzione) |
|
||||
| `TRUSTED_PROXY_COUNT` | int | 1 | Quanti hop di reverse proxy fidati stanno davanti al server, per calcolare l'IP reale del tablet |
|
||||
| `WEB_CONCURRENCY` | int | 1 | Numero di worker Uvicorn del server; il `Dockerfile` ne avvia 4 di default indipendentemente da questa variabile |
|
||||
| `SESSION_COOKIE_SECURE` | bool | true | Cookie di sessione Flask con flag Secure (richiede HTTPS). `false` per le installazioni on-premise su LAN in HTTP semplice (`docker-compose.dev.yml`) |
|
||||
| `VISION_RUN_TIMEOUT_S` | int | 100 | Timeout, in secondi, di un'esecuzione del worker di visione |
|
||||
| `VISION_MAX_CONCURRENCY` | int | 2 | Esecuzioni di visione in parallelo accettate dal worker |
|
||||
| `VISION_ENGINE_VERSION` | string | *(nessuno, obbligatoria alla build)* | Commit di `vendor/visionsuite` stampato nell'immagine `vision` al momento della build — vedi [Docker Deployment](#docker-deployment) |
|
||||
|
||||
---
|
||||
|
||||
## Database Setup
|
||||
@@ -168,63 +182,33 @@ EXIT;
|
||||
|
||||
### 2. Run Database Migrations
|
||||
|
||||
```bash
|
||||
cd server
|
||||
pip install -r requirements.txt
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
This creates all required tables:
|
||||
- `users` - System users
|
||||
- `recipes` - Measurement recipes
|
||||
- `recipe_versions` - Immutable recipe versions
|
||||
- `recipe_tasks` - Tasks within recipes
|
||||
- `recipe_subtasks` - Subtasks within tasks (individual measurements)
|
||||
- `measurements` - Recorded measurements
|
||||
- `access_logs` - API access audit trail
|
||||
- `system_settings` - Configuration key-value pairs
|
||||
- `recipe_version_audit` - Recipe change history
|
||||
|
||||
### 3. Create Initial Admin User (Optional)
|
||||
|
||||
Use the Flask client to create the first admin user, or run:
|
||||
The monorepo is gestito con `uv`, non con `requirements.txt` per sottocartella: si esegue dalla radice del repository, con il flag `-c` perché `alembic.ini` vive in `src/backend/migrations/`.
|
||||
|
||||
```bash
|
||||
cd server
|
||||
python -c "
|
||||
from database import init_db, SessionLocal
|
||||
from services.auth_service import create_user
|
||||
import asyncio
|
||||
|
||||
async def init():
|
||||
await init_db()
|
||||
async with SessionLocal() as db:
|
||||
await create_user(
|
||||
db,
|
||||
username='admin',
|
||||
password='change_me_first_login',
|
||||
display_name='Admin',
|
||||
email='admin@example.com',
|
||||
roles=['Maker', 'MeasurementTec', 'Metrologist'],
|
||||
is_admin=True
|
||||
)
|
||||
await db.commit()
|
||||
print('Admin user created: admin / change_me_first_login')
|
||||
|
||||
asyncio.run(init())
|
||||
"
|
||||
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||
```
|
||||
|
||||
Questo è lo stesso comando che il container del server esegue da solo a ogni avvio (`CMD` del `Dockerfile`, prima di `uvicorn`): un deployment Docker non richiede questo passo a mano. La prima migrazione crea lo schema base anche su un database completamente vuoto — non serve più creare le tabelle a parte prima di lanciare `upgrade head`.
|
||||
|
||||
### 3. Create Initial Admin User
|
||||
|
||||
Non c'è più uno script Python da lanciare a mano: il primo utente amministratore si crea dalla pagina `/api/setup` (protetta da `SETUP_PASSWORD`, vedi sopra), che inizializza il database, crea l'admin con le credenziali fornite nel form e, opzionalmente, carica i dati demo — vedi [`README.md`](../README.md#setup-iniziale).
|
||||
|
||||
Una volta creato il primo amministratore, l'endpoint di setup si disattiva da solo: le richieste successive di inizializzazione o creazione utenti ricevono `403 Forbidden`, anche con `SETUP_PASSWORD` corretta. Per rieseguirlo — ad esempio per riconfigurare una stazione da zero — si imposta `SETUP_ALLOW_AFTER_INIT=true` nell'ambiente del server, si esegue l'operazione, e si toglie la variabile: non è pensata per restare attiva in modo permanente.
|
||||
|
||||
---
|
||||
|
||||
## Server Deployment
|
||||
|
||||
Docker Compose (sezione successiva) è il metodo raccomandato e quello che gira
|
||||
in produzione. Questa sezione descrive l'avvio **senza Docker**, utile per
|
||||
sviluppare sul runner senza ricostruire un'immagine a ogni modifica.
|
||||
|
||||
### Development Server
|
||||
|
||||
```bash
|
||||
cd server
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
uv sync --extra server --extra dev
|
||||
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Runs at `http://0.0.0.0:8000`
|
||||
@@ -233,40 +217,15 @@ API Documentation:
|
||||
- Swagger UI: `http://localhost:8000/docs`
|
||||
- ReDoc: `http://localhost:8000/redoc`
|
||||
|
||||
### Production Server (Gunicorn + Uvicorn)
|
||||
### Production, senza Docker
|
||||
|
||||
Install Gunicorn:
|
||||
Lo stesso comando che gira dentro il container (`Dockerfile`), lanciato dalla
|
||||
radice del repository con l'interprete e il venv pinnati da `uv`:
|
||||
|
||||
```bash
|
||||
pip install gunicorn
|
||||
```
|
||||
|
||||
Start server:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
gunicorn main:app \
|
||||
--workers 4 \
|
||||
--worker-class uvicorn.workers.UvicornWorker \
|
||||
--bind 0.0.0.0:8000 \
|
||||
--access-logfile /var/log/tiemeasureflow/access.log \
|
||||
--error-logfile /var/log/tiemeasureflow/error.log \
|
||||
--log-level info
|
||||
```
|
||||
|
||||
### Production Server (Waitress - Windows)
|
||||
|
||||
Install Waitress:
|
||||
|
||||
```bash
|
||||
pip install waitress
|
||||
```
|
||||
|
||||
Start server:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
waitress-serve --host=0.0.0.0 --port=8000 main:app
|
||||
uv run uvicorn src.backend.main:app \
|
||||
--host 0.0.0.0 --port 8000 --workers ${WEB_CONCURRENCY:-4} \
|
||||
--proxy-headers --forwarded-allow-ips='*'
|
||||
```
|
||||
|
||||
### systemd Service (Linux)
|
||||
@@ -279,14 +238,14 @@ Description=TieMeasureFlow API Server
|
||||
After=network.target mysql.service
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
Type=simple
|
||||
User=tiemeasureflow
|
||||
WorkingDirectory=/opt/tiemeasureflow/server
|
||||
Environment="PATH=/opt/tiemeasureflow/venv/bin"
|
||||
ExecStart=/opt/tiemeasureflow/venv/bin/gunicorn main:app \
|
||||
--workers 4 \
|
||||
--worker-class uvicorn.workers.UvicornWorker \
|
||||
--bind 0.0.0.0:8000
|
||||
WorkingDirectory=/opt/tiemeasureflow
|
||||
EnvironmentFile=/opt/tiemeasureflow/.env
|
||||
Environment="WEB_CONCURRENCY=4"
|
||||
ExecStart=/opt/tiemeasureflow/.venv/bin/uv run uvicorn src.backend.main:app \
|
||||
--host 0.0.0.0 --port 8000 --workers ${WEB_CONCURRENCY} \
|
||||
--proxy-headers --forwarded-allow-ips='*'
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
@@ -314,12 +273,14 @@ sudo journalctl -u tiemeasureflow-api -f
|
||||
|
||||
## Client Deployment
|
||||
|
||||
Anche qui, Docker Compose è il metodo raccomandato; questa sezione è per lo
|
||||
sviluppo o un'installazione manuale senza container.
|
||||
|
||||
### Development Server
|
||||
|
||||
```bash
|
||||
cd client
|
||||
pip install -r requirements.txt
|
||||
flask run --host 0.0.0.0 --port 5000
|
||||
uv sync --extra client --extra dev
|
||||
cd src/frontend/flask_app && uv run --project ../../.. flask run --host 0.0.0.0 --port 5000
|
||||
```
|
||||
|
||||
Runs at `http://0.0.0.0:5000`
|
||||
@@ -327,16 +288,20 @@ Runs at `http://0.0.0.0:5000`
|
||||
### Compile Tailwind CSS (Optional)
|
||||
|
||||
```bash
|
||||
cd client
|
||||
cd src/frontend/flask_app
|
||||
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
||||
```
|
||||
|
||||
### Production Server (Gunicorn)
|
||||
### Production, senza Docker
|
||||
|
||||
Lo stesso comando del `Dockerfile.frontend` (gunicorn richiede la cwd dentro
|
||||
`flask_app` per risolvere `app:create_app()`):
|
||||
|
||||
```bash
|
||||
pip install gunicorn
|
||||
cd client
|
||||
gunicorn --workers 4 --bind 0.0.0.0:5000 app:app
|
||||
cd src/frontend/flask_app && uv run --project ../../.. gunicorn \
|
||||
--workers 5 --threads 4 --worker-class gthread --timeout 60 \
|
||||
--bind 0.0.0.0:5000 --forwarded-allow-ips='*' \
|
||||
"app:create_app()"
|
||||
```
|
||||
|
||||
### systemd Service (Linux)
|
||||
@@ -349,15 +314,14 @@ Description=TieMeasureFlow Web Client
|
||||
After=network.target tiemeasureflow-api.service
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
Type=simple
|
||||
User=tiemeasureflow
|
||||
WorkingDirectory=/opt/tiemeasureflow/client
|
||||
Environment="PATH=/opt/tiemeasureflow/venv/bin"
|
||||
Environment="FLASK_ENV=production"
|
||||
ExecStart=/opt/tiemeasureflow/venv/bin/gunicorn \
|
||||
--workers 4 \
|
||||
--bind 0.0.0.0:5000 \
|
||||
app:app
|
||||
WorkingDirectory=/opt/tiemeasureflow/src/frontend/flask_app
|
||||
EnvironmentFile=/opt/tiemeasureflow/.env
|
||||
ExecStart=/opt/tiemeasureflow/.venv/bin/uv run --project /opt/tiemeasureflow gunicorn \
|
||||
--workers 5 --threads 4 --worker-class gthread --timeout 60 \
|
||||
--bind 0.0.0.0:5000 --forwarded-allow-ips='*' \
|
||||
"app:create_app()"
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
@@ -372,8 +336,9 @@ WantedBy=multi-user.target
|
||||
|
||||
### Security Checklist
|
||||
|
||||
- [ ] Change all default passwords in `.env`
|
||||
- [ ] Change all default passwords in `.env` — con `docker-compose.yml`, `DB_ROOT_PASSWORD`, `DB_PASSWORD`, `SERVER_SECRET_KEY`, `CLIENT_SECRET_KEY` non hanno più un default: `docker compose config` si rifiuta di partire se mancano (introdotto dalla revisione del 21/09/2026)
|
||||
- [ ] Generate random `SERVER_SECRET_KEY` (32+ characters)
|
||||
- [ ] Set `APP_DOMAIN` to the real public domain (used by the Traefik router rules)
|
||||
- [ ] Set CORS origins to actual client domains
|
||||
- [ ] Enable SSL/HTTPS (see SSL/TLS section)
|
||||
- [ ] Configure firewall rules
|
||||
@@ -385,200 +350,140 @@ WantedBy=multi-user.target
|
||||
|
||||
### Recommended Architecture
|
||||
|
||||
Il progetto usa due reverse proxy diversi a seconda dell'installazione, non uno
|
||||
generico da configurare a mano:
|
||||
|
||||
- **On-premise, rete LAN, HTTP semplice** (`docker-compose.dev.yml`): Nginx,
|
||||
configurazione già pronta in [`nginx/nginx.conf`](../nginx/nginx.conf). È il
|
||||
caso di una stazione di misura sul reparto, senza certificato TLS.
|
||||
- **Produzione, dominio pubblico** (`docker-compose.yml`): Traefik, con
|
||||
certificati Let's Encrypt automatici. La configurazione sta nelle label
|
||||
Docker del servizio, non in un file a parte — vedi la sezione
|
||||
[Docker Deployment](#docker-deployment) sotto.
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Nginx Proxy │ (Port 80/443)
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────┴────┐
|
||||
│ │
|
||||
┌───▼──┐ ┌───▼──┐
|
||||
│API │ │Web │
|
||||
│:8000 │ │:5000 │
|
||||
└──────┘ └──────┘
|
||||
│ │
|
||||
└────┬─────┘
|
||||
│
|
||||
┌────▼────┐
|
||||
│ MySQL │
|
||||
│ :3306 │
|
||||
└─────────┘
|
||||
┌──────────────────────────┐
|
||||
On-premise (LAN) │ Nginx (:80) │
|
||||
└─────────────┬────────────┘
|
||||
│
|
||||
Produzione (dominio pubblico) → Traefik (:80/:443, TLS Let's Encrypt)
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ │
|
||||
┌─────▼─────┐ ┌──────▼─────┐
|
||||
│ server │ │ client │
|
||||
│ :8000 │ │ :5000 │
|
||||
└─────┬──────┘ └────────────┘
|
||||
│
|
||||
┌─────▼──────┐ ┌─────────────┐
|
||||
│ vision │ │ MySQL 8.0 │
|
||||
│ :8100 │ │ :3306 │
|
||||
│ (interno) │ └─────────────┘
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
### Nginx Configuration
|
||||
|
||||
Create `/etc/nginx/sites-available/tiemeasureflow`:
|
||||
|
||||
```nginx
|
||||
upstream tiemeasureflow_api {
|
||||
server 127.0.0.1:8000;
|
||||
}
|
||||
|
||||
upstream tiemeasureflow_web {
|
||||
server 127.0.0.1:5000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name yourdomain.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name yourdomain.com;
|
||||
|
||||
ssl_certificate /etc/ssl/certs/yourdomain.com.crt;
|
||||
ssl_certificate_key /etc/ssl/private/yourdomain.com.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
client_max_body_size 50M;
|
||||
|
||||
# API
|
||||
location /api/ {
|
||||
proxy_pass http://tiemeasureflow_api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Web Client
|
||||
location / {
|
||||
proxy_pass http://tiemeasureflow_web;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Static files (optional caching)
|
||||
location /static/ {
|
||||
proxy_pass http://tiemeasureflow_web;
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable and test:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/tiemeasureflow /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
Il worker di visione (`vision`) non pubblica porte verso l'esterno: lo
|
||||
raggiunge solo il `server`, sulla rete Docker interna `tmflow-net`.
|
||||
|
||||
---
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Docker Compose (Complete Stack)
|
||||
Docker Compose è il metodo raccomandato ed è quello usato in produzione. Il
|
||||
progetto ha **tre Dockerfile alla radice** del repository — non uno a
|
||||
sottocartella:
|
||||
|
||||
The project includes a `docker-compose.yml` for easy deployment:
|
||||
| File | Servizio | Base |
|
||||
|---|---|---|
|
||||
| `Dockerfile` | `server` (FastAPI) | `python:3.11.16-slim` |
|
||||
| `Dockerfile.frontend` | `client` (Flask) | `python:3.11.16-slim`, TailwindCSS compilato in uno stage `node:20.20.2-bookworm-slim` |
|
||||
| `Dockerfile.vision` | `vision` (worker di visione) | `python:3.13.15-slim` |
|
||||
|
||||
E due file compose:
|
||||
|
||||
- **`docker-compose.dev.yml`** — sviluppo/on-premise, con Nginx (vedi
|
||||
[Recommended Architecture](#recommended-architecture) sopra). 4 servizi:
|
||||
`mysql`, `server`, `vision`, `client`, `nginx`.
|
||||
- **`docker-compose.yml`** — produzione, con Traefik e TLS automatico. 4
|
||||
servizi: `mysql`, `server`, `vision`, `client`. Richiede la rete Docker
|
||||
esterna `traefik` (vedi `networks.traefik-net` nel file) e le variabili
|
||||
`APP_DOMAIN`, `DB_ROOT_PASSWORD`, `DB_PASSWORD`, `SERVER_SECRET_KEY`,
|
||||
`CLIENT_SECRET_KEY` valorizzate — senza default, `docker compose config` si
|
||||
rifiuta di procedere se mancano.
|
||||
|
||||
Il worker di visione dipende dal submodule `vendor/visionsuite`
|
||||
(`git clone --recurse-submodules`, vedi il [Quick Start del README](../README.md#quick-start-con-docker))
|
||||
e richiede `VISION_ENGINE_VERSION` valorizzata **prima** della build, altrimenti
|
||||
`Dockerfile.vision` fallisce di proposito:
|
||||
|
||||
```bash
|
||||
# Build images
|
||||
docker-compose build
|
||||
# Sviluppo / on-premise
|
||||
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
# Start services
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop services
|
||||
docker-compose down
|
||||
# Produzione
|
||||
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||
docker compose -f docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
### Manual Docker Build
|
||||
|
||||
#### API Server
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app/server
|
||||
|
||||
COPY server/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY server/ .
|
||||
COPY .env ..
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
Build and run:
|
||||
Comandi comuni (sintassi `docker compose` v2, senza trattino — il plugin, non
|
||||
il vecchio `docker-compose` standalone):
|
||||
|
||||
```bash
|
||||
docker build -f server/Dockerfile -t tiemeasureflow-api .
|
||||
docker run -p 8000:8000 --env-file .env tiemeasureflow-api
|
||||
docker compose -f docker-compose.yml ps
|
||||
docker compose -f docker-compose.yml logs -f server
|
||||
docker compose -f docker-compose.yml down
|
||||
```
|
||||
|
||||
#### Web Client
|
||||
### Aggiornare un'installazione esistente
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
Le immagini precedenti creavano l'utente non privilegiato senza una home
|
||||
directory (`useradd -u 10001 app`), e il volume `upload_data` che ne è
|
||||
risultato appartiene a `root`. Le immagini attuali creano l'utente con
|
||||
`-m` e impostano `UV_CACHE_DIR=/tmp/uv-cache`, ma questo non cambia da solo
|
||||
la proprietà di un volume già esistente: se si riparte con le nuove immagini
|
||||
su un volume creato da una versione precedente, il container `server` non
|
||||
riesce a scrivere sotto `/app/uploads`.
|
||||
|
||||
WORKDIR /app/client
|
||||
|
||||
COPY client/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY client/ .
|
||||
COPY .env ..
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["gunicorn", "--workers", "4", "--bind", "0.0.0.0:5000", "app:app"]
|
||||
```
|
||||
|
||||
Build and run:
|
||||
Prima del primo avvio con le nuove immagini, va quindi corretta la proprietà
|
||||
del volume:
|
||||
|
||||
```bash
|
||||
docker build -f client/Dockerfile -t tiemeasureflow-web .
|
||||
docker run -p 5000:5000 --env-file .env tiemeasureflow-web
|
||||
docker compose -f docker-compose.yml run --rm --user root --entrypoint chown server -R 10001:10001 /app/uploads
|
||||
```
|
||||
|
||||
(sostituire `docker-compose.yml` con `docker-compose.dev.yml` per
|
||||
un'installazione di sviluppo/on-premise). Il comando usa il servizio
|
||||
`server` perché è quello che monta il volume `upload_data` in lettura e
|
||||
scrittura; il worker di visione lo monta in sola lettura e non ne ha
|
||||
bisogno. Un'installazione nuova, che parte da un volume vuoto, non richiede
|
||||
questo passo.
|
||||
|
||||
---
|
||||
|
||||
## SSL/TLS
|
||||
|
||||
### Self-Signed Certificate (Development)
|
||||
Le due installazioni gestiscono il TLS in due modi diversi, e nessuno dei due
|
||||
richiede un certificato generato a mano:
|
||||
|
||||
```bash
|
||||
openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt -days 365 -nodes
|
||||
```
|
||||
- **Produzione** (`docker-compose.yml`): Traefik ottiene e rinnova da solo un
|
||||
certificato Let's Encrypt per `APP_DOMAIN`, tramite il `certresolver`
|
||||
configurato nelle label del servizio (`tls.certresolver=mytlschallenge`).
|
||||
Serve solo che `APP_DOMAIN` punti davvero al server (DNS) e che le porte
|
||||
80/443 siano raggiungibili da internet per la sfida ACME.
|
||||
- **On-premise, rete LAN** (`docker-compose.dev.yml`): Nginx serve in HTTP
|
||||
semplice, senza TLS — è il caso descritto in
|
||||
[Recommended Architecture](#recommended-architecture). Chi ha comunque
|
||||
bisogno di un certificato (es. una CA interna) lo aggiunge scommentando la
|
||||
sezione SSL già presente, ma commentata, in
|
||||
[`nginx/nginx.conf`](../nginx/nginx.conf).
|
||||
|
||||
### Let's Encrypt (Production)
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
sudo certbot certonly --nginx -d yourdomain.com
|
||||
```
|
||||
|
||||
### Configure in .env
|
||||
|
||||
```env
|
||||
SSL_CERTFILE=/etc/ssl/certs/yourdomain.com.crt
|
||||
SSL_KEYFILE=/etc/ssl/private/yourdomain.com.key
|
||||
```
|
||||
|
||||
### Start Server with SSL
|
||||
|
||||
```bash
|
||||
cd server
|
||||
gunicorn main:app \
|
||||
--workers 4 \
|
||||
--worker-class uvicorn.workers.UvicornWorker \
|
||||
--bind 0.0.0.0:8443 \
|
||||
--certfile /etc/ssl/certs/yourdomain.com.crt \
|
||||
--keyfile /etc/ssl/private/yourdomain.com.key
|
||||
```
|
||||
Un'installazione manuale (senza Docker, sezioni [Server](#server-deployment) e
|
||||
[Client Deployment](#client-deployment) sopra) che debba terminare il TLS da
|
||||
sola, dietro il proprio reverse proxy, può ancora valorizzare
|
||||
`SSL_CERTFILE`/`SSL_KEYFILE` in `.env` — variabili lasciate per questo caso,
|
||||
non lette da uvicorn/gunicorn nei comandi mostrati sopra: vanno passate al
|
||||
proprio reverse proxy o gestite fuori da questo progetto.
|
||||
|
||||
---
|
||||
|
||||
@@ -643,14 +548,11 @@ sqlalchemy.exc.OperationalError: (asyncmy.errors.ProgrammingError) (2003, "Can't
|
||||
|
||||
**Check:**
|
||||
```bash
|
||||
# Verify MySQL is running
|
||||
# Verify MySQL is running (con Docker: docker compose ps mysql, ha già un healthcheck)
|
||||
mysql -u tmflow -p -e "SELECT 1"
|
||||
|
||||
# Check .env variables
|
||||
# Check .env variables — non stampare mai la password: si controlla il valore, non la connection string
|
||||
grep DB_ .env
|
||||
|
||||
# Test connection string
|
||||
cd server && python -c "from config import settings; print(settings.database_url)"
|
||||
```
|
||||
|
||||
### Port Already in Use
|
||||
@@ -666,7 +568,7 @@ lsof -i :8000
|
||||
kill -9 <PID>
|
||||
|
||||
# Or use different port
|
||||
uvicorn main:app --port 8001
|
||||
uv run uvicorn src.backend.main:app --port 8001
|
||||
```
|
||||
|
||||
### Import Errors
|
||||
@@ -677,21 +579,20 @@ ModuleNotFoundError: No module named 'fastapi'
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
cd server
|
||||
pip install -r requirements.txt
|
||||
uv sync --extra server --extra client --extra dev
|
||||
```
|
||||
|
||||
### File Upload Not Working
|
||||
|
||||
```
|
||||
/app/server/uploads: Permission denied
|
||||
uploads: Permission denied
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Create uploads directory with correct permissions
|
||||
mkdir -p server/uploads
|
||||
chmod 755 server/uploads
|
||||
# Create uploads directory with correct permissions (radice del repo, non server/uploads)
|
||||
mkdir -p uploads/images uploads/pdfs uploads/logos uploads/reports
|
||||
chmod 755 uploads
|
||||
```
|
||||
|
||||
### Migrations Failed
|
||||
@@ -702,13 +603,12 @@ alembic.util.exc.CommandError: Can't locate revision identified by...
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
cd server
|
||||
# Check migration status
|
||||
alembic current
|
||||
uv run alembic -c src/backend/migrations/alembic.ini current
|
||||
|
||||
# Reset migrations (CAUTION - deletes data)
|
||||
alembic downgrade base
|
||||
alembic upgrade head
|
||||
uv run alembic -c src/backend/migrations/alembic.ini downgrade base
|
||||
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||
```
|
||||
|
||||
### API Key Not Working
|
||||
|
||||
@@ -10,7 +10,7 @@ TieMeasureFlow supports complete internationalization (i18n) with:
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
client/
|
||||
src/frontend/flask_app/
|
||||
├── translations/ # Flask-Babel translations
|
||||
│ ├── babel.cfg # Extraction config
|
||||
│ ├── it/LC_MESSAGES/
|
||||
@@ -99,7 +99,7 @@ flash(_("Profilo aggiornato con successo"))
|
||||
After editing .po files:
|
||||
|
||||
```bash
|
||||
cd client
|
||||
cd src/frontend/flask_app
|
||||
python compile_translations.py
|
||||
```
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Documentazione TieMeasureFlow
|
||||
|
||||
Indice della documentazione del progetto.
|
||||
|
||||
## Stato e direzione
|
||||
|
||||
| Documento | Scopo |
|
||||
|---|---|
|
||||
| [`../TieMeasureFlow_modifiche_2026-07-28.md`](../TieMeasureFlow_modifiche_2026-07-28.md) | **Il piano di lavoro corrente**: i quindici punti del sopralluogo del 28/07 e le decisioni in attesa del cliente (D-1…D-9). |
|
||||
| [`architecture/STATO_PROGETTO.md`](architecture/STATO_PROGETTO.md) | Cosa è fatto oggi (V3.0.0), punto per punto, e cosa non è ancora stato provato sul campo. |
|
||||
| [`architecture/ROADMAP.md`](architecture/ROADMAP.md) | Cosa resta, in ordine: collaudo, punto 4, innesto GAIA, installazione a Tràfilo. Include la mappatura con le Fasi rev04 di aprile. |
|
||||
| [`architecture/LAYOUT.md`](architecture/LAYOUT.md) | La cornice delle pagine: perché il layout si spostava fra le viste e la regola che lo tiene fermo. |
|
||||
| [`../src/frontend/flask_app/static/vendor/VERSIONS.md`](../src/frontend/flask_app/static/vendor/VERSIONS.md) | Librerie di terze parti in locale: versioni, impronte SHA-256, come aggiornarle. |
|
||||
|
||||
## Riferimenti operativi
|
||||
|
||||
| Documento | Scopo |
|
||||
|---|---|
|
||||
| [`API.md`](API.md) | Riferimento endpoint REST esposti dal backend FastAPI. |
|
||||
| [`DEPLOYMENT.md`](DEPLOYMENT.md) | Guida deploy su VPS (Hostinger/Tielogic con Traefik + Let's Encrypt). |
|
||||
| [`USER_GUIDE.md`](USER_GUIDE.md) | Manuale utente (operatore, maker, metrologist, admin). |
|
||||
| [`I18N_SETUP.md`](I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js). |
|
||||
| [`COLLAUDO.md`](COLLAUDO.md) | **Scaletta di collaudo V3.0.0**: cosa provare, in che ordine, con quali valori, e come si vede che è giusto. |
|
||||
|
||||
## Piani dettagliati TDD (rev04) — storici
|
||||
|
||||
> Il piano rev04 di aprile è **superato** dal documento del 28/07. La mappatura fra
|
||||
> le sue sette Fasi e i quindici punti correnti è in fondo a `architecture/ROADMAP.md`.
|
||||
|
||||
| Documento | Scopo |
|
||||
|---|---|
|
||||
| [`superpowers/plans/2026-04-17-rev04-master-roadmap.md`](superpowers/plans/2026-04-17-rev04-master-roadmap.md) | Master plan rev04 V1.0.7 → V1.1.0, le 7 fasi e le decisioni aperte. |
|
||||
| [`superpowers/plans/2026-04-17-rev04-phase1-stations.md`](superpowers/plans/2026-04-17-rev04-phase1-stations.md) | Piano TDD dettagliato Fase 1 (stazioni e identità per-tablet). **COMPLETATO.** |
|
||||
|
||||
## Specifiche esterne (binari)
|
||||
|
||||
| Documento | Scopo |
|
||||
|---|---|
|
||||
| [`specs/2026-04-16-schema-sviluppo-rev04.docx`](specs/2026-04-16-schema-sviluppo-rev04.docx) | Schema sviluppo SW TieFlow rev04-2026 fornito dal committente. |
|
||||
|
||||
## Storico
|
||||
|
||||
| Documento | Scopo |
|
||||
|---|---|
|
||||
| [`archive/2026-02-06-piano-implementazione-v1.md`](archive/2026-02-06-piano-implementazione-v1.md) | Piano implementazione V1.0.0 originale (riferimento storico). |
|
||||
|
||||
## Ulteriori riferimenti nel repo
|
||||
|
||||
- [`/CLAUDE.md`](../CLAUDE.md) — guidance per Claude Code (architettura, comandi, pattern critici).
|
||||
- [`/README.md`](../README.md) — entry point del progetto.
|
||||
Binary file not shown.
+89
-3
@@ -37,6 +37,8 @@ TieMeasureFlow is a web-based measurement management system that enables teams t
|
||||
| **Subtask** | Individual measurement point with tolerance limits (UTL/UWL/LWL/LTL) |
|
||||
| **Measurement** | Individual recorded value with automatic pass/fail/warning status |
|
||||
| **Lot/Serial** | Traceability fields linking measurements to physical parts |
|
||||
| **Station** | A physical measurement post (typically one tablet on a production line). Each station has a unique code (`STATION_CODE`) and a list of assigned recipes |
|
||||
| **Station assignment** | Many-to-many link between a station and the recipes available to the operators using that station's tablet |
|
||||
|
||||
### Architecture
|
||||
|
||||
@@ -175,23 +177,25 @@ TieMeasureFlow has four primary roles. Users can have multiple roles simultaneou
|
||||
|
||||
### Admin
|
||||
|
||||
**Purpose:** System administration and user management
|
||||
**Purpose:** System administration, user management and station deployment
|
||||
|
||||
**Permissions:**
|
||||
- Create/edit/delete users
|
||||
- Assign roles to users
|
||||
- Regenerate user API keys
|
||||
- Create/edit/delete stations and assign recipes to them
|
||||
- Configure system settings
|
||||
- Upload company logo
|
||||
- Manage CSV export settings
|
||||
|
||||
**Access:**
|
||||
- Menu: **Admin** → User Management
|
||||
- Can see: All users and system settings
|
||||
- Menu: **Admin** → Utenti / Stazioni
|
||||
- Can see: All users, all stations and system settings
|
||||
|
||||
**Typical Tasks:**
|
||||
- Onboard new users
|
||||
- Reset lost API keys
|
||||
- Roll out a new tablet: create the matching station, assign recipes, hand the `STATION_CODE` to devops
|
||||
- Configure locale/format settings
|
||||
- Upload company branding
|
||||
- Manage system access
|
||||
@@ -276,6 +280,12 @@ When you edit a recipe (add/remove tasks, change tolerances), a **new version**
|
||||
|
||||
## MeasurementTec Workflow
|
||||
|
||||
### Recipes you see are filtered by station
|
||||
|
||||
Each tablet/PC running the Flask client is configured at deployment time with a `STATION_CODE` environment variable (for example `ST-LINEA-A`). Whenever you open **Select Recipe**, the page only lists recipes that the admin has assigned to that station. If your tablet shows fewer recipes than you expect, ask the admin to assign the missing recipes to your station — see **Admin Workflow → Station Management**.
|
||||
|
||||
If the page shows "Stazione non configurata" (HTTP 503), the deployment is missing the `STATION_CODE` setting; this is a deploy-time configuration issue, not something the operator can fix from the UI.
|
||||
|
||||
### Select a Recipe
|
||||
|
||||
**Method 1: Search**
|
||||
@@ -503,6 +513,82 @@ If user loses their API key:
|
||||
4. New key is generated and displayed
|
||||
5. Provide new key to user (display once only)
|
||||
|
||||
### Station Management
|
||||
|
||||
Stations are how TieMeasureFlow enforces "this tablet is responsible for these measurements". Each physical measurement post in the shop floor is modelled as a station; each tablet's Flask client identifies itself through a `STATION_CODE` env var that must match a station's code in the database.
|
||||
|
||||
The page is reachable from the navbar entry **"Stazioni"** (workstation icon) for any user with the admin flag, or directly at `/admin/stations`.
|
||||
|
||||
#### Mental model
|
||||
|
||||
- **One station = one tablet/PC** in the shop floor. The station's identity (`STATION_CODE`) is configured **once at deploy time** in the tablet's `.env`, never changed at runtime.
|
||||
- A station has a list of **assigned recipes**. The operator using that tablet sees exactly that list — no more, no less.
|
||||
- A recipe can be assigned to several stations (e.g. a calibration recipe everyone needs).
|
||||
- A station can be temporarily **disabled** (`active = false`) to take a line offline without losing its history; tablets pointing at a disabled station get HTTP 404 from the recipe endpoint.
|
||||
|
||||
#### Create a Station
|
||||
|
||||
1. Navigate to **Admin** → **Stazioni**
|
||||
2. Click **Nuova Stazione**
|
||||
3. Fill in the modal:
|
||||
- **Codice** (required, unique): `ST-LINEA-A`. This is the value the tablet will set as `STATION_CODE` in its `.env`. Use ASCII letters, digits and hyphens only — no spaces, no accented characters. Once created the code cannot be changed (changing it would break every tablet pointing at it).
|
||||
- **Nome** (required): human-readable description, e.g. `Linea A — Tornitura alberi`.
|
||||
- **Postazione** (optional): physical location, e.g. `Reparto 2 — Cella 3`.
|
||||
- **Note** (optional): free text, useful for tracking who set up the station.
|
||||
- **Attiva** (default checked): uncheck only when retiring the station.
|
||||
4. Click **Crea Stazione**
|
||||
|
||||
Naming convention: prefix every station code with `ST-` and use a stable identifier that survives shop-floor reorganisations.
|
||||
|
||||
#### Assign Recipes to a Station
|
||||
|
||||
1. From the stations table, click the **checklist icon** on the row of the target station
|
||||
2. The "Ricette Assegnate" modal opens with two columns:
|
||||
- **Ricette disponibili** (left): every recipe in the system not yet assigned to this station. Each row has an inline **+ Assegna** button that immediately moves the recipe to the right column.
|
||||
- **Assegnate alla stazione** (right): the list the operator at this station's tablet will see. The **X** button removes the assignment.
|
||||
3. Use the search field at the top to narrow either column by recipe code or name
|
||||
4. Empty-state hints tell you why a column is empty:
|
||||
- "Tutte le ricette sono già assegnate" — nothing more to assign
|
||||
- "Nessuna ricetta nel sistema" — create at least one recipe first (Maker workflow)
|
||||
- "Nessun risultato per il filtro" — clear the search
|
||||
|
||||
The assignment audit trail (`assigned_by`, `assigned_at`) is stored on the server but not currently shown in the UI.
|
||||
|
||||
#### Edit a Station
|
||||
|
||||
1. Click the **pencil icon** (or the row itself) for the target station
|
||||
2. Update **Nome**, **Postazione**, **Note** or the **Attiva** flag
|
||||
3. Click **Salva Modifiche**
|
||||
|
||||
The **Codice** field is read-only on edit because the value is contractually bound to the `STATION_CODE` set on already-deployed tablets. To rename a station you must delete it and create a new one with the new code, then update every affected tablet's `.env`.
|
||||
|
||||
#### Delete a Station
|
||||
|
||||
1. Click the **trash icon** for the station
|
||||
2. Confirm in the modal
|
||||
|
||||
Deletion cascades to **all recipe assignments** for that station. Existing measurements collected by tablets at that station are not affected (measurements are linked to recipe versions, not stations).
|
||||
|
||||
#### The `ST-DEFAULT` station
|
||||
|
||||
The first time `/api/setup/seed` runs, it creates a station called `ST-DEFAULT` and assigns every demo recipe to it. This is intended for single-tablet demos and dev setups where no per-station segmentation is needed.
|
||||
|
||||
In multi-station production deployments you should either:
|
||||
|
||||
- Delete `ST-DEFAULT` once your real stations are configured, or
|
||||
- Disable it (`Attiva` off), to prevent a misconfigured tablet from accidentally inheriting the default assignment set.
|
||||
|
||||
#### Tablet deployment cheat sheet
|
||||
|
||||
For each new physical tablet:
|
||||
|
||||
1. Admin creates the station in the UI (e.g. `ST-LINEA-A`)
|
||||
2. Admin assigns the relevant recipes
|
||||
3. Devops sets `STATION_CODE=ST-LINEA-A` in the tablet's `.env`
|
||||
4. Tablet container starts; the operator opens **Measure → Select Recipe** and sees the curated list
|
||||
|
||||
If step 3 is missed, **Select Recipe** shows the page "Stazione non configurata" — this is the intentional fail-fast behaviour to prevent a tablet from silently falling back to the wrong recipe set.
|
||||
|
||||
### System Settings
|
||||
|
||||
#### Configure CSV Export
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Cornice delle pagine
|
||||
|
||||
Nasce dal punto 14 del documento del 28/07: *«le dimensioni delle viste cambiano a
|
||||
seconda del menu; passando da una schermata all'altra la finestra non mantiene
|
||||
proporzioni stabili»*. La segnalazione era di un operatore e non era ancora stata
|
||||
circoscritta sul codice. Questo è l'esito della circoscrizione e la regola che ne
|
||||
è uscita.
|
||||
|
||||
## Cosa succedeva
|
||||
|
||||
Tre meccanismi distinti, tutti verificabili sul codice senza avere il tablet in mano.
|
||||
|
||||
**1. Ogni vista si dichiarava la propria larghezza.** Sette valori diversi su
|
||||
diciassette template, e nessuno coincideva con quello della navbar, che sta a
|
||||
`max-w-7xl` su tutte le pagine. Il contenuto risultava quindi disallineato dalla
|
||||
barra sopra di sé, e il disallineamento cambiava da pagina a pagina.
|
||||
|
||||
| vista | prima | dopo |
|
||||
|---|---|---|
|
||||
| `measure/select_recipe.html` | `max-w-7xl` `py-8` | `tmf-page` |
|
||||
| `measure/task_list.html` | `max-w-5xl` `py-8` | `tmf-page` |
|
||||
| `measure/task_complete.html` | `max-w-7xl` `py-6` | `tmf-page` |
|
||||
| `maker/recipe_list.html` | `max-w-6xl` `py-8` | `tmf-page` |
|
||||
| `maker/task_editor.html` | `max-w-5xl` `py-8` | `tmf-page` |
|
||||
| `maker/task_drawing.html` | `max-w-5xl` `py-8` | `tmf-page` |
|
||||
| `maker/recipe_preview.html` | `max-w-5xl` `py-8` | `tmf-page` |
|
||||
| `admin/stations.html` | `max-w-7xl` `py-6` | `tmf-page` |
|
||||
| `admin/users.html` | `max-w-7xl` `py-6` | `tmf-page` |
|
||||
| `statistics/dashboard.html` | `max-w-7xl` `py-6` | `tmf-page` |
|
||||
| `admin/settings.html` | `max-w-3xl` `py-6` | `tmf-page tmf-page-narrow` |
|
||||
| `auth/profile.html` | `max-w-4xl` `py-8` | `tmf-page tmf-page-narrow` |
|
||||
| `maker/recipe_editor.html` | `max-w-4xl` `py-8` | `tmf-page tmf-page-narrow` |
|
||||
| `maker/version_history.html` | `max-w-4xl` `py-8` | `tmf-page tmf-page-narrow` |
|
||||
| `errors/station_not_configured.html` | `max-w-2xl` `py-8` | `tmf-page tmf-page-narrow` |
|
||||
|
||||
Il percorso dell'operatore — quello da cui è arrivata la segnalazione — faceva
|
||||
`1280px → 1024px → tutto schermo → 1280px` in quattro passaggi.
|
||||
|
||||
**2. Il padding verticale non era lo stesso.** `py-8` e `py-6` mescolati fra viste
|
||||
consecutive: la prima card si trovava a un'altezza diversa a ogni cambio di
|
||||
schermata.
|
||||
|
||||
**3. La barra di scorrimento appariva e spariva.** È una barra classica da 8px
|
||||
(`themes.css`, `::-webkit-scrollbar`), quindi occupa spazio nel layout. Una pagina
|
||||
lunga la mostrava, una corta no, e la schermata di misura la toglie sempre
|
||||
(`body { overflow: hidden }`): a ogni navigazione tutto il contenuto centrato —
|
||||
navbar compresa — si spostava di 8px in orizzontale.
|
||||
|
||||
## La regola
|
||||
|
||||
Due classi in `static/css/themes.css`, nessuna larghezza nei template.
|
||||
|
||||
- **`.tmf-page`** — la cornice: `max-width: 80rem`, padding `1rem / 1.5rem / 2rem`
|
||||
ai tre breakpoint, `padding-block: 1.5rem`. Sono esattamente i valori della
|
||||
navbar (`max-w-7xl px-4 sm:px-6 lg:px-8`), così il contenuto ci si allinea.
|
||||
- **`.tmf-page-narrow`** — si aggiunge alla prima e stringe a `56rem`. È per i
|
||||
moduli: un campo di testo largo 1280px non si compila meglio.
|
||||
- **`html { scrollbar-gutter: stable }`** — lo spazio della barra è riservato
|
||||
sempre, anche dove la pagina non scorre.
|
||||
- **`body.h-screen { height: 100dvh }`** — la schermata di misura è alta quanto la
|
||||
finestra vera. `100vh` su Android e iOS è l'altezza che la pagina avrebbe con la
|
||||
barra dell'indirizzo nascosta: su un tablet il piede della schermata — dove
|
||||
stanno «Fine ciclo misura» e il tastierino — finiva sotto il bordo, e ricompariva
|
||||
quando la barra si ritraeva.
|
||||
|
||||
Due larghezze in tutto il prodotto al posto di sette, e il criterio è il tipo di
|
||||
pagina: liste, tabelle e tele di disegno stanno larghe, i moduli stanno stretti.
|
||||
|
||||
## Le due eccezioni
|
||||
|
||||
Hanno geometria propria per un motivo, e sono elencate in
|
||||
`tests/test_layout_shell.py` perché non sembrino dimenticate.
|
||||
|
||||
- **`auth/login.html`** — schermata piena senza navbar: non c'è niente a cui
|
||||
allinearsi.
|
||||
- **`measure/task_execute.html`** — la schermata di misura è un pannello a tutta
|
||||
altezza che non deve scorrere mentre un operatore sta misurando
|
||||
(`h-screen overflow-hidden`, footer nascosto). È l'unica vista in cui il cambio
|
||||
di forma è voluto, ed è anche l'unica in cui l'operatore si ferma a lavorare:
|
||||
entrarci e uscirne resta un salto, ma ora è l'unico.
|
||||
|
||||
## Cosa resta da verificare sul campo
|
||||
|
||||
La segnalazione non è mai stata riprodotta su un dispositivo: quanto sopra viene
|
||||
dalla lettura del codice, non da una prova. Quello che è dimostrato è che il
|
||||
layout *poteva* muoversi per quattro motivi distinti e che ora non può più per
|
||||
nessuno dei quattro.
|
||||
|
||||
Se l'operatore vedesse ancora spostamenti, il sospetto successivo è la **tastiera
|
||||
virtuale**: quando compare, il browser ridimensiona la finestra, e con `100dvh` la
|
||||
schermata di misura si ridimensiona con lei invece di scorrere sotto. È il
|
||||
comportamento giusto per il tastierino a schermo, ma va guardato con un calibro
|
||||
USB collegato, dove la tastiera di sistema non dovrebbe comparire affatto.
|
||||
|
||||
Da chiedere all'operatore che ha aperto la segnalazione: **su quale schermo** l'ha
|
||||
vista. Su un tablet in verticale la maggior parte delle larghezze qui sopra
|
||||
collassa allo stesso valore e il difetto non si vede; su un monitor da scrivania
|
||||
si vede tutto.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Roadmap TieMeasureFlow — V3.0.0 → installazione a Tràfilo
|
||||
|
||||
> Aggiornata al 2026-07-28. Aggiornare a ogni punto chiuso.
|
||||
|
||||
## Dove siamo
|
||||
|
||||
La roadmap rev04 di aprile (Fasi 1-7, milestone M1/M2) è **superata dai fatti**: il
|
||||
sopralluogo del 28/07 ha prodotto un elenco di quindici punti concreti che è oggi il
|
||||
piano di lavoro. Buona parte delle vecchie Fasi 2 e 4 è dentro quei punti ed è già
|
||||
fatta; il resto è confluito o decaduto. La mappatura è in fondo, per non perdere il
|
||||
filo con i documenti di aprile.
|
||||
|
||||
- Piano corrente: [`../../TieMeasureFlow_modifiche_2026-07-28.md`](../../TieMeasureFlow_modifiche_2026-07-28.md)
|
||||
- Stato di dettaglio: [`STATO_PROGETTO.md`](STATO_PROGETTO.md)
|
||||
- Piano rev04 (storico): [`../superpowers/plans/2026-04-17-rev04-master-roadmap.md`](../superpowers/plans/2026-04-17-rev04-master-roadmap.md)
|
||||
|
||||
**Scadenza che comanda tutto: l'installazione on-premise a Tràfilo è prevista per
|
||||
settembre 2026.**
|
||||
|
||||
## I quindici punti
|
||||
|
||||
| # | Punto | Stato |
|
||||
|---|---|---|
|
||||
| 1 | Memoria della produzione in corso | ✅ fatto |
|
||||
| 2 | Tipo di task esplicito | ✅ fatto |
|
||||
| 3 | Loop di misura e ripetizione | ✅ fatto |
|
||||
| 4 | Limite di tentativi prima del capoturno | ⛔ **fermo** su D-6, D-7, D-9 |
|
||||
| 5 | Avanzamento solo se in tolleranza | ✅ fatto |
|
||||
| 6 | Fermo linea e Fine produzione con effetto | ✅ fatto (innesto GAIA fermo su D-1, D-2) |
|
||||
| 7 | Gestione delle stazioni | ✅ fatto |
|
||||
| 8 | Tracciabilità obbligatoria | ✅ fatto |
|
||||
| 9 | Blocco dell'inserimento manuale | ✅ fatto |
|
||||
| 10 | Interfaccia operatore: sequenza e conferme | ✅ fatto |
|
||||
| 11 | Formattazione delle descrizioni | ✅ fatto |
|
||||
| 12 | Funzionamento senza internet | ✅ fatto |
|
||||
| 13 | Generazione task dalla scheda tecnica con l'AI | — fuori offerta (quotazione su D-8) |
|
||||
| 14 | Stabilità del layout | ✅ fatto |
|
||||
| 15 | Statistica separata dalla registrazione | ✅ fatto |
|
||||
|
||||
## Cosa resta, in ordine
|
||||
|
||||
### 1. Collaudo sul campo — *da fare adesso, non serve nessuna risposta*
|
||||
|
||||
È il lavoro più urgente rimasto, ed è l'unico che non dipende da nessuno. Tredici
|
||||
punti sono in esercizio e **nessuno li ha percorsi su un tablet**.
|
||||
|
||||
Le ricette `COLLAUDO-A` e `COLLAUDO-B` sono già sul sistema, con un utente
|
||||
`capoturno` (`scripts/seed_collaudo.py`). Da verificare, in quest'ordine:
|
||||
|
||||
| Cosa | Come si vede che è giusto |
|
||||
|---|---|
|
||||
| Ciclo di misura (3) | Il conto alla rovescia sopravvive al cambio task, prosegue in rosso oltre lo zero, riporta alla misura |
|
||||
| Fuori tolleranza (5) | Con `12.00` sulla prima quota non si passa alla seconda finché il capoturno non autorizza, o finché non si rimisura dentro |
|
||||
| Tracciabilità (8) | Su `COLLAUDO-B` l'avvio non parte finché lotto e seriale non ci sono |
|
||||
| Inserimento manuale (9) | Su `COLLAUDO-B` il tastierino numerico non c'è affatto |
|
||||
| Sequenza (10) | La ricetta si apre sul primo task; un task lasciato a metà appare «Incompiuto 1/2» nella lista |
|
||||
| Senza rete (12) | Staccare la rete e percorrere login → ricetta → task → annotazione → statistiche → report, console aperta |
|
||||
| Layout (14) | **Da un monitor da scrivania**, non solo dal tablet: è lì che il difetto si vedeva |
|
||||
|
||||
Il giro del punto 12 va fatto **ora**, perché la Content-Security-Policy è appena
|
||||
entrata in vigore: se una libreria avesse bisogno di un permesso non concesso, il
|
||||
sintomo compare adesso.
|
||||
|
||||
### 2. Punto 4 — appena arrivano le risposte
|
||||
|
||||
Numero di tentativi prima del capoturno. Serve sapere **D-7** (quanti, e se uguali
|
||||
per tutte le ricette), **D-6** (password, PIN o badge) e **D-9** (se cambiare i
|
||||
parametri crea una nuova versione). Il resto dell'impianto è pronto: il blocco per
|
||||
fuori tolleranza e l'autorizzazione del capoturno esistono già, manca il contatore.
|
||||
|
||||
Stima, a decisioni chiuse: **2-3 giorni**.
|
||||
|
||||
### 3. Innesto verso GAIA — fermo su D-1 e D-2
|
||||
|
||||
Avvio produzione, fermo linea e fine produzione hanno già lo stato e gli eventi; la
|
||||
fine produzione emette già il file di statistica. Manca solo il canale verso il
|
||||
gestionale, e non si può nemmeno disegnare finché non si sa **come** si parla con
|
||||
GAIA (D-1) e **da dove** (D-2).
|
||||
|
||||
Stima, a protocollo noto: **1-2 settimane**, molto dipendente dalla risposta.
|
||||
|
||||
### 4. Installazione a Tràfilo — settembre
|
||||
|
||||
| Cosa | Blocco |
|
||||
|---|---|
|
||||
| Macchina server, spazio disco | D-4 |
|
||||
| Immagini Docker portate in reparto, non repository da compilare sul posto | — |
|
||||
| Colonnina luminosa, se serve | D-5 |
|
||||
| Validazione rete e macchine con l'IT | D-4 |
|
||||
|
||||
Nota: **la costruzione delle immagini richiede rete** (npm, apt, uv); è l'esecuzione
|
||||
a non richiederla. In reparto va portata l'immagine già costruita.
|
||||
|
||||
## Decisioni in attesa del cliente
|
||||
|
||||
Da girare a Tràfilo tramite Menoncin. Le prime due e la D-4 hanno l'orizzonte di
|
||||
settembre; D-6 e D-7 bloccano lavoro che sappiamo già fare.
|
||||
|
||||
| ID | Decisione | Blocca | Chi risponde |
|
||||
|---|---|---|---|
|
||||
| **D-1** | Protocollo del gestionale GAIA: servizi web, database condiviso, file? | innesto GAIA (punto 6) | IT Tràfilo + fornitore GAIA |
|
||||
| **D-2** | Rete e credenziali per raggiungere GAIA | come sopra | IT Tràfilo |
|
||||
| ~~D-3~~ | ~~Una app per stazione o una sola?~~ **Decisa il 28/07**: dati sul server, app di stazione su ogni PC | — | chiusa; validazione in D-4 |
|
||||
| **D-4** | Server: quale macchina, quanto spazio disco (database **e** disegni) | installazione | IT Tràfilo |
|
||||
| **D-5** | Cicalino: basta il suono del browser o serve una colonnina luminosa? | punto 3 (completamento) | Tràfilo |
|
||||
| **D-6** | Autorizzazione capoturno: password come oggi, o PIN / badge? Venti volte al giorno la password è attrito | punti 4, 5, 6 | Tràfilo |
|
||||
| **D-7** | Quanti tentativi prima del capoturno, e uguali per tutte le ricette? | punto 4 | Tràfilo |
|
||||
| **D-8** | Schede tecniche: quante, formato standard, conversione una-tantum o funzione permanente? | punto 13 e la sua quotazione | Tràfilo |
|
||||
| **D-9** | Cambiare i parametri di una ricetta (timer, tentativi) crea una nuova versione? | punti 3, 4 | noi, con conferma cliente |
|
||||
|
||||
Una decisione già presa che va **confermata**: la migrazione 009 ha impostato
|
||||
`allow_manual_input = 1` su tutte le ricette esistenti, per conservare il
|
||||
comportamento in essere invece di irrigidire di colpo ricette già in uso. Va deciso
|
||||
ricetta per ricetta quali devono passare a solo calibro.
|
||||
|
||||
## Tech debt
|
||||
|
||||
| Item | Priorità | Note |
|
||||
|---|---|---|
|
||||
| `components/barcode_scanner.html` dipende da `html5-qrcode`, mai caricata | Media | Codice morto: il componente non è incluso da nessuna parte e il lettore che l'operatore usa è un campo di testo. Da rimuovere, o da completare portando la libreria in `static/vendor/` |
|
||||
| Pagina `task_complete`: riga vuota segnalata in alcuni scenari | Media | Segnalazione di aprile mai riprodotta. Da verificare durante il collaudo, ora che il flusso è cambiato |
|
||||
| `.env` rename a convenzione spec (SERVICE_NAME, SERVICE_DOMAIN, API_KEY) | Bassa | Rinviato: impatta i deploy esistenti |
|
||||
| Header `X-API-Key` → `X-Api-Key` | Bassa | Breaking per i deploy esistenti |
|
||||
| Envelope risposta `{success,data,error}` | Bassa | Eventuale API v2 |
|
||||
| Test di carico a 20 tablet reali | Bassa | La capacità è dimensionata ma mai misurata sotto carico vero |
|
||||
|
||||
I quattro test rotti tracciati nello snapshot V2.0.0 non risultano più: la suite è a
|
||||
360 pass, 0 fail.
|
||||
|
||||
## Mappatura con la roadmap rev04 di aprile
|
||||
|
||||
Per chi torna sui documenti di aprile e non ritrova le Fasi.
|
||||
|
||||
| Fase rev04 | Che fine ha fatto |
|
||||
|---|---|
|
||||
| 1 — Stazioni per-tablet | Chiusa in V2.0.0, ampliata dal punto 7 |
|
||||
| 2 — Ruolo Capoturno + override | Assorbita dai punti 5 e 6: il ruolo `Supervisor` esiste e l'autorizzazione resta scritta sulla misura. L'override a token breve dipende da D-6 |
|
||||
| 3 — Editor ricetta a blocchi | Sostituita dal punto 2 (tipo di task dichiarato), che risolve il problema vero senza riscrivere l'editor |
|
||||
| 4 — Workflow operatore | Assorbita dai punti 3, 4, 10: timer e sequenza sono fatti, i tentativi sono il punto 4 |
|
||||
| 5 — Import GAIA | Diventata l'innesto del punto 6, ferma su D-1 e D-2 |
|
||||
| 6 — Deploy industriale (registry + Watchtower) | Ridimensionata: con una rete isolata l'aggiornamento automatico non ha senso. Restano immagini versionate portate a mano |
|
||||
| 7 — Hardening | Parzialmente assorbita: CSP, versioni congelate e impronte sono entrate col punto 12 |
|
||||
|
||||
Le vecchie decisioni `D-0.x` sono confluite nelle `D-x` qui sopra: D-0.1→D-1,
|
||||
D-0.2→D-2, D-0.4→D-5, D-0.5→D-9, D-0.6→D-6. D-0.8 (nome del ruolo capoturno) è
|
||||
chiusa: si chiama `Supervisor`. D-0.7 (auto-logout) era già risolta.
|
||||
@@ -0,0 +1,192 @@
|
||||
# Stato Progetto TieMeasureFlow — V3.0.0
|
||||
|
||||
> Snapshot al 2026-07-28. Aggiornare ad ogni milestone.
|
||||
> Lo snapshot V2.0.0 è in [`../archive/STATO_PROGETTO_V2.0.0_2026-04-27.md`](../archive/STATO_PROGETTO_V2.0.0_2026-04-27.md).
|
||||
|
||||
## Versione corrente
|
||||
|
||||
**V3.0.0** (in sviluppo, branch `V3.0.0`). Versione precedente: `V2.0.0`.
|
||||
|
||||
In esercizio su `tieflow.tielogic.xyz` (Docker Compose + Traefik + Let's Encrypt),
|
||||
schema alla migrazione `010_meas_authorisation`.
|
||||
|
||||
## Sintesi esecutiva
|
||||
|
||||
V2.0.0 aveva chiuso stazioni per-tablet, ristrutturazione monorepo con `uv` e la
|
||||
tenuta multi-tablet. **V3.0.0 nasce dal sopralluogo del 28/07 e lavora sui quindici
|
||||
punti** raccolti in [`TieMeasureFlow_modifiche_2026-07-28.md`](../../TieMeasureFlow_modifiche_2026-07-28.md).
|
||||
|
||||
Il filo che li tiene insieme: fino a V2.0.0 il sistema *registrava* misure ma non
|
||||
*governava* la produzione. Le regole vivevano nell'interfaccia — una modale che si
|
||||
poteva chiudere, un timer che moriva al cambio pagina, un tipo di task dedotto dalla
|
||||
presenza di quote. V3.0.0 le sposta sul server, dove non si aggirano.
|
||||
|
||||
Tredici punti su quindici sono fatti e in esercizio. Uno è fermo sulle risposte del
|
||||
cliente, uno è fuori offerta.
|
||||
|
||||
## I quindici punti
|
||||
|
||||
| # | Punto | Stato | Dove vive |
|
||||
|---|---|---|---|
|
||||
| 1 | Memoria della produzione in corso | ✅ | `production_runs`, `production_service` |
|
||||
| 2 | Tipo di task esplicito | ✅ | `RecipeTask.task_type`, migrazione 007 |
|
||||
| 3 | Loop di misura e ripetizione | ✅ | `production-clock.js`, `production_service`, migrazione 008 |
|
||||
| 4 | Limite di tentativi prima del capoturno | ⛔ **Fermo su D-6, D-7, D-9** | — |
|
||||
| 5 | Avanzamento solo se in tolleranza | ✅ | `measurement_service.pending_authorisation`, migrazione 010 |
|
||||
| 6 | Fermo linea e Fine produzione con effetto | ✅ | `production_service`, `production_export_service` |
|
||||
| 7 | Gestione delle stazioni | ✅ | `station_service`, `/admin/stations` |
|
||||
| 8 | Tracciabilità obbligatoria | ✅ | `Recipe.requires_lot/serial`, migrazione 009 |
|
||||
| 9 | Blocco dell'inserimento manuale | ✅ | `Recipe.allow_manual_input`, `numpad.js` |
|
||||
| 10 | Interfaccia operatore: sequenza e conferme | ✅ | `/measure/start`, `task-progress`, `task_list`, `task_execute` |
|
||||
| 11 | Formattazione delle descrizioni | ✅ | filtro `rich_text`, `rich-text.js` |
|
||||
| 12 | Funzionamento senza internet | ✅ | `static/vendor/`, CSP in `app.py` |
|
||||
| 13 | Generazione task dalla scheda tecnica con l'AI | — **Fuori offerta** | — |
|
||||
| 14 | Stabilità del layout | ✅ | `.tmf-page` in `themes.css`, [`LAYOUT.md`](LAYOUT.md) |
|
||||
| 15 | Statistica separata dalla registrazione | ✅ | `@role_required("Metrologist")`, test dedicato |
|
||||
|
||||
## Cosa è entrato in V3.0.0
|
||||
|
||||
### Produzione come stato, non come pulsante (punti 1, 6)
|
||||
|
||||
- Tabelle `production_runs` e `production_events`; una produzione si apre su una
|
||||
stazione, accumula eventi (`start`, `cycle_completed`, `task_measured`,
|
||||
`remeasure`, `line_stop`, `resume`, `close`) e si chiude.
|
||||
- Fermo linea, ripresa e fine produzione richiedono le credenziali del capoturno e
|
||||
**cambiano lo stato**: prima erano modali che si chiudevano.
|
||||
- La fine produzione emette il file di statistica dell'intera produzione
|
||||
(`production_export_service`), CSV con i delimitatori configurati, marcando le
|
||||
misure come esportate. Il punto d'innesto verso GAIA è lì accanto, in attesa di
|
||||
D-1 e D-2.
|
||||
|
||||
### Il ciclo di misura (punto 3)
|
||||
|
||||
- L'intervallo della ricetta è calcolato dal server (`seconds_to_next_measurement`,
|
||||
`overdue`, `server_time`): sopravvive al cambio task, che è un ricaricamento di
|
||||
pagina, e non si azzera cambiando schermata.
|
||||
- Il conteggio **prosegue oltre lo zero**: il ritardo si vede, in rosso.
|
||||
- A scadenza l'operatore viene riportato al primo task di misura della ricetta, con
|
||||
cinque secondi di preavviso; chi sta già misurando viene lasciato finire.
|
||||
- Cicalino via WebAudio, nessun file audio da caricare.
|
||||
- `remeasure` distingue una seconda lettura dello stesso pezzo da un ciclo nuovo:
|
||||
girare il pezzo e rimisurare non deve far ripartire l'intervallo.
|
||||
|
||||
### Le regole della ricetta (punti 8, 9)
|
||||
|
||||
- `requires_lot`, `requires_serial`, `allow_manual_input` per ricetta, verificati in
|
||||
`measurement_service`: **ogni porta d'ingresso passa di lì**, barcode compreso.
|
||||
- Con `allow_manual_input` a falso il tastierino non viene disegnato — non nascosto:
|
||||
il markup non esiste — e il server rifiuta comunque un valore digitato a mano.
|
||||
- Il rilevamento del calibro USB è stato reso più tollerante (Enter veloce), perché
|
||||
con la regola stretta una lettura corta come `9.5` veniva scambiata per digitazione.
|
||||
|
||||
### Il fuori tolleranza (punto 5)
|
||||
|
||||
- Una quota fuori tolleranza e non autorizzata **blocca** il salvataggio della quota
|
||||
successiva e la chiusura del ciclo (409).
|
||||
- Conta l'**ultima** lettura di ogni quota: rimisurare la stessa quota resta
|
||||
possibile — il calibro scivola, il pezzo si riposiziona — e una lettura dentro i
|
||||
limiti scioglie il blocco senza chiamare nessuno.
|
||||
- L'autorizzazione del capoturno resta scritta sulla misura (`supervisor_id`,
|
||||
`authorised_at`) e finisce in due colonne del CSV di produzione: il file mostra il
|
||||
guasto **e** la decisione.
|
||||
- Quanti tentativi siano ammessi è il punto 4, non questo.
|
||||
|
||||
### L'interfaccia dell'operatore (punti 10, 11, 14, 15)
|
||||
|
||||
- La ricetta si apre sul primo task (`/measure/start/<id>`), non su un elenco. La
|
||||
lista scende a secondo livello e mostra quali task sono rimasti incompiuti
|
||||
(`GET /api/measurements/task-progress`, contato per quota e non per tentativo).
|
||||
- «Fine ciclo misura» è visibile da subito e spento finché mancano quote, con
|
||||
scritto quante ne mancano.
|
||||
- Descrizioni con `**grassetto**` e a capo: marcatura, non HTML, così la
|
||||
sanificazione è per costruzione.
|
||||
- Una cornice sola per tutte le viste, allineata alla navbar — vedi [`LAYOUT.md`](LAYOUT.md).
|
||||
- Nessun percorso dell'operatore porta alla statistica, ed è un test.
|
||||
|
||||
### Fuori dalla rete (punto 12)
|
||||
|
||||
- Alpine, Plotly, PDF.js (+ worker), Fabric e i font sono nell'installazione, con
|
||||
versione nel nome e impronta SHA-256 verificata da un test.
|
||||
- Content-Security-Policy a sola origine locale servita **dal client Flask**: prima
|
||||
esisteva solo sul backend, cioè sulle risposte API e non sulle pagine.
|
||||
- Tailwind fissato a `3.4.19` nel `Dockerfile.frontend`.
|
||||
|
||||
## Migrazioni
|
||||
|
||||
| # | Contenuto |
|
||||
|---|---|
|
||||
| 001–004 | image_path, stazioni, intervallo di misura + auto-logout, `input_duration_ms` |
|
||||
| 005–006 | `production_runs`, misure legate alla produzione |
|
||||
| 007 | `task_type` dichiarato |
|
||||
| 008 | tipi di evento del ciclo + `task_id` sull'evento |
|
||||
| 009 | `requires_lot`, `requires_serial`, `allow_manual_input` |
|
||||
| 010 | `supervisor_id`, `authorised_at` sulla misura |
|
||||
|
||||
Le 008 e 010 usano il *batch mode* per compatibilità SQLite (i test) e sono state
|
||||
verificate anche in resa MySQL (`alembic upgrade X:Y --sql`) prima del deploy.
|
||||
|
||||
La 009 imposta `allow_manual_input = 1` sulle ricette esistenti: conserva il
|
||||
comportamento in essere invece di irrigidire di colpo ricette già in uso. È una
|
||||
scelta, e va confermata dal cliente ricetta per ricetta.
|
||||
|
||||
## Test status
|
||||
|
||||
| | Test | Fail |
|
||||
|---|---|---|
|
||||
| Backend (`src/backend/tests/`) | 212 | 0 |
|
||||
| Frontend (`src/frontend/flask_app/tests/`) | 148 | 0 |
|
||||
| **Totale** | **360** | **0** |
|
||||
|
||||
I quattro fallimenti pre-esistenti tracciati nello snapshot V2.0.0 non ci sono più.
|
||||
|
||||
Tre file di test non renderizzano niente e leggono i sorgenti, perché guardano
|
||||
proprietà che sopravvivono solo se qualcuno le controlla:
|
||||
|
||||
| File | Cosa impedisce |
|
||||
|---|---|
|
||||
| `test_offline.py` | Una libreria caricata dalla rete, un worker PDF.js lasciato sul CDN, una libreria sostituita senza aggiornare l'impronta |
|
||||
| `test_layout_shell.py` | Una vista che torna a dichiararsi la propria larghezza |
|
||||
| `test_template_js_syntax.py` | Una traduzione con l'apostrofo dentro una stringa JS a virgolette singole, che spegne Alpine su tutta la pagina |
|
||||
|
||||
## Cosa non è stato provato
|
||||
|
||||
Va detto perché non si confonda «i test passano» con «funziona in reparto».
|
||||
|
||||
- **Il collaudo sul campo non è mai stato percorso.** Le ricette `COLLAUDO-A` e
|
||||
`COLLAUDO-B` sono state seminate sul sistema in esercizio (`scripts/seed_collaudo.py`)
|
||||
insieme a un utente `capoturno`, ma nessuno ha ancora guidato il flusso su un
|
||||
tablet vero.
|
||||
- **Il punto 14 non è stato riprodotto su un dispositivo**: è dimostrato che il
|
||||
layout *poteva* muoversi per quattro motivi e che ora non può più, non che
|
||||
l'operatore vedesse esattamente quelli.
|
||||
- **Il punto 12 non è stato provato a rete staccata.** È dimostrato che nessuna
|
||||
risorsa esterna viene richiesta. Il giro con la rete staccata va fatto, ed è ora il
|
||||
momento giusto perché la CSP è appena entrata in vigore.
|
||||
- **Il cicalino** è il suono del browser. Se serva una colonnina luminosa è D-5.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend:** FastAPI + SQLAlchemy 2.0 async + MySQL 8 + Alembic + Pydantic v2 +
|
||||
WeasyPrint + Plotly/Kaleido. 12 router.
|
||||
- **Frontend:** Flask + Jinja2 + Alpine.js 3.15.12 + TailwindCSS 3.4.19 +
|
||||
Fabric.js 5.3.1 + PDF.js 3.11.174 + Plotly.js 2.32.0 + Flask-Babel —
|
||||
**tutte copie locali**, nessun CDN.
|
||||
- **Deploy:** Docker Compose. Dev = Nginx; Prod = Traefik + Let's Encrypt.
|
||||
- **Tooling:** uv, pytest + pytest-asyncio + httpx + aiosqlite.
|
||||
|
||||
## Decisioni architetturali rilevanti
|
||||
|
||||
| Decisione | Stato | Note |
|
||||
|---|---|---|
|
||||
| Le regole di misura stanno sul server, non sullo schermo | **Confermata (V3.0.0)** | Tracciabilità, inserimento manuale, fuori tolleranza: la schermata può nasconderle, il server le rifiuta. |
|
||||
| Una app di stazione per PC, dati sul server | **Decisa il 28/07** (D-3) | Validazione con l'IT di Tràfilo aperta come D-4. |
|
||||
| Descrizioni in marcatura, non in HTML | **Confermata** | Niente HTML accettato in ingresso: la sanificazione è per costruzione, non per filtro. |
|
||||
| Frontend Flask invece di React (deroga vs spec §8) | **Confermata** | Tablet UX server-side, calibri USB, editor Fabric.js, i18n Babel collaudato. |
|
||||
| NATS messaging (spec §7) | **Skippato** | Monorepo single-host, nessun microservizio. |
|
||||
| Envelope risposta `{success,data,error}` (spec §6) | **Rimandato** | Costo alto, rotture client. Eventuale API v2. |
|
||||
| Header `X-API-Key` vs spec `X-Api-Key` | **Mantenuto attuale** | Rinominare è breaking per i deploy esistenti. |
|
||||
|
||||
## Branch git
|
||||
|
||||
- **Corrente:** `V3.0.0`
|
||||
- **Precedenti:** `V2.0.0`, `V1.0.0` … `V1.0.7` (release storiche)
|
||||
@@ -0,0 +1,145 @@
|
||||
# Stato Progetto TieMeasureFlow — V2.0.0
|
||||
|
||||
> Snapshot al 2026-04-27. Aggiornare ad ogni milestone.
|
||||
|
||||
## Versione corrente
|
||||
|
||||
**V2.0.0** (in sviluppo, branch `V2.0.0` come default su `git.tielogic.xyz`).
|
||||
|
||||
Versione precedente di produzione: `V1.0.7`.
|
||||
|
||||
## Sintesi esecutiva
|
||||
|
||||
Il sistema base (V1.0.7) è completo e collaudato: ricette, task, misurazioni, SPC, report PDF, gestione utenti, dashboard metrologist. La V2.0.0 in corso aggiunge il primo blocco della migrazione **rev04** (stazioni per-tablet) e ristruttura l'intero monorepo secondo lo standard `python-project-spec-design.md` (uv + `src/backend/` + `src/frontend/flask_app/`).
|
||||
|
||||
## Cosa funziona oggi (V2.0.0 — branch corrente)
|
||||
|
||||
### Funzionalità ereditate da V1.0.7
|
||||
- Autenticazione username/password + API key per-utente, ruoli combinabili (Maker, MeasurementTec, Metrologist) + flag `is_admin`.
|
||||
- Recipe versioning copy-on-write: una nuova versione si crea solo se la corrente ha già measurements; altrimenti update in-place.
|
||||
- Editor ricette (Maker) con annotation editor Fabric.js (~1200 LOC, collaudato su tablet).
|
||||
- Workflow operatore tablet: select_recipe → task_list → task_execute → task_complete, con barcode scanner e numpad touch (input USB calibro con burst detection).
|
||||
- Calcolo pass/fail con limiti UTL/UWL/LWL/LTL.
|
||||
- Dashboard SPC: capability (Cp/Cpk/Pp/Ppk), control chart (UCL/LCL = mean ± 3σ), istogramma con curva normale, calcoli puro stdlib (no numpy).
|
||||
- Report PDF (WeasyPrint + Kaleido SVG).
|
||||
- Setup page protetta da `SETUP_PASSWORD` per inizializzazione DB e seed.
|
||||
- i18n IT/EN (Flask-Babel + Alpine.js JSON).
|
||||
- Tema light/dark via `Alpine.store('theme')` + localStorage.
|
||||
|
||||
### Aggiunte V2.0.0 (rev04 Fase 1 — Stazioni per-tablet)
|
||||
- Tabelle `stations` + `station_recipe_assignments` (Alembic migration `002_add_stations.py`).
|
||||
- Modelli ORM: `Station`, `StationRecipeAssignment` con vincolo unique `(station_id, recipe_id)`.
|
||||
- Schemas Pydantic: `StationCreate/Update/Response`, `StationRecipeAssignmentCreate/Response`, `RecipeSummary`.
|
||||
- Service `station_service` (CRUD + assegnazioni + cascade delete).
|
||||
- Router `/api/stations` con CRUD admin + endpoint operatore `GET /api/stations/by-code/{code}/recipes`.
|
||||
- Seed automatico `ST-DEFAULT` con tutte le ricette esistenti (idempotente).
|
||||
- Variabile env client `STATION_CODE` letta da `Config`, helper `APIClient.get_station_recipes()`.
|
||||
- Filtro `select_recipe`: il client mostra solo le ricette assegnate alla propria stazione, errore se `STATION_CODE` non configurato.
|
||||
- **GUI admin completa** in `/admin/stations`: tabella con search, modal create/edit, modal gestione assegnazioni ricette, conferma eliminazione, link in navbar (desktop + mobile).
|
||||
- 47 nuovi test (32 server + 15 client) tutti pass.
|
||||
|
||||
### Aggiunte V2.0.0 (performance + multi-utente)
|
||||
- Gunicorn 5 workers × 4 thread (gthread) — capacità ~20 richieste concorrenti Flask, regge 20+ tablet.
|
||||
- Uvicorn 4 workers + `--proxy-headers --forwarded-allow-ips='*'`.
|
||||
- Rate limit middleware: identificazione IP reale via `X-Forwarded-For` → `X-Real-IP` → `request.client.host`.
|
||||
- Rate limit general 100 → 300 req/min/IP (per-tablet ora, non più condiviso).
|
||||
- Flask `ProxyFix(x_for=1, x_proto=1, x_host=1)` per IP reale dietro Nginx.
|
||||
- `APIClient` propaga `X-Forwarded-For` + `X-Real-IP` (sia JSON che multipart).
|
||||
- 12 test aggiuntivi (7 server + 5 client).
|
||||
|
||||
### Aggiunte V2.0.0 (struttura monorepo)
|
||||
- `pyproject.toml` unico con extra `server`/`client`/`dev`. Niente più `requirements.txt`.
|
||||
- `uv.lock` (77 pacchetti) + `.python-version` (3.11) committati per build riproducibili.
|
||||
- Layout `src/backend/` + `src/frontend/flask_app/` (vedi sotto).
|
||||
- `Dockerfile` (root) + `Dockerfile.frontend` riscritti con `uv sync --frozen --no-dev --extra server|client`.
|
||||
- `docker-compose.{dev,}.yml` con build context `.`.
|
||||
- Alembic env.py aggiunge project root a `sys.path`; `script_location = %(here)s` resta valido.
|
||||
- `.dockerignore` aggiornato.
|
||||
|
||||
### Hardening post-restructure (smoke test 2026-04-26)
|
||||
|
||||
Sequenza di smoke test in locale (uvicorn + gunicorn + MySQL Docker) ha fatto emergere quattro regressioni che sarebbero rimaste invisibili al test suite:
|
||||
|
||||
- **`src/backend/config.py`**: `env_file` era cwd-relative (`../../.env`). Rotto fuori da `src/backend/`. Risolto con percorso assoluto `Path(__file__).resolve().parents[2] / ".env"`.
|
||||
- **`src/backend/models/orm/__init__.py`**: `Station` e `StationRecipeAssignment` non erano esportati, quindi `Base.metadata.create_all` non creava le tabelle stations. Aggiunti agli import.
|
||||
- **`.env.example`**: `UPLOAD_DIR=server/uploads` era residuo della vecchia struttura → file landavano fuori dall'albero di progetto. Aggiornato a `UPLOAD_DIR=uploads`.
|
||||
- **Apostrofi italiani in template Alpine** (`l'utente`, `nell'eliminazione`, `nell'assegnazione`): chiudevano prematuramente JS string literals dentro `x-text` e blocchi `<script>`. Riscritti con delimitatori `"..."` o riformulazione testuale.
|
||||
|
||||
Inoltre **UX rework** della modale assegnazione ricette su `/admin/stations`: dropdown sostituita da layout a 2 colonne (disponibili / assegnate) con bottone inline `+ Assegna`, search filter, empty state esplicativo (mostrava silenziosamente lista vuota se tutte le ricette erano già assegnate).
|
||||
|
||||
Test guard aggiunto: `test_template_js_syntax.py` valida ogni inline `<script>` E ogni espressione Alpine (`x-*`, `@*`, `:*`) della pagina con `node --check`. Cattura automaticamente il bug-class apostrofo. Skip se Node non è installato.
|
||||
|
||||
## Layout repository (V2.0.0)
|
||||
|
||||
```
|
||||
TieMeasureFlow/
|
||||
├── pyproject.toml + uv.lock + .python-version
|
||||
├── Dockerfile (backend) + Dockerfile.frontend
|
||||
├── docker-compose.dev.yml + docker-compose.yml
|
||||
├── nginx/
|
||||
├── uploads/ # volume Docker
|
||||
├── docs/ # raggruppata e indicizzata
|
||||
│ ├── README.md (indice)
|
||||
│ ├── API.md / DEPLOYMENT.md / USER_GUIDE.md / I18N_SETUP.md
|
||||
│ ├── architecture/ # questo file + ROADMAP.md
|
||||
│ ├── archive/ # piani storici
|
||||
│ ├── specs/ # spec esterne (.docx)
|
||||
│ └── superpowers/plans/ # piani TDD dettagliati
|
||||
└── src/
|
||||
├── backend/
|
||||
│ ├── main.py / config.py / database.py
|
||||
│ ├── api/{routers,middleware}/
|
||||
│ ├── models/{orm,api}/
|
||||
│ ├── services/
|
||||
│ ├── migrations/
|
||||
│ ├── templates/
|
||||
│ └── tests/
|
||||
└── frontend/
|
||||
└── flask_app/
|
||||
├── app.py / config.py / compile_translations.py
|
||||
├── blueprints/ (auth, maker, measure, statistics, admin)
|
||||
├── services/ (api_client.py)
|
||||
├── templates/ + static/ + translations/
|
||||
└── tests/
|
||||
```
|
||||
|
||||
## Smoke test status
|
||||
|
||||
Validazione end-to-end in locale (2026-04-26):
|
||||
|
||||
- ✅ MySQL container Docker up, schema creato, alembic stamp head OK
|
||||
- ✅ uvicorn `--reload` su :8000, `/api/health` risponde
|
||||
- ✅ Seed `/api/setup/seed` con `SETUP_PASSWORD=adriano77` → admin + 4 utenti demo + DEMO-001 + ST-DEFAULT con assegnazione automatica
|
||||
- ✅ Login `admin/admin123` via web, sessione persistente
|
||||
- ✅ `/admin/stations`: tabella, modal create/edit, modal gestione assegnazioni a 2 colonne con search, eliminazione con cascade
|
||||
- ✅ `/admin/users`, `/maker/recipes`, `/measure/select` (filtrato per stazione), `/statistics/dashboard`
|
||||
- ✅ Workflow MeasurementTec end-to-end: select_recipe → task_list → task_execute → task_complete (riepilogo con misure)
|
||||
- ✅ Hot reload Flask + uvicorn `--reload` + Tailwind watch attivi durante lo sviluppo
|
||||
|
||||
## Test status
|
||||
| Backend (`src/backend/tests/`) | 127 | 3 | Fail pre-esistenti: `test_recipes` (2) + `test_tasks` (1). Nessuno introdotto dalla V2.0.0. |
|
||||
| Frontend (`src/frontend/flask_app/tests/`) | 46 | 1 | +2 test post-restructure (`test_template_js_syntax.py`). Fail pre-esistente: `test_save_measurement_proxy`. |
|
||||
| **Totale** | **173** | **4** | Tutti i fallimenti tracciati come tech debt da risolvere. |
|
||||
|
||||
## Stack confermato
|
||||
|
||||
- **Backend:** FastAPI + SQLAlchemy 2.0 async + MySQL 8 + Alembic + Pydantic v2 + WeasyPrint + Plotly/Kaleido.
|
||||
- **Frontend:** Flask + Jinja2 + Alpine.js + TailwindCSS + Fabric.js 5.3.1 + html5-qrcode + Plotly.js + Flask-Babel.
|
||||
- **Deploy:** Docker Compose. Dev = Nginx; Prod = Traefik + Let's Encrypt SSL.
|
||||
- **Tooling:** uv (package mgmt), pytest + pytest-asyncio + httpx + aiosqlite (test).
|
||||
|
||||
## Decisioni architetturali rilevanti
|
||||
|
||||
| Decisione | Stato | Note |
|
||||
|---|---|---|
|
||||
| Frontend Flask invece di React (deroga vs spec §8) | **Confermata** | Tablet UX server-side, USB calipers/barcode, Fabric.js editor, i18n Babel collaudato. Vedi conversazione 2026-04-25. |
|
||||
| NATS messaging (spec §7) | **Skippato** | Monorepo single-host, no microservizi. Nessuno stub `nats_client/` creato. |
|
||||
| Envelope risposta `{success,data,error}` (spec §6) | **Rimandato** | Costo 4-5gg refactor + rotture client. Eventuale v2 API in M2. |
|
||||
| Header `X-API-Key` vs spec `X-Api-Key` | **Mantenuto attuale** | Rinominare costa 50+ punti di codice + breaking per deploy. Rivedere in M2. |
|
||||
| Variabili `.env` (DB_HOST, SERVER_PORT, ...) | **Mantenute attuali** | Rename a SERVICE_NAME/SERVICE_DOMAIN/API_KEY rinviato (impatta deploy esistenti). |
|
||||
|
||||
## Branch git
|
||||
|
||||
- **Default:** `V2.0.0` (lavoro corrente)
|
||||
- **Mantenuti:** `V1.0.0` … `V1.0.7` (release branches storiche)
|
||||
- **Mergiato e chiuso:** `feature/rev04-phase1-stations` (in `V2.0.0` con commit `ea8e468`)
|
||||
Binary file not shown.
@@ -0,0 +1,367 @@
|
||||
# La visione in TieMeasureFlow — stato verificato e specifica del lavoro che manca
|
||||
|
||||
> Progetto: TieMeasureFlow, ramo **`V3.0.0`** (HEAD `39a6f62`, 17/08/2026)
|
||||
> Data: 14 settembre 2026
|
||||
> Stato: specifica da approvare
|
||||
> Sostituisce, come documento operativo: il piano `docs/superpowers/plans/2026-08-16-vision-fondamenta-server.md`
|
||||
> (eseguito) e la corsia `A1-tiemeasureflow.md` del piano ferie (eseguita, cancellata dal disco il 01/09 e
|
||||
> recuperabile con `git show 874a7a4^:docs/plans/corsie-fase2/A1-tiemeasureflow.md` nel repo AI-OS).
|
||||
> **Resta in vigore** `docs/superpowers/specs/2026-08-16-visionsuite-integration-design.md`: le sue otto
|
||||
> decisioni architetturali non vengono riscritte qui, vengono richiamate.
|
||||
|
||||
## 1. Perché questo documento
|
||||
|
||||
Fra il 16 e il 17 agosto sono entrate in TieMeasureFlow le fondamenta della visione: ventiquattro commit,
|
||||
sette file di test nuovi, tre migrazioni. Da allora il lavoro si è fermato. Nel frattempo VisionSuite è
|
||||
andato avanti di sessantasette commit, la scheda di commessa non registra nulla dopo il 25 agosto, e
|
||||
l'installazione a Tràfilo, prevista per settembre, è alle porte insieme alla fiera del 27-29 ottobre.
|
||||
|
||||
Questo documento fa due cose. Prima dichiara, verificandolo sul codice e non sui piani, **che cosa esiste
|
||||
davvero** sul ramo `V3.0.0`. Poi specifica **che cosa manca** per arrivare a ciò che il cliente ha chiesto
|
||||
il 27 luglio: pattern matching, confronto del profilo con il disegno DXF e due o tre quote lineari, dentro
|
||||
l'interfaccia di TieMeasureFlow, con l'esito OK/warning/fail che il prodotto già sa dare.
|
||||
|
||||
La distanza fra le due cose è più grande di quanto i documenti di agosto lascino credere, e la ragione è
|
||||
una sola, spiegata al capitolo 3.
|
||||
|
||||
## 2. Quello che esiste, verificato sul ramo V3.0.0
|
||||
|
||||
Ogni riga di questa tabella è stata controllata aprendo il file indicato nel clone del ramo `V3.0.0` il
|
||||
14/09/2026. Non è lo stato dichiarato da un piano: è lo stato del disco.
|
||||
|
||||
| Cosa | Stato | Dove si vede |
|
||||
|---|---|---|
|
||||
| VisionSuite come sottomodulo git, su commit fisso | ✅ | `.gitmodules`, pin `f095fcc` (15/08) |
|
||||
| Runner unico, senza HTTP né database | ✅ | `src/vision/runner.py`, 129 righe |
|
||||
| Versione del motore timbrata su ogni numero prodotto | ✅ | `runner.engine_version()`, errore se non risolve |
|
||||
| Vision worker come servizio separato nel compose | ✅ | `src/vision_worker/main.py`, `Dockerfile.vision`, servizio `vision` |
|
||||
| Il server non importa mai VisionSuite | ✅ e **sorvegliato** | `src/backend/tests/test_vision_boundary.py` |
|
||||
| Grafo di visione sul task (`vision_json`) | ✅ | migrazione `011`, `models/orm/task.py` |
|
||||
| Legame quota del grafo → quota della ricetta (`vision_output`) | ✅ | migrazione `011`, `recipe_subtasks` |
|
||||
| `camera` fra i metodi di ingresso misura | ✅ | migrazione `011`, enum `input_method` |
|
||||
| Tabella `vision_results`, una riga per acquisizione | ✅ | migrazione `012`, `models/orm/vision.py` |
|
||||
| Immagini di riferimento con provenienza | ✅ | migrazione `013`, `vision_reference_images` |
|
||||
| Il verdetto lo dà TieMeasureFlow, non il grafo | ✅ | `vision_service` chiama `measurement_service` |
|
||||
| Gate del fuori tolleranza valutato per acquisizione | ✅ | `enforce_tolerance_gate=False` più gate a monte |
|
||||
| Copy-on-write delle ricette che copia anche il grafo | ✅ | `test_vision_copy_on_write.py` |
|
||||
| Il refuso `xf_compare` corretto in `dxf_compare` | ✅ | migrazione `011`, prima che una riga lo usasse |
|
||||
| Limiti di tipo e dimensione sugli endpoint di upload | ✅ | `test_vision_upload_limits.py` |
|
||||
| Il guasto del worker resta del worker | ✅ | `vision_service._call_worker`, 502/504 nominati |
|
||||
| L'applicazione parte senza internet | ✅ | `static/vendor/`, `test_offline.py` |
|
||||
| API `POST /api/vision/execute`, `/preview`, `/reference-images` | ✅ | `api/routers/vision.py` |
|
||||
|
||||
Trentanove test di visione fra `src/backend/tests/test_vision_*.py`, `src/vision/tests/` e
|
||||
`src/vision_worker/tests/`. ⚠️ **Non li ho eseguiti**: l'albero della visione richiede Python 3.13 e il
|
||||
sottomodulo inizializzato, cioè un'installazione da alcuni gigabyte che su questa macchina non c'è. Il
|
||||
conteggio è dei file; la loro verdezza è dichiarata dai commit del 16-17 agosto e va riverificata da chi
|
||||
riprende, prima di scriverci sopra.
|
||||
|
||||
**In sintesi: le fondamenta ci sono e sono fatte bene.** Il confine architetturale è difeso da un test
|
||||
invece che da una buona intenzione, la provenienza di ogni numero è tracciata, e il modello dati regge il
|
||||
resto del lavoro senza ristrutturazioni.
|
||||
|
||||
## 3. Il buco strutturale: pattern matching e DXF compare non sono eseguibili dal grafo
|
||||
|
||||
> ✅ **Colmato in VisionSuite il 14/09/2026** (commit `6f5990c`, issue #2). Quello che segue è la
|
||||
> diagnosi com'era, e resta perché spiega perché il ponte sta dove sta. Il seguito, cioè cosa manca
|
||||
> ancora **qui**, è nella fase V1.
|
||||
|
||||
È il punto che i documenti di agosto non dicono, ed è quello che decide il calendario.
|
||||
|
||||
Il runner esegue **un grafo `vs-task`**: `Task.from_dict(graph)`, poi `task.run(context)`. Gli strumenti
|
||||
che un grafo può contenere sono quelli registrati in `vs-task`, e al 14/09 — sia sul commit fissato
|
||||
`f095fcc`, sia sull'ultimo commit di VisionSuite — sono **dieci, tutti geometrici**:
|
||||
|
||||
`DetectLine`, `DetectCircle`, `CircleCenter`, `PointIntersectionLL`, `Distance2P`, `DistancePL`,
|
||||
`DistanceCL`, `Angle2L`, `Radius`, `Diameter`.
|
||||
|
||||
Nessuno strumento di pattern matching. Nessuno strumento di confronto DXF. Una ricerca di `pm2d`,
|
||||
`shape_model` o `dxf` dentro `packages/vs-task/src` non trova nulla.
|
||||
|
||||
Coerentemente, in `pyproject.toml` gli extra `vision` e `vision-worker` installano `visionsuite` (core),
|
||||
`vs-task`, `vs-measure`, `vs-pm2d` e `numpy`: **`vs-dxf` non è installato affatto**, e `vs-pm2d` è
|
||||
installato ma il grafo non ha modo di chiamarlo.
|
||||
|
||||
I due motori esistono, e sono buoni: `visionsuite.pm2d` ha il matcher e l'auto-tune, `visionsuite.dxf` ha
|
||||
`DXFCompare`, l'`ICPAligner`, il ricampionamento, il controllo di tolleranza e il generatore di rapporto.
|
||||
Ma sono **librerie**, non strumenti di grafo. Fra loro e TieMeasureFlow manca un ponte, e il ponte va
|
||||
costruito **in VisionSuite**, non qui: la decisione 4 della spec di agosto — il server non importa mai
|
||||
VisionSuite — e il criterio di accettazione della corsia A1 — *«`grep` non trova algoritmi di visione dentro
|
||||
il repo di TieMeasureFlow»* — rendono illegittima qualunque scorciatoia che scriva visione dentro questo
|
||||
repository.
|
||||
|
||||
🔑 **La conseguenza commerciale, detta chiara:** oggi TieMeasureFlow sa eseguire un grafo che misura
|
||||
distanze, angoli, raggi e diametri su un'immagine. Le due cose che Menoncin ha chiesto per nome — il
|
||||
confronto DXF, che il suo cliente finale non ha, e il pattern matching, che il loro «funziona male» — non
|
||||
sono ancora raggiungibili da nessun percorso del prodotto.
|
||||
|
||||
## 4. Le altre lacune, in ordine di peso
|
||||
|
||||
1. **Nessuna interfaccia.** Nel frontend Flask la visione compare in due sole righe di
|
||||
`templates/measure/task_list.html`, le etichette «Confronto profilo» e «Misura camera». Non esiste la
|
||||
schermata del Maker per comporre un grafo, scegliere un'immagine di riferimento, agganciare un'uscita a
|
||||
una quota o premere «prova»; non esiste la schermata dell'operatore che acquisisce e vede l'esito. Oggi
|
||||
un grafo entra solo via `POST /api/tasks` scrivendo il JSON a mano.
|
||||
2. **Nessun agente di stazione, nessun registro dei device.** La tabella `station_devices` della spec non
|
||||
esiste in nessuna migrazione; il WebSocket `/api/stations/agent` non esiste; `vs-camera`, cioè il
|
||||
pacchetto che contiene il driver Itala, non è montato. L'unico modo di far entrare un'immagine è un
|
||||
upload HTTP.
|
||||
3. **L'immagine di produzione non viene conservata.** `vision_results.image_path` e `overlay_path`
|
||||
migrano e restano vuote: nessuna riga le scrive. Una misura contestata non ha prova materiale, e
|
||||
l'overlay — l'immagine con sopra le zone dove il sistema ha guardato — non esiste.
|
||||
4. **La prova di non regressione è promessa e non mantenuta.** `expected_json` esiste sulla tabella delle
|
||||
immagini di riferimento; nessun percorso la scrive, `preview` compreso. È la rete di sicurezza che
|
||||
rende sostenibile aggiornare il sottomodulo con il motore installato su due host: senza, ogni
|
||||
aggiornamento di VisionSuite è un salto al buio.
|
||||
5. **Il sottomodulo è indietro di 67 commit**, dal 15/08 al 10/09. Fra questi c'è almeno una correzione di
|
||||
`vs-core` sulla ricomposizione dei mosaici. Aggiornare il pin non è manutenzione facoltativa: è la
|
||||
decisione 2 della spec, che vuole server e stazione sullo stesso commit, e va fatta con la prova di non
|
||||
regressione già in piedi — cioè dopo il punto 4, non prima.
|
||||
6. **Le tre verifiche a monte non sono mai state fatte**, e la spec di agosto le poneva come condizione
|
||||
d'ingresso: lo stato reale di VisionSuite rispetto al suo README, il test di calibrazione dato per
|
||||
rosso (focale recuperata a 373 contro 456 attesi, tolleranza 5%) e il peso effettivo del sottoinsieme
|
||||
di pacchetti che il worker installa. La seconda pesa più delle altre: **tutte le quote in millimetri
|
||||
poggiano sulla calibrazione**, e finché quel test non è verde o spiegato, i millimetri che il sistema
|
||||
produce non hanno garanzia.
|
||||
|
||||
## 5. Che cosa si vuole ottenere
|
||||
|
||||
Un operatore apre un task di visione su una postazione, appoggia il profilato sul piano, e il sistema:
|
||||
|
||||
- riconosce il pezzo e la sua posa (**pattern matching**);
|
||||
- ne confronta il profilo con il **DXF di riferimento**, dichiarando lo scarto massimo e dove sta;
|
||||
- ne ricava **due o tre quote lineari** (le quote massima e minima che il cliente chiede);
|
||||
- applica le tolleranze della ricetta e dà **OK / warning / fail**, con il gate del fuori tolleranza e
|
||||
l'autorizzazione del capoturno come per qualunque altra misura;
|
||||
- registra tutto in `measurements` e `vision_results`, con immagine, overlay, versione del motore e
|
||||
taratura in vigore.
|
||||
|
||||
Nulla di quanto il prodotto già fa — ricette, SPC, Cp/Cpk, carta di controllo, export, tracciabilità —
|
||||
deve cambiare. Una quota da camera resta una misura come le altre: è la decisione 7, e va tenuta.
|
||||
|
||||
## 6. Il lavoro, in sei fasi
|
||||
|
||||
Le fasi sono ordinate per dipendenza. Le prime due sono il cammino critico verso la fiera; la terza è il
|
||||
cammino critico verso l'installazione a Tràfilo. Non sono stimate in ore: una stima si scrive dopo aver
|
||||
guardato il codice dei due motori, e quella lettura fa parte della fase V0.
|
||||
|
||||
### V0 — Le verifiche a monte, prima di scrivere righe
|
||||
|
||||
Le tre della spec di agosto (capitolo 4, punto 6), più una quarta che nasce da questo documento: leggere
|
||||
`visionsuite.pm2d.matcher` e `visionsuite.dxf.compare` per sapere **che forma hanno gli ingressi e le
|
||||
uscite** dei due motori, perché è quella forma che decide la firma degli strumenti della fase V1.
|
||||
|
||||
*Fatto quando:* il test di calibrazione è verde o il suo scarto è spiegato per iscritto; il peso
|
||||
dell'ambiente del worker è un numero misurato; esiste una nota di due pagine con le firme reali dei due
|
||||
motori.
|
||||
|
||||
### V1 — Il ponte — ✅ **fatta il 14/09/2026** (VisionSuite `6f5990c`, issue #2 chiusa)
|
||||
|
||||
**Il lavoro è stato fatto in VisionSuite**, come questo documento prescriveva. Gli strumenti non sono
|
||||
finiti in `vs-task` insieme ai dieci geometrici, ma **ciascuno nel pacchetto del proprio motore** —
|
||||
`visionsuite.pm2d.task_tools` e `visionsuite.dxf.task_tools` — perché altrimenti il livello-task avrebbe
|
||||
ereditato numba, opencv e fastapi da `pm2d` e scipy ed ezdxf da `dxf`, e se li sarebbe portati dietro
|
||||
ogni consumatore che voleva soltanto una distanza fra due punti. Si dichiarano con l'extra `task`.
|
||||
|
||||
- `MatchPattern` — ingressi: la scena dal contesto, il modello per chiave o per percorso, punteggio
|
||||
minimo, intervallo di angolo e di scala. Uscite: `found`, `score`, `row`, `col`, `angle_deg`, `scale`,
|
||||
`n_matches`.
|
||||
- `CompareDXF` — ingressi: immagine, `dxf_path`, `px_per_mm` (o la calibrazione dal contesto),
|
||||
`tolerance_mm`. Uscite: `max_deviation_mm`, `mean_deviation_mm`, `n_points`, `overlay`.
|
||||
|
||||
**Il vincolo che ha retto:** gli strumenti espongono numeri, non verdetti. `CompareResult.passed` esiste
|
||||
e resta fuori dalle uscite, e un test lo sorveglia. La `tolerance_mm` serve **solo a colorare l'overlay**:
|
||||
è un parametro di disegno, non una soglia di accettazione.
|
||||
|
||||
⚠️ **Due cose che questo documento dava per buone non hanno retto alla prova, ed è il motivo per cui una
|
||||
specifica si verifica invece di eseguirla.**
|
||||
|
||||
1. **«`vs-dxf` si porta dietro PyTorch per una riga» era falso.** Torch è stato tolto in L2 insieme al
|
||||
rilevatore DexiNed che non aveva mai girato, e il `pyproject.toml` di `vs-dxf` lo dice per esteso. La
|
||||
nota veniva dalla scheda VisionSuite in AI-OS, che su quel punto era invecchiata. Il lavoro non
|
||||
esisteva: il container della visione non triplica.
|
||||
2. **La posa iniziale non serve, ed è misurato.** Questo documento dava per scontato che `CompareDXF`
|
||||
dovesse partire allineato dalla posa di `MatchPattern`. Ruotando la scena e lasciando l'ICP senza
|
||||
aiuto, sul rettangolo forato a 4 px/mm: **0°** → 0,285 mm, **5°** → 0,346, **20°** → 0,288, **45°** →
|
||||
0,243. Converge da solo, e a 45° meglio che a zero. In più `DXFCompare._pose_to_transform` non ha
|
||||
chiamanti né test e trasla le **righe** dove il nome dice colonne: l'ingresso non è stato esposto,
|
||||
perché chi lo avesse collegato al match avrebbe ottenuto una traslazione di centinaia di pixel
|
||||
credendo di aver fatto la cosa giusta. Un test parametrico su 0°, 20° e 45° tiene ferma la scelta.
|
||||
|
||||
⚠️⚠️ **E una trappola nuova, da sapere prima di comporre ricette: su un pezzo simmetrico l'angolo del
|
||||
match è ambiguo.** Sulla scena non ruotata il rettangolo torna `angle_deg = -180` con punteggio 0,924,
|
||||
perché a 180° è identico a sé stesso. `row` e `col` restano affidabili; l'angolo, su un pezzo senza un
|
||||
riferimento che rompa la simmetria, non è una risposta. I profilati di Tràfilo sono esattamente il genere
|
||||
di pezzo in cui questo si presenta.
|
||||
|
||||
*Verificato:* `tests/test_visione_nel_grafo.py` carica una ricetta JSON che contiene **entrambi** gli
|
||||
strumenti con `Task.from_dict`, la esegue e ottiene le uscite attese. 21 test nuovi; **1242 verdi** su
|
||||
tutto il monorepo.
|
||||
|
||||
**Quello che resta da fare qui, in TieMeasureFlow** (è ciò che la issue #8 della lista lavori chiedeva, e
|
||||
non è cambiato): `vs-dxf` entra negli extra `vision` e `vision-worker` di `pyproject.toml`, il pin del
|
||||
sottomodulo avanza a `6f5990c`, e il runner **importa i due moduli** `visionsuite.pm2d.task_tools` e
|
||||
`visionsuite.dxf.task_tools` — senza quell'import il registro non li conosce e una ricetta che li nomina
|
||||
non si carica. Nessuna riga di algoritmo entra in questo repository.
|
||||
|
||||
*Fatto quando:* un grafo JSON che contiene `MatchPattern` e `CompareDXF` viene eseguito da
|
||||
`POST /api/vision/preview` su un'immagine caricata, e restituisce le uscite attese. Senza camera, senza
|
||||
interfaccia. E `test_vision_boundary.py` continua a passare.
|
||||
|
||||
### V2 — L'authoring nel Maker — **in fiera** (decisa il 14/09)
|
||||
|
||||
La schermata che oggi non esiste. Si compone con un **editor visuale a nodi** (decisione A, chiusa il
|
||||
14/09). Il Maker, alla scrivania:
|
||||
|
||||
- carica o sceglie un'**immagine di riferimento** per il task (l'API esiste già);
|
||||
- compone il grafo collegando gli strumenti — l'immagine entra, e da lì scendono il riconoscimento, le
|
||||
quote e il confronto col disegno;
|
||||
- preme **prova** e vede le uscite (`/preview` esiste già);
|
||||
- **aggancia** ciascuna uscita a una quota della ricetta, scrivendo `vision_output` sulla subtask (il
|
||||
campo esiste già), e su quella quota mette nominale e tolleranze come su qualunque altra;
|
||||
- **promuove le uscite della prova a `expected_json`**, che è ciò che accende la prova di non regressione
|
||||
(lacuna 4) e chiude una promessa che la spec di agosto ha fatto e non ha mantenuto.
|
||||
|
||||
**Il taglio per la fiera** (deciso il 14/09): ci va tutto **fino alla misura vera** — comporre, provare,
|
||||
agganciare alle quote, e il ciclo dell'operatore che misura e riceve OK/warning/fail con le tolleranze
|
||||
applicate. È la storia che vende: il confronto DXF **dentro il flusso di produzione**, non un tool a sé.
|
||||
Restano fuori dalla fiera, e si fanno dopo, la promozione a `expected_json` e la prova di non regressione:
|
||||
valgono per la robustezza, ma allo stand non si mostrano.
|
||||
|
||||
⚠️ **Un limite dell'editor, emerso il 14/09 costruendo gli strumenti.** Negli strumenti geometrici di
|
||||
`vs-task` gli ingressi di scansione — `start_row`, `start_col`, `end_row`, `end_col` di `DetectLine` e
|
||||
compagnia — **non sono collegabili**: si impostano a mano. Finché restano così, la posa che
|
||||
`MatchPattern` trova non può spostare le scansioni delle quote, e il pezzo va appoggiato dove la ricetta
|
||||
si aspetta. Renderli collegabili è un lavoro in VisionSuite, piccolo ma non nullo, ed è **il passo che
|
||||
rende utile il riconoscimento dentro una ricetta di misura**: senza, `MatchPattern` serve a dire se il
|
||||
pezzo c'è ed è quello giusto, non a inseguirlo.
|
||||
|
||||
*Fatto quando:* una ricetta con un task di visione si compone interamente dall'interfaccia, senza scrivere
|
||||
JSON a mano, e un operatore la esegue ottenendo un esito vero. La prova di non regressione — rieseguire
|
||||
l'intero parco ricette su immagini di riferimento e vedere quali quote si sono mosse — è il criterio della
|
||||
parte differita.
|
||||
|
||||
### V3 — L'agente di stazione, i device, la camera
|
||||
|
||||
È il cammino critico per Tràfilo, perché è ciò che rende il sistema utilizzabile in reparto.
|
||||
|
||||
- Tabella `station_devices` e migrazione, così com'è descritta nella spec di agosto (specchio in sola
|
||||
lettura, popolato dall'agente; un device che non si dichiara più è **offline**, non cancellato).
|
||||
- WebSocket `/api/stations/agent`: **è l'agente che apre la connessione**, il server non bussa mai a un PC
|
||||
di reparto. È la scelta che evita di aprire porte in fabbrica.
|
||||
- L'agente: sottile, monta `vs-core` e `vs-camera` e basta; acquisisce e carica il fotogramma. Camera
|
||||
**Itala** di Opto Engineering, il cui producer GenTL è già stato provato in VisionSuite e ha richiesto
|
||||
mezz'ora invece delle sei-dieci ore stimate.
|
||||
- `vision_results.image_path` e `overlay_path` finalmente scritte (lacuna 3), con l'overlay prodotto dal
|
||||
worker.
|
||||
|
||||
*Fatto quando:* un operatore misura un pezzo da una postazione vera, e la riga in `vision_results` porta
|
||||
immagine, overlay, device, taratura e versione del motore.
|
||||
|
||||
### V4 — L'esecuzione sulla stazione
|
||||
|
||||
La policy per stazione, il ripiego in locale quando il server non risponde, e il **rifiuto di misurare**
|
||||
quando la versione del motore non concorda con quella del server. Quest'ultimo non è un dettaglio di
|
||||
build: è la garanzia metrologica su cui poggia tutto il resto.
|
||||
|
||||
*Fatto quando:* una stazione con un pin discorde si rifiuta di misurare e lo dichiara, invece di produrre
|
||||
numeri plausibili.
|
||||
|
||||
### V5 — Il task `dxf_compare` come tipo a sé
|
||||
|
||||
Il valore `dxf_compare` esiste già nell'enum. Qui prende il suo comportamento: quote sintetiche derivate
|
||||
dal confronto (scarto massimo, numero di zone fuori tolleranza) trattate come misure normali, e il
|
||||
**rapporto allegato** che `visionsuite.dxf.report` sa già generare, archiviato accanto all'immagine.
|
||||
|
||||
## 7. Le decisioni che restano da prendere
|
||||
|
||||
Nessuna di queste si può dedurre dal codice: vanno chiuse da Adriano, alcune con il cliente.
|
||||
|
||||
**A. Come si compone un grafo — chiusa il 14/09/2026: un editor visuale a nodi.** Adriano lo ha rimesso
|
||||
nel perimetro, e la scelta **revoca la revoca del 09/08**. Allora TieWorkflow era uscito perché *«un editor
|
||||
ha senso solo se a comporre le pipeline è qualcuno che non programma, e il cliente aveva chiesto misure,
|
||||
non un editor»*: motivazione giusta quando il committente era Tràfilo, che vuole misurare profilati.
|
||||
Adesso il modulo è un investimento per prendere lavori futuri (decisione F), e il pubblico dello stand non
|
||||
è l'operatore di Tràfilo — è chi allo stand deve immaginarci il **proprio** pezzo, e a quello un form fisso
|
||||
non parla.
|
||||
|
||||
Le altre due strade restano scritte qui perché sono la via d'uscita, non perché siano state scelte: un
|
||||
**modulo per tipo di task** con i parametri in chiaro (il minimo che funziona) e una **libreria di grafi
|
||||
preconfezionati** che il Maker sceglie e tara.
|
||||
|
||||
⚠️⚠️ **È la più cara delle tre, su un cammino critico già pieno.** Ad agosto l'editor era stimato **12-18
|
||||
ore su un perimetro più piccolo** — senza `MatchPattern`, senza `CompareDXF`, senza l'aggancio quota→uscita
|
||||
— ed è l'ordine di grandezza che aveva fatto sfondare il budget del piano ferie. Quelle ore ora si sommano
|
||||
alla V1, anch'essa obbligatoria per la fiera, dentro 43 giorni. **Il ripiego va deciso adesso, non il 20
|
||||
ottobre:** se a metà ottobre l'editor non regge, si espone il modulo per tipo di task, che mostra le stesse
|
||||
misure con meno scena.
|
||||
|
||||
|
||||
**B. La scala: pixel o millimetri.** Il 09/08 si era deciso che le quote potessero restare **in pixel**, e
|
||||
che per il DXF compare servisse un fattore di scala ricavato da una quota nota misurata col calibro. Va
|
||||
confermato: sono due prodotti diversi da spiegare al cliente, e il secondo dipende dal test di
|
||||
calibrazione della fase V0.
|
||||
|
||||
**C. Il dimensionamento del server di Tràfilo.** Con l'esecuzione lato server come default, il pattern
|
||||
matching contende la CPU fra tutte le stazioni. La domanda alla D-4 non è più solo quale macchina e quanto
|
||||
disco: è anche quanti core e quanta RAM. Venti postazioni non sono tre.
|
||||
|
||||
**D. La conservazione delle immagini.** Quelle di riferimento sono poche e vivono quanto la ricetta;
|
||||
quelle di produzione sono una per pezzo e crescono senza limite. Serve una scadenza, o una regola del tipo
|
||||
«si conservano solo quelle dei pezzi fuori tolleranza». Senza, il disco del cliente si riempie e nessuno
|
||||
lo ha avvertito.
|
||||
|
||||
**E. Che cosa fa la stazione se la rete cade** e non è configurata per elaborare in locale: si blocca, o
|
||||
consente il calibro segnando il task come non eseguibile? È una domanda per Tràfilo.
|
||||
|
||||
**F. Chi paga — chiusa il 14/09/2026.** Nessuno, ed è una scelta. Il modulo di visione è un
|
||||
**investimento commerciale di Tielogic**: non si quota a Ricerca e Misure, non entra nel setup né nel
|
||||
canone dell'OFF-2026-022, e non aspetta un'offerta per cominciare. Serve a prendere lavori futuri, e il
|
||||
posto dove quell'investimento si trasforma in lavori è la fiera.
|
||||
|
||||
Ne discendono tre conseguenze che questo documento adotta:
|
||||
|
||||
1. **Il modulo non ha una data di consegna contrattuale**, quindi l'unica scadenza che lo governa è la
|
||||
fiera del 27-29 ottobre. La fase V1 è l'unica del percorso visione che ha una data vera.
|
||||
2. **Tràfilo va servito con il perimetro venduto** — le dodici modifiche del 28/07, l'installazione
|
||||
on-premise, l'agente di stazione — e quel lavoro non si rallenta per la visione: è quello che è già
|
||||
stato fatturato. Dove la visione e Tràfilo si contendono le stesse ore, vince ciò che è stato venduto,
|
||||
con l'unica eccezione della V1 prima della fiera.
|
||||
3. **Il confine va tenuto per iscritto**: le ore spese per la fiera sono a carico Tielogic, quelle spese
|
||||
per Tràfilo stanno nella commessa. Senza quella riga, a consuntivo il modulo di visione sembrerà un
|
||||
costo della commessa Ricerca e Misure e ne falserà il margine.
|
||||
|
||||
⚠️ Resta aperto un pezzo che non è «chi paga» ma «che cosa»: **cosa si mostra in fiera** non coincide con
|
||||
cosa si consegna. Una demo può girare su un grafo preparato, un pezzo noto e una posa controllata; un
|
||||
prodotto no. Deciderlo prima cambia l'ampiezza della V1.
|
||||
|
||||
## 8. Quello che non va fatto
|
||||
|
||||
- **Non scrivere algoritmi di visione in questo repository.** Il confine è sorvegliato da un test; se
|
||||
domani il server importasse VisionSuite per una scorciatoia, il vantaggio si perderebbe in silenzio.
|
||||
- **Non rifare l'interfaccia** oltre le schermate nuove che le fasi V2 e V3 richiedono.
|
||||
- **Non toccare SPC, ricette e integrazione GAIA.** GAIA è il gestionale del cliente finale e non è in
|
||||
questo perimetro.
|
||||
- **Non aggiungere il modulo AI** di cui si è parlato in call: è perimetro nuovo, senza offerta.
|
||||
- **Non far giudicare il grafo.** I verdetti di `vs-dxf` e di `vs-measure` restano inutilizzati: decide
|
||||
`measurement_service`.
|
||||
- **Non avanzare il pin del sottomodulo senza la prova di non regressione in piedi.** Con il motore
|
||||
installato su due host, un aggiornamento non verificato sposta i numeri senza che nessuno se ne accorga
|
||||
fino alla contestazione.
|
||||
|
||||
## 9. Il calendario, per capire le priorità
|
||||
|
||||
La catena è **Tielogic produce → Ricerca e Misure (Menoncin) rivende → Tràfilo usa**, venti postazioni.
|
||||
Due scadenze esterne, e dal 14/09 non pesano più allo stesso modo.
|
||||
|
||||
- **Fiera del 27-29 ottobre**, banchetto Tielogic nella zona Opto Engineering. Le servono **V1 e V2
|
||||
insieme**, perché allo stand si mostra il prodotto e non una demo: comporre una ricetta di visione
|
||||
dall'interfaccia, misurare un pezzo, vedere l'esito. È l'unica data del percorso visione, ed è il posto
|
||||
dove l'investimento della decisione F si trasforma in lavori.
|
||||
- **Installazione a Tràfilo**, attesa per settembre. Le serve la **V3**, ed è **ciò che è stato
|
||||
fatturato**. ⚠️ Con V1 e V2 entrambe prima della fiera, la V3 resta indietro: è una scelta consapevole,
|
||||
ma la tempistica dell'installazione era stata data a Menoncin, quindi va **detta a lui** invece di
|
||||
scoperta da lui.
|
||||
|
||||
⚠️ Il rischio non è che manchi una delle due: è che l'editor a nodi si prenda le ore della V1. Il ripiego
|
||||
è scritto nella decisione A, e va guardato a **metà ottobre**, quando cambiare strada costa ancora poco.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,474 @@
|
||||
# Integrazione di VisionSuite in TieMeasureFlow — design
|
||||
|
||||
> Progetto: TieMeasureFlow V3.0.0 → V3.1.0
|
||||
> Data: 2026-08-16
|
||||
> Stato: design approvato, piano di implementazione da scrivere
|
||||
|
||||
## Perché
|
||||
|
||||
TieMeasureFlow oggi registra misure prese a mano, con il calibro USB o con il
|
||||
tastierino. Il passo successivo che il cliente chiede è misurare con una camera:
|
||||
acquisire l'immagine di un pezzo e ricavarne le quote, oppure confrontarne il
|
||||
profilo con il disegno DXF di riferimento.
|
||||
|
||||
La visione artificiale non va scritta qui. Tielogic ha già
|
||||
[VisionSuite](ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git), un
|
||||
monorepo di moduli riusabili il cui README indica esplicitamente TieMeasureFlow
|
||||
fra i consumatori previsti. Questo documento descrive come agganciarlo.
|
||||
|
||||
Il terreno è in parte già preparato. La migrazione `007_task_type` ha introdotto
|
||||
il tipo di task dichiarato e l'enum contiene fin da allora due valori che nessuno
|
||||
produce né consuma ancora:
|
||||
|
||||
```python
|
||||
TASK_TYPES = ("note", "measure", "drawing", "xf_compare", "camera_measure")
|
||||
MEASURING_TASK_TYPES = ("measure", "camera_measure")
|
||||
```
|
||||
|
||||
Questo lavoro li riempie.
|
||||
|
||||
## Che cosa entra e che cosa resta fuori
|
||||
|
||||
**Entra:** l'acquisizione da camera, l'esecuzione di un programma di visione su
|
||||
quell'immagine — pattern matching più misure, oppure pattern matching più
|
||||
confronto DXF — e l'ingresso dei risultati nel modello di misura che già esiste.
|
||||
|
||||
**Resta fuori, per ora:** i semafori USB. Sono previsti dall'architettura — è
|
||||
l'agente di stazione a possederli, insieme alle camere — ma non fanno parte di
|
||||
questa consegna. Il confine è disegnato perché ci entrino senza ristrutturazioni.
|
||||
|
||||
## Le otto decisioni
|
||||
|
||||
Sono le scelte prese in fase di brainstorming, con il motivo per cui sono state
|
||||
prese. Chi legge fra sei mesi ha bisogno del motivo più della scelta.
|
||||
|
||||
### 1. La visione gira sia sul server sia sulla stazione
|
||||
|
||||
Il motore è un componente unico distribuito in due posti: accanto al server e
|
||||
dentro l'agente di stazione. Questo dà la resilienza — se cade il collegamento,
|
||||
la stazione continua a misurare — e permette al server di elaborare in fase di
|
||||
produzione, di comporre ricette per conto del Maker e di rifare analisi su
|
||||
immagini archiviate.
|
||||
|
||||
È coerente con la decisione **D-3**, chiusa il 28/07: dati sul server, app di
|
||||
stazione su ogni PC.
|
||||
|
||||
### 2. Server e stazione montano la stessa identica versione di VisionSuite
|
||||
|
||||
Non è una raccomandazione di build: è un vincolo metrologico. Se il server monta
|
||||
un commit e una stazione ne monta un altro, lo stesso pezzo con lo stesso grafo
|
||||
può dare due numeri diversi, e nessuno se ne accorge finché non arriva una
|
||||
contestazione.
|
||||
|
||||
Da qui tre obblighi che il design deve garantire:
|
||||
|
||||
- VisionSuite entra come **sottomodulo git su un commit preciso**, lo stesso per
|
||||
entrambi — la forma che il README di VisionSuite prescrive
|
||||
- ogni misura porta scritto **con quale versione del motore** è stata prodotta
|
||||
- una stazione che monta una versione diversa da quella del server **si rifiuta
|
||||
di misurare** e lo dichiara, invece di produrre numeri plausibili e sbagliati
|
||||
|
||||
### 3. L'agente di stazione è sottile
|
||||
|
||||
L'agente parla ai device, consegna immagini e — più avanti — piloterà i semafori.
|
||||
Non contiene decisioni di visione.
|
||||
|
||||
La conseguenza pratica è l'impacchettamento: un agente che non contiene
|
||||
VisionSuite non contiene nemmeno PyTorch. Distribuirlo come eseguibile su Windows
|
||||
o come container su Linux diventa realistico, e la scelta su driver e formato può
|
||||
restare tardiva — che è bene, perché dipende dalla **D-4**, ancora aperta.
|
||||
|
||||
L'agente incorpora il motore solo quando la stazione è configurata per elaborare
|
||||
in locale, e in quel caso resta comunque un guscio: riceve un grafo, lo esegue,
|
||||
restituisce le uscite.
|
||||
|
||||
### 4. Il server non importa mai VisionSuite
|
||||
|
||||
La visione sul lato server vive in un **servizio separato** nello stesso compose,
|
||||
il *vision worker*. Il server FastAPI continua a fare quello che fa —
|
||||
autenticazione, ricette, misure, statistica — e resta leggero.
|
||||
|
||||
Quattro ragioni concrete: l'immagine dell'API non passa da poche centinaia di
|
||||
megabyte a cinque gigabyte; un aggiornamento di VisionSuite non richiede di
|
||||
riavviare l'API in produzione; un'elaborazione che va in crash non porta giù le
|
||||
richieste degli altri tablet; il giorno in cui la visione chiede una GPU o una
|
||||
macchina propria si sposta senza toccare il resto.
|
||||
|
||||
Il confine va difeso: se un domani il server importasse VisionSuite per una
|
||||
scorciatoia, il vantaggio si perde in silenzio.
|
||||
|
||||
### 5. La configurazione dei device sta sul PC
|
||||
|
||||
Le camere — e domani i semafori — appartengono alla macchina, non alla ricetta.
|
||||
Il server ne tiene uno **specchio** in sola lettura, popolato dall'agente quando
|
||||
si connette, perché il Maker deve poter scegliere un device da un elenco stando
|
||||
alla scrivania.
|
||||
|
||||
Un device che l'agente non dichiara più è **offline**, non cancellato: una
|
||||
ricetta che lo referenzia deve continuare a esistere e a spiegare perché non può
|
||||
girare.
|
||||
|
||||
### 6. Dove si esegue lo decide la configurazione della stazione
|
||||
|
||||
Ogni PC dichiara se elabora in locale o delega al server, nella stessa
|
||||
configurazione dove già stanno i device. Il default è il server.
|
||||
|
||||
Le macchine capaci si prendono il carico e non mandano immagini in rete; quelle
|
||||
deboli delegano. Se il server non risponde e la stazione ha il motore, la
|
||||
stazione ripiega in locale e lo segna nel risultato. Un impianto misto si governa
|
||||
senza toccare le ricette.
|
||||
|
||||
La ricetta non dice mai dove gira: legare una scelta di impianto a un documento
|
||||
di produzione significa dover rimettere mano alle ricette per spostare il carico.
|
||||
|
||||
### 7. Il verdetto lo dà TieMeasureFlow, non il grafo
|
||||
|
||||
Gli strumenti di quota di `vs-task` hanno anche loro `nominal`, `plus`, `minus` e
|
||||
un `outcome`. Quell'esito viene ignorato e quei limiti restano vuoti: il grafo
|
||||
produce numeri, `measurement_service` decide se sono buoni.
|
||||
|
||||
Due giudici sullo stesso valore è il modo più rapido per ottenere un CSV che si
|
||||
contraddice e una contestazione che non si sa come chiudere.
|
||||
|
||||
La conseguenza è che tutto il resto continua a funzionare senza modifiche:
|
||||
pass/warning/fail, gate del fuori tolleranza con autorizzazione del capoturno,
|
||||
Cp/Cpk, carta di controllo, colonne del CSV di produzione. Una quota da camera è
|
||||
una misura come le altre.
|
||||
|
||||
### 8. La sorgente immagine è un'astrazione a tre facce
|
||||
|
||||
Il motore riceve un'immagine e non sa da dove venga. Le tre sorgenti sono: dal
|
||||
vivo da un device tramite l'agente, un'immagine di riferimento salvata sul
|
||||
server, un file caricato dal Maker.
|
||||
|
||||
Non è un'astrazione nostra: è quella che VisionSuite si è già data — *«espongono
|
||||
una sorgente, non una telecamera: è questo che permette di collaudare un impianto
|
||||
a cinque telecamere senza avere le cinque telecamere sul tavolo, e di scrivere
|
||||
prove che danno sempre lo stesso risultato»*.
|
||||
|
||||
Sblocca tre cose che altrimenti non ci sarebbero:
|
||||
|
||||
- il Maker compone alla scrivania, senza tenere occupata una stazione né avere il
|
||||
pezzo in mano
|
||||
- una ricetta si prova prima di andare in produzione, invece di scoprirne i
|
||||
difetti misurando
|
||||
- diventa possibile una **prova di non regressione**: salvando accanto
|
||||
all'immagine il risultato atteso, si riesegue l'intero parco ricette dopo un
|
||||
aggiornamento di VisionSuite e si vede quali quote si sono mosse
|
||||
|
||||
Quest'ultima è la rete di sicurezza che rende sostenibile aggiornare il
|
||||
sottomodulo, con un motore che sta su due host.
|
||||
|
||||
> **Non ancora consegnata.** La colonna `expected_json` esiste, ma nessun
|
||||
> percorso la scrive — nemmeno `preview`, che è l'unico posto dove un grafo gira
|
||||
> davvero su un'immagine. La prova di non regressione è quindi **promessa da
|
||||
> questo documento e non mantenuta da nessuna riga di codice**. Diventa una
|
||||
> consegna nominata del piano 1b: «`preview` può promuovere le proprie uscite a
|
||||
> `expected_json`». Finché non è fatta, questa decisione non va dichiarata
|
||||
> completa.
|
||||
|
||||
## Architettura
|
||||
|
||||
### I componenti
|
||||
|
||||
| Componente | Nuovo | Dove vive | Responsabilità |
|
||||
|---|---|---|---|
|
||||
| `vendor/visionsuite` | sì | sottomodulo git | i pacchetti `vs-core`, `vs-camera`, `vs-pm2d`, `vs-measure`, `vs-dxf`, su un commit fisso |
|
||||
| Vision runner | sì | libreria condivisa | immagine + grafo + calibrazione → quote e artefatti. Unica implementazione, due host |
|
||||
| Vision worker | sì | container nel compose | espone il runner dietro una API interna. Il container pesante |
|
||||
| Agente di stazione | sì | PC di reparto | possiede i device, acquisisce, apre la connessione verso il server, incorpora il runner se configurato |
|
||||
| Backend FastAPI | modificato | container esistente | tipi di task, grafo sul task, registro device, inoltro, ingresso risultati. **Nessun import di VisionSuite** |
|
||||
| Frontend Flask | modificato | container esistente | Maker: device, fotogramma, aggancio quote. Measure: schermata del task con camera |
|
||||
|
||||
L'aggancio del sottomodulo segue la forma prescritta dal README di VisionSuite:
|
||||
dipendenza dai soli pacchetti che servono, dichiarati in path editable.
|
||||
|
||||
```toml
|
||||
[tool.uv.sources]
|
||||
visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true }
|
||||
vs-camera = { path = "vendor/visionsuite/packages/vs-camera", editable = true }
|
||||
```
|
||||
|
||||
I due host non montano lo stesso insieme, ed è il punto dell'intera struttura di
|
||||
VisionSuite:
|
||||
|
||||
| Pacchetto | Worker | Agente | Perché |
|
||||
|---|---|---|---|
|
||||
| `vs-core` | sì | sì | strutture di base e calibrazione, servono a entrambi |
|
||||
| `vs-pm2d` | sì | solo se elabora in locale | pattern matching |
|
||||
| `vs-measure` | sì | solo se elabora in locale | primitive e quote |
|
||||
| `vs-dxf` | sì | solo se elabora in locale | confronto col disegno. È quello che porta PyTorch |
|
||||
| `vs-camera` | **no** | sì | il worker non tocca hardware: riceve immagini già acquisite |
|
||||
|
||||
Un agente su una stazione che delega al server monta quindi due soli pacchetti,
|
||||
`vs-core` e `vs-camera`, e resta leggero. È la differenza fra un eseguibile
|
||||
distribuibile e un'installazione da cinque gigabyte su ogni PC di reparto.
|
||||
|
||||
I nomi di importazione non sono uniformi — `vs-core` si importa come
|
||||
`visionsuite`, `vs-pm2d` come `pm2d`, `vs-dxf` come `dxf_compare` — ed è una
|
||||
stortura nota e dichiarata a monte, non un errore da correggere qui.
|
||||
|
||||
### Il flusso di una misura, esecuzione sul server
|
||||
|
||||
```
|
||||
operatore apre il task
|
||||
→ client chiede al server di eseguire
|
||||
→ server chiede all'agente un fotogramma sul device dichiarato
|
||||
→ agente acquisisce e carica l'immagine
|
||||
→ server passa immagine e grafo al worker
|
||||
→ worker restituisce le uscite
|
||||
→ server applica pass/fail, gate del fuori tolleranza, salva in measurements
|
||||
→ client mostra esito e immagine con le zone dove ha guardato
|
||||
```
|
||||
|
||||
### Il flusso con esecuzione in locale
|
||||
|
||||
Il giro si accorcia: il server manda il grafo all'agente, l'agente acquisisce ed
|
||||
esegue, e rimanda uscite più immagine. **Da lì in poi il server fa le stesse
|
||||
identiche cose.** Il verdetto, il gate e il salvataggio non cambiano mai posto:
|
||||
cambia chi calcola i numeri, non chi decide se sono buoni.
|
||||
|
||||
È questo che rende le due strade equivalenti a valle, e che permette di cambiare
|
||||
la configurazione di una stazione senza toccare né il frontend né le ricette.
|
||||
|
||||
## Modello dati
|
||||
|
||||
### Il grafo di visione
|
||||
|
||||
`recipe_tasks` prende **`vision_json`** (JSON, nullable): il grafo di `vs-task`
|
||||
serializzato, opaco al server.
|
||||
|
||||
Il versionamento arriva gratis. Il copy-on-write delle ricette copia già i task
|
||||
in profondità, quindi una modifica al grafo su una ricetta con misure produce una
|
||||
versione nuova come qualunque altra modifica; in `recipe_service` è un campo in
|
||||
più nella copia, non una logica nuova.
|
||||
|
||||
Non si riusa `annotations_json`, che sta lì accanto: le annotazioni sono un
|
||||
disegno per l'operatore, il grafo è un programma da eseguire. Hanno cicli di vita
|
||||
e regole di validazione diversi, e mescolarli significa non poter validare né
|
||||
l'uno né l'altro.
|
||||
|
||||
### Il legame fra quota del grafo e quota della ricetta
|
||||
|
||||
`recipe_subtasks` prende **`vision_output`** (String, nullable): la coppia
|
||||
«identificativo dello strumento, nome della sua uscita».
|
||||
|
||||
È la stessa forma di legame che `vs-task` usa internamente, e per la stessa
|
||||
ragione dichiarata nel suo README: *«Il legame è un ID, non un puntatore»* — un
|
||||
riferimento in memoria non si scrive su disco.
|
||||
|
||||
Tutto il resto della subtask — nominale, UTL, UWL, LWL, LTL, unità — resta com'è
|
||||
e continua a significare quello che significa oggi.
|
||||
|
||||
### Le misure
|
||||
|
||||
`input_method` passa da `("usb_caliper", "manual")` a
|
||||
`("usb_caliper", "manual", "camera")`.
|
||||
|
||||
Il contorno della visione **non** va su `measurements`. Quella tabella la leggono
|
||||
la statistica e l'export a ogni giro, e appesantirla la rovina. Ma soprattutto
|
||||
sarebbe sbagliato di modello: una sola acquisizione produce N quote, e immagine,
|
||||
overlay, device e versione del motore sono gli stessi per tutte.
|
||||
|
||||
Nasce quindi **`vision_results`**, una riga per **esecuzione**:
|
||||
|
||||
| Campo | Perché c'è |
|
||||
|---|---|
|
||||
| `image_path`, `overlay_path` | l'immagine, e quella con sopra le zone dove ha guardato |
|
||||
| `engine_version` | il commit di VisionSuite che ha prodotto i numeri |
|
||||
| `executed_on` | `server` o `station` |
|
||||
| `station_id`, `device_id` | da quale macchina e quale camera |
|
||||
| `calibration_snapshot` | la taratura in vigore in quel momento, **copiata** |
|
||||
| `graph_snapshot` | il grafo effettivamente eseguito, **copiato** |
|
||||
| `duration_ms`, `executed_at` | quanto è costata, quando |
|
||||
|
||||
Su `measurements` una sola colonna nuova: `vision_result_id`, FK nullable.
|
||||
|
||||
Calibrazione e grafo si copiano invece di essere referenziati per la stessa
|
||||
ragione: una taratura rifatta sei mesi dopo non deve riscrivere il significato di
|
||||
una misura già presa.
|
||||
|
||||
### Il registro dei device
|
||||
|
||||
**`station_devices`**: `station_id`, `code` (l'identificativo locale), `kind`
|
||||
(`camera` oggi, `light` domani), `label`, `capabilities` (JSON), `calibration`
|
||||
(JSON) con `calibration_taken_at`, `last_seen_at`.
|
||||
|
||||
Si popola dall'agente. Il server lo riceve e non lo modifica.
|
||||
|
||||
### Le immagini di riferimento
|
||||
|
||||
**`vision_reference_images`**: `task_id`, `path`, e la provenienza per intero —
|
||||
`station_id`, `device_id`, snapshot della calibrazione, `engine_version`,
|
||||
`acquired_at`, una nota — più `expected_json` per l'atteso della prova di non
|
||||
regressione.
|
||||
|
||||
La provenienza non è rimandabile. Senza, si riesegue il grafo mesi dopo, escono
|
||||
numeri diversi, e non c'è modo di distinguere un motore aggiornato da un'ottica
|
||||
spostata.
|
||||
|
||||
### Migrazione 011
|
||||
|
||||
Cinque cose: `vision_json` su `recipe_tasks`, `vision_output` su
|
||||
`recipe_subtasks`, `camera` in `input_method_enum`, `vision_result_id` su
|
||||
`measurements`, e le tre tabelle nuove.
|
||||
|
||||
Più una sesta, **da fare adesso o mai**: `task_type_enum` contiene `xf_compare`,
|
||||
refuso per `dxf_compare`. Oggi nessuna riga usa quel valore e correggerlo costa
|
||||
una riga. Dal primo task salvato in poi diventa una migrazione di dati, e il
|
||||
refuso finisce nelle API pubbliche.
|
||||
|
||||
Come le migrazioni 008 e 010, va scritta in *batch mode* per la compatibilità
|
||||
SQLite dei test e verificata in resa MySQL con `alembic upgrade X:Y --sql` prima
|
||||
del deploy: è la procedura che il progetto si è già dato.
|
||||
|
||||
## API
|
||||
|
||||
L'agente apre lui la connessione. **Il server non bussa mai a un PC**, e questo
|
||||
evita di dover aprire porte sulle macchine di reparto — che è materia della D-4.
|
||||
|
||||
| Verso | Endpoint | Cosa fa |
|
||||
|---|---|---|
|
||||
| agente → server | `WS /api/stations/agent` | si autentica con la chiave di stazione, dichiara i device, resta in ascolto |
|
||||
| server → agente | comando `acquire` | «scatta sul device X»; l'agente carica con `POST /api/vision/frames` |
|
||||
| server → agente | comando `execute` | esecuzione in locale: manda il grafo, riceve le uscite |
|
||||
| client → server | `POST /api/vision/execute` | esegui il task per la produzione in corso; **il server sceglie dove** |
|
||||
| client → server | `POST /api/vision/preview` | authoring: esegui su una sorgente qualsiasi, non salvare nulla |
|
||||
| client → server | `GET /api/stations/{code}/devices` | l'elenco per il Maker |
|
||||
| client → server | `POST /api/vision/reference-images` | salva un fotogramma come riferimento |
|
||||
| server → worker | `POST /run` | interno, mai esposto: immagine e grafo → uscite e overlay |
|
||||
|
||||
Un solo endpoint decide dove si esegue, ed è `execute`. Il client non sa e non
|
||||
deve sapere se ha misurato il server o la stazione: chiede una misura e riceve un
|
||||
esito.
|
||||
|
||||
## Errori e casi limite
|
||||
|
||||
| Caso | Comportamento |
|
||||
|---|---|
|
||||
| Versione del motore diversa fra stazione e server | la stazione si rifiuta di misurare e lo dichiara. Mai produrre numeri con un motore non concorde |
|
||||
| Agente non raggiungibile | il task di visione non è eseguibile e lo dice. Da decidere con il cliente se la stazione debba consentire il ripiego sul calibro |
|
||||
| Server non raggiungibile, stazione con motore | la stazione esegue in locale e lo segna in `executed_on` |
|
||||
| Device dichiarato dalla ricetta ma offline | la ricetta resta valida, il task spiega perché non può girare |
|
||||
| Il grafo non produce un'uscita attesa da una subtask | la misura non si salva, l'errore nomina l'uscita mancante |
|
||||
| Elaborazione in crash o oltre il tempo | il worker isola il guasto, l'API resta in piedi, il task riporta l'errore |
|
||||
| Device senza calibrazione | le quote in millimetri non si producono. Mai convertire con un fattore implicito |
|
||||
|
||||
## Prove
|
||||
|
||||
Ai tre file di test che il progetto ha già e che leggono i sorgenti invece di
|
||||
renderizzare — `test_offline.py`, `test_layout_shell.py`,
|
||||
`test_template_js_syntax.py` — se ne aggiunge la stessa specie:
|
||||
|
||||
- **una prova che il server non importi VisionSuite.** È il confine su cui poggia
|
||||
la decisione 4, e senza qualcuno che lo controlli si perde in silenzio
|
||||
- **una prova che il commit del sottomodulo dichiarato coincida** con quello che
|
||||
server e agente montano
|
||||
|
||||
Per il resto, il runner si prova su immagini di riferimento salvate — che è
|
||||
precisamente ciò per cui l'astrazione della sorgente esiste. Nessuna prova
|
||||
richiede una camera collegata.
|
||||
|
||||
## Quello che questo design non risolve
|
||||
|
||||
- **Il dimensionamento del server.** Il pattern matching è pesante e, con
|
||||
l'esecuzione lato server come default, la CPU è contesa fra tutte le stazioni.
|
||||
Va aggiunto alla **D-4**, che finora chiedeva solo quale macchina e quanto
|
||||
disco: ora chiede anche quanti core e quanta RAM.
|
||||
- **La conservazione delle immagini.** Due politiche distinte, entrambe da
|
||||
portare in D-4: le immagini di riferimento sono poche e vivono quanto la
|
||||
ricetta; quelle di produzione sono una per pezzo e crescono senza limite, e
|
||||
vogliono una scadenza o una regola del tipo «si conservano solo quelle dei
|
||||
pezzi fuori tolleranza».
|
||||
- **Che cosa fa la stazione se la rete cade** e non è configurata per elaborare
|
||||
in locale: si blocca, o consente il calibro e segna il task come non
|
||||
eseguibile? È una domanda per il cliente.
|
||||
- **L'impacchettamento dell'agente.** Le macchine possono essere Linux o Windows
|
||||
e i driver possono stare sulla macchina o nel container. La decisione è
|
||||
volutamente tardiva; il design la rende tale tenendo l'agente sottile e neutro.
|
||||
|
||||
## Correzioni imposte dall'esecuzione
|
||||
|
||||
Il piano derivato da questo documento è stato eseguito il 16/08/2026. Sei cose
|
||||
che qui erano scritte male o taciute, corrette dai fatti.
|
||||
|
||||
### Il gate del fuori tolleranza vale per l'acquisizione, non per la quota
|
||||
|
||||
La decisione 7 e il testo del punto 5 parlano di «la quota successiva». Quella
|
||||
regola era stata pensata per la misura **manuale e sequenziale**, dove esiste
|
||||
davvero un momento successivo in cui l'operatore potrebbe tirare avanti con una
|
||||
lettura cattiva irrisolta.
|
||||
|
||||
Una camera produce tutte le N quote **nello stesso istante**. Dentro
|
||||
un'acquisizione non esiste un «tirare avanti», quindi lì non c'è nulla da
|
||||
presidiare — e applicare la regola sequenziale a un evento simultaneo produceva
|
||||
un blocco permanente: la lettura fuori tolleranza non veniva mai registrata,
|
||||
quindi nessuno poteva autorizzarla, e rifare la stessa foto ripeteva l'errore.
|
||||
|
||||
Il gate si valuta **una volta per acquisizione**: se la misura pendente è fra le
|
||||
quote che questa acquisizione riprodurrà non blocca — riacquisire *è* rimisurare
|
||||
quelle quote, che questo documento già dichiara la via d'uscita che non richiede
|
||||
il capoturno. Fra due acquisizioni, e alla chiusura del ciclo, blocca come prima.
|
||||
|
||||
### Il repository è deliberatamente bi-ambiente
|
||||
|
||||
`vs-task`, `vs-measure` e `vs-pm2d` dichiarano `requires-python >= 3.13`;
|
||||
`vs-core` si ferma a 3.10. Backend e frontend restano su **3.11**, l'albero della
|
||||
visione gira su **3.13**, e l'extra `vision` porta i marker d'ambiente che
|
||||
tengono separate le due risoluzioni.
|
||||
|
||||
Questo rafforza la decisione 4 più di come era argomentata: con VisionSuite dentro
|
||||
il server FastAPI, questa scoperta avrebbe costretto l'intero backend a 3.13.
|
||||
|
||||
### La versione del motore si timbra al build
|
||||
|
||||
`vendor/visionsuite/.git` è un file che rimanda a `../../.git/modules/...`, che
|
||||
nel contesto di build non esiste: interrogare git dentro il container non
|
||||
funziona. `engine_version()` legge `VISION_ENGINE_VERSION` dall'ambiente, ripiega
|
||||
su git nei checkout di sviluppo, e **alza un errore** se non risolve né l'uno né
|
||||
l'altro. Il build fallisce se la variabile manca.
|
||||
|
||||
### La divisione degli extra compra esplicitezza, non peso
|
||||
|
||||
Gli extra sono due — `vision` per il runner, `vision-worker` per il servizio web
|
||||
— ed è la forma giusta. Ma la motivazione che sembrava ovvia è falsa: `vs-pm2d`
|
||||
dichiara **di suo** `fastapi`, `uvicorn`, `python-multipart` e `pillow`, quindi
|
||||
un agente di stazione che fa pattern matching se li porterà dietro comunque. La
|
||||
correzione vera è a monte — una libreria non dovrebbe dipendere da un server web
|
||||
— e va aggiunta alle verifiche su VisionSuite qui sotto.
|
||||
|
||||
### L'immagine di produzione non viene ancora conservata
|
||||
|
||||
`vision_results.image_path` e `overlay_path` migrano e restano `NULL`: nulla le
|
||||
scrive. Il flusso descritto in questo documento si chiude con «il client mostra
|
||||
esito e immagine», e finché quelle colonne sono vuote una misura contestata non
|
||||
ha prova materiale. Consegna nominata del piano 1b, insieme all'overlay.
|
||||
|
||||
### Prima di applicare le migrazioni in produzione
|
||||
|
||||
La 011 rinomina un valore di enum su una tabella in esercizio. Va contato prima
|
||||
che nessuna riga usi `xf_compare`: in strict mode una riga sopravvissuta fa
|
||||
fallire l'`ALTER` a metà deploy, fuori strict mode diventa `''` in silenzio.
|
||||
Attenzione anche alla finestra: il `MODIFY` su `recipe_tasks` è una rinomina di
|
||||
valore e userà `ALGORITHM=COPY`, mentre quello su `measurements.input_method`
|
||||
aggiunge in coda e dovrebbe restare `INPLACE` — da confermare sulle dimensioni
|
||||
reali, perché `measurements` è la tabella grossa.
|
||||
|
||||
## Da verificare prima di cominciare
|
||||
|
||||
VisionSuite è stato lavorato fino al 15/08/2026 e il suo ultimo commit è
|
||||
*«Rassegna delle firme pubbliche prima che si congelino»*: le API si stanno
|
||||
stabilizzando proprio ora. Prima di fissare il commit del sottomodulo:
|
||||
|
||||
- **il suo README è indietro rispetto al codice.** Dichiara `vs-measure` «da
|
||||
scrivere» quando invece esiste, con circa 4.865 righe e tredici file di test, e
|
||||
dichiara `vs-dxf` non avviabile quando l'import di `common.calibration` non c'è
|
||||
più. Va riletto lo stato reale, non quello documentato.
|
||||
- **il test di calibrazione dato per rosso** — la focale recuperata a 373 contro
|
||||
456 attesi, con tolleranza al 5% — va verificato sullo stato attuale. Il README
|
||||
di VisionSuite avverte di guardarlo *prima* di costruirci sopra misure in
|
||||
millimetri, che è esattamente ciò che questo lavoro fa. Le calibrazioni sono
|
||||
state toccate il 13/08 e lo stato potrebbe essere cambiato.
|
||||
- **il peso dell'ambiente.** `vs-dxf` dipende da PyTorch e l'installazione
|
||||
completa arriva a circa 5 GB. È la ragione per cui il worker è un container a
|
||||
parte, e va confermato che il solo sottoinsieme necessario pesi meno.
|
||||
+8
-9
@@ -34,13 +34,6 @@ server {
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# Upload files served by server
|
||||
location /uploads/ {
|
||||
proxy_pass http://server_backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# All other requests → Flask client
|
||||
location / {
|
||||
proxy_pass http://client_frontend;
|
||||
@@ -50,12 +43,18 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Static file caching
|
||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
|
||||
# Static file caching. Limitata a /static/: un add_header in questo
|
||||
# location spegne l'ereditarietà di quelli del blocco server (comportamento
|
||||
# nginx), quindi vanno ripetuti qui.
|
||||
location ~* ^/static/.*\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
|
||||
proxy_pass http://client_frontend;
|
||||
proxy_set_header Host $host;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
|
||||
# SSL configuration (uncomment when using Let's Encrypt)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,81 @@
|
||||
; Installer di TieMeasureFlow (TieMeasureFlow#76).
|
||||
; Cosa fa: copia la cartella standalone di Nuitka in Program Files\Tielogic\TieMeasureFlow,
|
||||
; crea in ProgramData le cartelle scrivibili da tutti gli utenti (licenze, uploads, camera, log)
|
||||
; e i collegamenti in menu Start e sul desktop.
|
||||
; Come si usa: lo lancia build.ps1 con /DVersione /DVersione4 /DOrigine /DUscita; a mano:
|
||||
; ISCC /DVersione=3.0.0 /DVersione4=3.0.0.0 /DOrigine=build\nuitka\avvio.dist /DUscita=dist TieMeasureFlow.iss
|
||||
; Setup silenzioso: TieMeasureFlow-setup-X.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
|
||||
; (/CURRENTUSER per installare senza amministratore, in %LOCALAPPDATA%\Programs).
|
||||
; ATTENZIONE: la licenza NON e' nel setup: va copiata in {commonappdata}\Tielogic\licenze\tiemeasureflow.json
|
||||
; (la cartella e' scrivibile dagli utenti). La disinstallazione lascia licenze, database (tmf.db),
|
||||
; uploads e config.env: sono dati del cliente.
|
||||
|
||||
#ifndef Versione
|
||||
#define Versione "0.0.0"
|
||||
#endif
|
||||
#ifndef Versione4
|
||||
#define Versione4 "0.0.0.0"
|
||||
#endif
|
||||
#ifndef Origine
|
||||
#define Origine "..\..\build\nuitka\avvio.dist"
|
||||
#endif
|
||||
#ifndef Uscita
|
||||
#define Uscita "..\..\dist"
|
||||
#endif
|
||||
|
||||
[Setup]
|
||||
; AppId proprio di TieMeasureFlow (diverso da quello di TieVision Studio)
|
||||
AppId={{B7D4E2A1-5C93-4F6E-8A0B-3D1F7E9C2A64}
|
||||
AppName=TieMeasureFlow
|
||||
AppVersion={#Versione}
|
||||
AppVerName=TieMeasureFlow {#Versione}
|
||||
AppPublisher=Tielogic
|
||||
DefaultDirName={autopf}\Tielogic\TieMeasureFlow
|
||||
DefaultGroupName=Tielogic\TieMeasureFlow
|
||||
PrivilegesRequired=admin
|
||||
; /CURRENTUSER solo da riga di comando: serve alle prove senza amministratore
|
||||
PrivilegesRequiredOverridesAllowed=commandline
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
OutputDir={#Uscita}
|
||||
OutputBaseFilename=TieMeasureFlow-setup-{#Versione}
|
||||
SetupIconFile=TieMeasureFlow.ico
|
||||
UninstallDisplayIcon={app}\TieMeasureFlow.exe
|
||||
VersionInfoVersion={#Versione4}
|
||||
VersionInfoCompany=Tielogic
|
||||
VersionInfoProductName=TieMeasureFlow
|
||||
Compression=lzma2/max
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
DisableProgramGroupPage=yes
|
||||
; il programma in esecuzione tiene aperti i file: Inno chiede di chiuderlo (in silenzioso lo chiude)
|
||||
CloseApplications=yes
|
||||
|
||||
[Languages]
|
||||
Name: "italiano"; MessagesFile: "compiler:Languages\Italian.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "Crea un collegamento sul desktop"; GroupDescription: "Collegamenti:"
|
||||
|
||||
[Dirs]
|
||||
; scrivibili da tutti gli utenti: l'exe gira con l'utente che lo lancia, non con admin
|
||||
Name: "{commonappdata}\Tielogic\licenze"; Permissions: users-modify
|
||||
Name: "{commonappdata}\Tielogic\TieMeasureFlow"; Permissions: users-modify
|
||||
Name: "{commonappdata}\Tielogic\TieMeasureFlow\uploads"; Permissions: users-modify
|
||||
Name: "{commonappdata}\Tielogic\TieMeasureFlow\camera"; Permissions: users-modify
|
||||
Name: "{commonappdata}\Tielogic\TieMeasureFlow\log"; Permissions: users-modify
|
||||
|
||||
[Files]
|
||||
Source: "{#Origine}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\TieMeasureFlow"; Filename: "{app}\TieMeasureFlow.exe"
|
||||
Name: "{group}\Impronta di questo computer"; Filename: "{app}\TieMeasureFlow.exe"; Parameters: "--impronta"
|
||||
; l'exe non ha finestra: per fermarlo si chiude il processo
|
||||
Name: "{group}\Arresta TieMeasureFlow"; Filename: "{sys}\taskkill.exe"; Parameters: "/IM TieMeasureFlow.exe /F"; Flags: runminimized
|
||||
Name: "{group}\Cartella dati e configurazione"; Filename: "{commonappdata}\Tielogic\TieMeasureFlow"
|
||||
Name: "{group}\Disinstalla TieMeasureFlow"; Filename: "{uninstallexe}"
|
||||
Name: "{autodesktop}\TieMeasureFlow"; Filename: "{app}\TieMeasureFlow.exe"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\TieMeasureFlow.exe"; Description: "Avvia TieMeasureFlow"; Flags: nowait postinstall skipifsilent
|
||||
@@ -0,0 +1,536 @@
|
||||
"""Entry point del pacchetto Windows di TieMeasureFlow (TieMeasureFlow#76).
|
||||
|
||||
Cosa fa: controlla la licenza (prodotto "tiemeasureflow"); se non e' valida mostra il
|
||||
motivo e l'impronta del computer, scrive l'impronta in
|
||||
%PROGRAMDATA%\\Tielogic\\licenze\\impronta-tiemeasureflow.txt ed esce con 1.
|
||||
Se e' valida: prepara la cartella dati (%PROGRAMDATA%\\Tielogic\\TieMeasureFlow, con
|
||||
config.env dei segreti generati al primo avvio), imposta le variabili d'ambiente del
|
||||
contratto, esegue le migrazioni Alembic (SQLite su file) e avvia in un solo processo
|
||||
quattro server, ciascuno nel suo thread:
|
||||
web Flask (src/frontend/flask_app) servito da waitress, --host (default 127.0.0.1) :8780
|
||||
backend FastAPI src.backend.main:app, 127.0.0.1:8000
|
||||
vision FastAPI src.vision_worker.main:app, 127.0.0.1:8100
|
||||
camera scripts/camera_service.py (camere della stazione), 127.0.0.1:8765
|
||||
Poi apre il browser. Se un'altra copia e' gia' in esecuzione apre solo il browser.
|
||||
|
||||
Come si usa: e' l'exe compilato con Nuitka (build.ps1); da sorgente, dalla radice del repo
|
||||
con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`.
|
||||
Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove
|
||||
automatiche; NON salta il controllo di licenza) e `--senza-browser`.
|
||||
`--controlla-strumenti` (TieMeasureFlow#82): NON richiede la licenza, non avvia server, migrazioni
|
||||
ne' database; registra gli strumenti, scrive su stdout e in
|
||||
%PROGRAMDATA%\\Tielogic\\TieMeasureFlow\\log\\controllo-strumenti.json un JSON
|
||||
{strumenti, kind, falliti, unavailable} ed esce con 0 (strumenti > 0 e nessun fallito) o 3.
|
||||
La usa build.ps1 prima di Inno Setup.
|
||||
|
||||
Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic.
|
||||
|
||||
⚠️ L'exe e' senza console: stdout/stderr possono essere None, quindi il log va su file
|
||||
(<dati>\\log\\tiemeasureflow.log) e uvicorn gira con log_config=None.
|
||||
⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono
|
||||
la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni.
|
||||
⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss).
|
||||
⚠️ Nel binario Nuitka mancano i metadati di vs-tools, quindi `load_tools()` (che cerca gli
|
||||
strumenti come entry point) non ne trova nessuno: `build.ps1` genera `_strumenti.py`
|
||||
(MODULI, dagli entry point del venv) e `registra_strumenti()` li importa per nome (#82).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
|
||||
from visionsuite import license as lic # contratto licenza v1: impronta, cerca_licenza, verifica
|
||||
|
||||
PRODOTTO = "tiemeasureflow"
|
||||
TITOLO = "TieMeasureFlow"
|
||||
PORTA_WEB = 8780
|
||||
PORTA_BACKEND = 8000
|
||||
PORTA_VISION = 8100
|
||||
PORTA_CAMERA = 8765
|
||||
log = logging.getLogger("tmf.avvio")
|
||||
|
||||
# Nuitka definisce __compiled__ nel modulo compilato; da sorgente non esiste
|
||||
COMPILATO = "__compiled__" in globals()
|
||||
# Da sorgente (python packaging/windows/avvio.py) la radice e' due livelli sopra questo file
|
||||
RADICE_SORGENTI = Path(__file__).resolve().parents[2]
|
||||
# Segreti generati al primo avvio e riletti dopo (nome -> generatore)
|
||||
SEGRETI = ("SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD")
|
||||
# STATION_CODE: senza una stazione l'interfaccia risponde 503; ST-DEFAULT e' quella creata dal seed della pagina di setup
|
||||
PREDEFINITI = {"STATION_CODE": "ST-DEFAULT"}
|
||||
|
||||
|
||||
def _stream_sicuri() -> None:
|
||||
# Exe senza console: stdout/stderr sono None e print/logging/fileConfig di alembic
|
||||
# andrebbero in errore; li si sostituisce con il device nullo
|
||||
if sys.stdout is None:
|
||||
sys.stdout = open(os.devnull, "w", encoding="utf-8")
|
||||
if sys.stderr is None:
|
||||
sys.stderr = open(os.devnull, "w", encoding="utf-8")
|
||||
|
||||
|
||||
def cartella_tielogic() -> Path:
|
||||
# %PROGRAMDATA% e' sempre impostata su Windows; il ripiego serve solo a sviluppo/test su altri sistemi
|
||||
base = os.environ.get("PROGRAMDATA") or str(Path.home() / ".tielogic-dati")
|
||||
return Path(base) / "Tielogic"
|
||||
|
||||
|
||||
def cartella_dati() -> Path:
|
||||
return cartella_tielogic() / "TieMeasureFlow"
|
||||
|
||||
|
||||
def cartella_programma() -> Path:
|
||||
# Cartella dell'exe (P); da sorgente, la radice del repo
|
||||
if COMPILATO:
|
||||
return Path(sys.argv[0]).resolve().parent
|
||||
return RADICE_SORGENTI
|
||||
|
||||
|
||||
def percorso_impronta() -> Path:
|
||||
return cartella_tielogic() / "licenze" / f"impronta-{PRODOTTO}.txt"
|
||||
|
||||
|
||||
def messaggio(testo: str, titolo: str = TITOLO, errore: bool = True, senza_finestre: bool = False) -> None:
|
||||
# Sempre su stderr (se esiste); la MessageBox solo su Windows e se non soppressa
|
||||
if sys.stderr is not None:
|
||||
print(testo, file=sys.stderr)
|
||||
if senza_finestre or sys.platform != "win32":
|
||||
return
|
||||
# MB_OK | MB_ICONERROR (0x10) oppure MB_ICONINFORMATION (0x40) | MB_TOPMOST (0x40000)
|
||||
ctypes.windll.user32.MessageBoxW(None, testo, titolo, 0x40000 | (0x10 if errore else 0x40))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- licenza
|
||||
|
||||
def _scrivi_impronta(impronta: str) -> None:
|
||||
try:
|
||||
p = percorso_impronta()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(impronta + "\n", encoding="utf-8")
|
||||
except OSError as e:
|
||||
log.warning("impronta non scritta: %s", e)
|
||||
|
||||
|
||||
def controlla_licenza(senza_finestre: bool = False) -> bool:
|
||||
"""True se la licenza e' valida; altrimenti mostra il motivo e l'impronta e torna False."""
|
||||
try:
|
||||
percorso = lic.cerca_licenza(PRODOTTO)
|
||||
lic.verifica(percorso, PRODOTTO)
|
||||
return True
|
||||
except lic.LicenzaNonValida as e:
|
||||
motivo = str(e)
|
||||
except Exception as e: # impronta non calcolabile, file illeggibile: mai un'eccezione muta nell'exe
|
||||
motivo = f"Controllo licenza non riuscito: {e}"
|
||||
try:
|
||||
impronta = lic.impronta()
|
||||
except Exception as e:
|
||||
impronta = None
|
||||
motivo += f"\nImpronta macchina non disponibile: {e}"
|
||||
testo = f"{motivo}\n\n"
|
||||
if impronta:
|
||||
_scrivi_impronta(impronta)
|
||||
testo += (f"Impronta di questo computer:\n{impronta}\n\n"
|
||||
f"Comunicala a Tielogic per ottenere la licenza (salvata anche in {percorso_impronta()}). "
|
||||
f"La licenza va copiata in {lic.cerca_licenza(PRODOTTO)}.")
|
||||
messaggio(testo, f"{TITOLO} - licenza", senza_finestre=senza_finestre)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- configurazione
|
||||
|
||||
def _leggi_env(percorso: Path) -> dict[str, str]:
|
||||
valori: dict[str, str] = {}
|
||||
for riga in percorso.read_text(encoding="utf-8").splitlines():
|
||||
riga = riga.strip()
|
||||
if riga and not riga.startswith("#") and "=" in riga:
|
||||
k, v = riga.split("=", 1)
|
||||
valori[k.strip()] = v.strip()
|
||||
return valori
|
||||
|
||||
|
||||
def carica_segreti(dati: Path) -> dict[str, str]:
|
||||
"""Legge <dati>\\config.env; i segreti mancanti si generano e si riscrivono (una volta sola)."""
|
||||
dati.mkdir(parents=True, exist_ok=True)
|
||||
percorso = dati / "config.env"
|
||||
valori = _leggi_env(percorso) if percorso.exists() else {}
|
||||
nuovi = [k for k in SEGRETI if not valori.get(k)]
|
||||
# valori di partenza modificabili dall'utente in config.env (scritti una volta sola)
|
||||
mancanti = [k for k in PREDEFINITI if k not in valori]
|
||||
if nuovi or mancanti:
|
||||
for k in nuovi:
|
||||
valori[k] = secrets.token_urlsafe(32)
|
||||
for k in mancanti:
|
||||
valori[k] = PREDEFINITI[k]
|
||||
righe = ["# Generato da TieMeasureFlow al primo avvio. Non condividere questo file.",
|
||||
"# SETUP_PASSWORD serve per la pagina http://127.0.0.1:8000/api/setup (primo amministratore)."]
|
||||
righe += [f"{k}={v}" for k, v in valori.items()]
|
||||
percorso.write_text("\n".join(righe) + "\n", encoding="utf-8")
|
||||
log.info("segreti generati in %s", percorso)
|
||||
return valori
|
||||
|
||||
|
||||
def trova_browser() -> str | None:
|
||||
"""Chrome o Edge per kaleido (grafici dei report PDF, offline)."""
|
||||
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||
pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
||||
local = os.environ.get("LOCALAPPDATA", "")
|
||||
candidati = [
|
||||
Path(pf) / "Google/Chrome/Application/chrome.exe",
|
||||
Path(pf86) / "Google/Chrome/Application/chrome.exe",
|
||||
Path(local) / "Google/Chrome/Application/chrome.exe" if local else None,
|
||||
Path(pf86) / "Microsoft/Edge/Application/msedge.exe",
|
||||
Path(pf) / "Microsoft/Edge/Application/msedge.exe",
|
||||
]
|
||||
for c in candidati:
|
||||
if c is not None and c.is_file():
|
||||
return str(c)
|
||||
return None
|
||||
|
||||
|
||||
def imposta_ambiente(dati: Path, host: str, valori: dict[str, str]) -> None:
|
||||
"""Variabili d'ambiente del contratto v1; da chiamare PRIMA di importare backend e frontend."""
|
||||
uploads = dati / "uploads"
|
||||
env = {
|
||||
"APP_ENV": "production",
|
||||
# barre "/" nel percorso: l'URL SQLAlchemy non vuole le barre rovesciate
|
||||
"DB_URL": f"sqlite+aiosqlite:///{(dati / 'tmf.db').as_posix()}",
|
||||
"UPLOAD_DIR": str(uploads),
|
||||
"VISION_WORKER_URL": f"http://127.0.0.1:{PORTA_VISION}",
|
||||
"VISION_RECIPES_ROOT": str(uploads),
|
||||
"API_SERVER_URL": f"http://127.0.0.1:{PORTA_BACKEND}",
|
||||
"SESSION_COOKIE_SECURE": "0", # niente HTTPS in locale: un cookie Secure verrebbe scartato
|
||||
"SERVER_CORS_ORIGINS": f"http://127.0.0.1:{PORTA_WEB},http://localhost:{PORTA_WEB}",
|
||||
**{k: valori[k] for k in SEGRETI},
|
||||
}
|
||||
# le altre chiavi di config.env (STATION_CODE, STATION_SWITCH_ENABLED, ...) passano cosi' come sono,
|
||||
# ma non sovrascrivono le variabili fisse del contratto
|
||||
for k, v in valori.items():
|
||||
env.setdefault(k, v)
|
||||
programma = cartella_programma()
|
||||
versione = programma / "vision-engine-version.txt" # scritto da build.ps1
|
||||
if versione.is_file():
|
||||
env["VISION_ENGINE_VERSION"] = versione.read_text(encoding="utf-8").strip()
|
||||
gtk = programma / "gtk" # DLL di WeasyPrint (piano B)
|
||||
if gtk.is_dir():
|
||||
env["WEASYPRINT_DLL_DIRECTORIES"] = str(gtk)
|
||||
if hasattr(os, "add_dll_directory"):
|
||||
# cintura e bretelle: WeasyPrint salta add_dll_directory se trova sys.frozen
|
||||
try:
|
||||
os.add_dll_directory(str(gtk))
|
||||
except OSError:
|
||||
pass
|
||||
browser = trova_browser()
|
||||
if browser:
|
||||
env["BROWSER_PATH"] = browser
|
||||
os.environ.update(env)
|
||||
for cartella in (uploads, dati / "camera", dati / "log"):
|
||||
cartella.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _prepara_percorsi_sorgenti() -> None:
|
||||
# Da sorgente i moduli del frontend (config, services, blueprints, app) e lo script camera
|
||||
# non sono pacchetti: si aggiungono al path. Compilati, Nuitka li ha gia' incorporati.
|
||||
if COMPILATO:
|
||||
return
|
||||
for p in (RADICE_SORGENTI / "scripts", RADICE_SORGENTI / "src" / "frontend" / "flask_app", RADICE_SORGENTI):
|
||||
if str(p) not in sys.path:
|
||||
sys.path.insert(0, str(p))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- log
|
||||
|
||||
def _prepara_log(dati: Path | None = None) -> None:
|
||||
# Exe senza console: il log va su file; se la cartella non e' scrivibile si prosegue senza.
|
||||
# Idempotente: alembic (fileConfig in env.py) azzera i gestori del root, quindi si richiama dopo.
|
||||
try:
|
||||
d = (dati or cartella_dati()) / "log"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
percorso = str(d / "tiemeasureflow.log")
|
||||
root = logging.getLogger()
|
||||
for h in root.handlers:
|
||||
if isinstance(h, logging.FileHandler) and h.baseFilename.lower() == os.path.abspath(percorso).lower():
|
||||
break
|
||||
else:
|
||||
h = logging.FileHandler(percorso, encoding="utf-8")
|
||||
h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
||||
root.addHandler(h)
|
||||
root.setLevel(logging.INFO)
|
||||
for nome in ("tmf.avvio",):
|
||||
logging.getLogger(nome).disabled = False # fileConfig disattiva i logger esistenti
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- strumenti di visione
|
||||
|
||||
def registra_strumenti() -> list[str]:
|
||||
"""Registra gli strumenti di Core; torna i moduli che non si sono importati (#82).
|
||||
|
||||
Nel pacchetto Nuitka gli entry point non esistono: `load_tools()` non trova nulla e
|
||||
l'elenco dei moduli arriva da `_strumenti.py`, generato da build.ps1. Da sorgente
|
||||
`_strumenti` non c'e' e bastano gli entry point. Il worker di visione gira in un thread
|
||||
di questo processo: registrare una volta sola qui vale per tutti."""
|
||||
from visionsuite.task.registry import load_tools
|
||||
load_tools()
|
||||
try:
|
||||
from _strumenti import MODULI # generato da build.ps1, accanto ad avvio.py
|
||||
except ImportError:
|
||||
MODULI = () # da sorgente: gli entry point di load_tools() bastano
|
||||
falliti: list[str] = []
|
||||
for modulo in MODULI:
|
||||
try:
|
||||
importlib.import_module(modulo) # eseguire il modulo registra i suoi @tool
|
||||
except Exception: # un modulo rotto non ferma l'app: si logga e si raccoglie
|
||||
log.error("strumenti %s non importati", modulo, exc_info=True)
|
||||
falliti.append(modulo)
|
||||
return falliti
|
||||
|
||||
|
||||
def controlla_strumenti() -> int:
|
||||
"""Senza licenza, server ne' DB: scrive il JSON del catalogo strumenti. Exit 0 se completo, 3 altrimenti."""
|
||||
from visionsuite.task.graph import registry
|
||||
from visionsuite.task.registry import unavailable
|
||||
falliti = registra_strumenti()
|
||||
kind = sorted(registry())
|
||||
esito = {"strumenti": len(kind), "kind": kind, "falliti": falliti, "unavailable": unavailable()}
|
||||
riga = json.dumps(esito, ensure_ascii=False)
|
||||
if sys.stdout is not None:
|
||||
print(riga)
|
||||
try:
|
||||
f = cartella_dati() / "log" / "controllo-strumenti.json"
|
||||
f.parent.mkdir(parents=True, exist_ok=True)
|
||||
f.write_text(riga + "\n", encoding="utf-8") # l'exe e' senza console: il file e' l'unica uscita sicura
|
||||
except OSError as e:
|
||||
log.warning("controllo-strumenti.json non scritto: %s", e)
|
||||
return 0 if kind and not falliti else 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- rete
|
||||
|
||||
def _porta_aperta(porta: int) -> bool:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", porta), timeout=0.5):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _e_tmf() -> bool:
|
||||
# Una copia gia' in esecuzione: il backend risponde su /api/health con il nome del servizio
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{PORTA_BACKEND}/api/health", timeout=2) as r:
|
||||
return "TieMeasureFlow" in str(json.load(r).get("service", ""))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _nessun_utente(db: Path) -> bool:
|
||||
"""True se il database esiste ed e' senza utenti (primo avvio: serve la pagina di setup)."""
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=2)
|
||||
try:
|
||||
return con.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 0
|
||||
finally:
|
||||
con.close()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _apri_browser_quando_pronto(url: str, porte: tuple[int, ...]) -> None:
|
||||
# Aspetta che i server ascoltino (max ~60 s), poi apre il browser predefinito
|
||||
for _ in range(300):
|
||||
if all(_porta_aperta(p) for p in porte):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
webbrowser.open(url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- migrazioni
|
||||
|
||||
def _neutralizza_fileconfig() -> None:
|
||||
# env.py di Alembic chiama logging.config.fileConfig (anche la pagina di setup, con command.stamp):
|
||||
# azzererebbe i gestori del log su file. Nel pacchetto il log lo decide avvio.py.
|
||||
import logging.config
|
||||
logging.config.fileConfig = lambda *a, **k: None
|
||||
|
||||
|
||||
def esegui_migrazioni() -> None:
|
||||
"""alembic upgrade head. Compilato: env.py e versions\\ sono soli .pyc, quindi sourceless."""
|
||||
_neutralizza_fileconfig()
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
import src.backend as pacchetto
|
||||
migrazioni = Path(pacchetto.__file__).resolve().parent / "migrations"
|
||||
cfg = Config(str(migrazioni / "alembic.ini"))
|
||||
if COMPILATO:
|
||||
cfg.set_main_option("sourceless", "true")
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- server
|
||||
|
||||
class ServerThread(threading.Thread):
|
||||
"""Un server (uvicorn o waitress) in un thread; `errore` resta valorizzato se muore."""
|
||||
|
||||
def __init__(self, nome: str, costruisci):
|
||||
super().__init__(name=nome, daemon=True)
|
||||
self._costruisci = costruisci
|
||||
self.server = None
|
||||
self.errore: BaseException | None = None
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.server = self._costruisci()
|
||||
self.server.run()
|
||||
except BaseException as e: # un server morto deve comparire nel log e fermare l'insieme
|
||||
self.errore = e
|
||||
log.exception("server %s terminato con errore", self.name)
|
||||
|
||||
def ferma(self) -> None:
|
||||
s = self.server
|
||||
try:
|
||||
if hasattr(s, "should_exit"):
|
||||
s.should_exit = True # uvicorn
|
||||
elif s is not None:
|
||||
s.close() # waitress
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _uvicorn(app, porta: int):
|
||||
import uvicorn
|
||||
# log_config=None: niente console; i thread non toccano i signal handler (uvicorn lo gestisce)
|
||||
return uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=porta, log_config=None, workers=1))
|
||||
|
||||
|
||||
def costruisci_web(host: str):
|
||||
import waitress
|
||||
from app import create_app # src/frontend/flask_app/app.py
|
||||
return waitress.create_server(create_app(), host=host, port=PORTA_WEB, threads=8)
|
||||
|
||||
|
||||
def costruisci_backend():
|
||||
from src.backend.main import app
|
||||
return _uvicorn(app, PORTA_BACKEND)
|
||||
|
||||
|
||||
def costruisci_vision():
|
||||
from src.vision_worker.main import app
|
||||
return _uvicorn(app, PORTA_VISION)
|
||||
|
||||
|
||||
def costruisci_camera(cartella: Path):
|
||||
from camera_service import create_app # scripts/camera_service.py (non e' un pacchetto)
|
||||
return _uvicorn(create_app(cartella), PORTA_CAMERA)
|
||||
|
||||
|
||||
def avvia_server(host: str, dati: Path) -> list[ServerThread]:
|
||||
thread = [
|
||||
ServerThread("backend", costruisci_backend),
|
||||
ServerThread("vision", costruisci_vision),
|
||||
ServerThread("camera", lambda: costruisci_camera(dati / "camera")),
|
||||
ServerThread("web", lambda: costruisci_web(host)),
|
||||
]
|
||||
for t in thread:
|
||||
t.start()
|
||||
return thread
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
_stream_sicuri()
|
||||
p = argparse.ArgumentParser(prog="TieMeasureFlow", description=TITOLO)
|
||||
p.add_argument("--impronta", action="store_true", help="mostra l'impronta di questo computer")
|
||||
p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)")
|
||||
p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)")
|
||||
p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)")
|
||||
p.add_argument("--controlla-strumenti", action="store_true",
|
||||
help="registra gli strumenti, scrive il JSON ed esce (senza licenza ne' server; usata da build.ps1)")
|
||||
args = p.parse_args(argv)
|
||||
_prepara_log()
|
||||
|
||||
if args.impronta:
|
||||
try:
|
||||
impronta = lic.impronta()
|
||||
except Exception as e:
|
||||
messaggio(f"Impronta non disponibile: {e}", senza_finestre=args.senza_finestre)
|
||||
return 1
|
||||
print(impronta) if sys.stdout is not None else None
|
||||
messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre)
|
||||
return 0
|
||||
|
||||
if args.controlla_strumenti:
|
||||
return controlla_strumenti() # prima della licenza: la build non ha una licenza
|
||||
|
||||
if not controlla_licenza(args.senza_finestre):
|
||||
return 1
|
||||
|
||||
if _porta_aperta(PORTA_WEB):
|
||||
if _e_tmf():
|
||||
# TieMeasureFlow e' gia' in esecuzione: basta riaprire la pagina
|
||||
if not args.senza_browser:
|
||||
webbrowser.open(f"http://127.0.0.1:{PORTA_WEB}/")
|
||||
return 0
|
||||
messaggio(f"La porta {PORTA_WEB} e' occupata da un altro programma: chiuderlo e riprovare.",
|
||||
senza_finestre=args.senza_finestre)
|
||||
return 2
|
||||
occupate = [str(x) for x in (PORTA_BACKEND, PORTA_VISION, PORTA_CAMERA) if _porta_aperta(x)]
|
||||
if occupate:
|
||||
messaggio(f"Porte occupate da altri programmi: {', '.join(occupate)}. Chiuderli e riprovare.",
|
||||
senza_finestre=args.senza_finestre)
|
||||
return 2
|
||||
|
||||
dati = cartella_dati()
|
||||
try:
|
||||
valori = carica_segreti(dati)
|
||||
imposta_ambiente(dati, args.host, valori)
|
||||
_prepara_percorsi_sorgenti()
|
||||
primo_avvio = not (dati / "tmf.db").exists()
|
||||
esegui_migrazioni()
|
||||
except Exception as e:
|
||||
_prepara_log(dati)
|
||||
log.exception("avvio non riuscito")
|
||||
messaggio(f"Avvio non riuscito: {e}\nDettagli in {dati / 'log' / 'tiemeasureflow.log'}",
|
||||
senza_finestre=args.senza_finestre)
|
||||
return 3
|
||||
_prepara_log(dati) # alembic ha riconfigurato il logging
|
||||
log.info("migrazioni applicate (primo avvio: %s)", primo_avvio)
|
||||
|
||||
registra_strumenti() # prima del server: il worker di visione legge registry() gia' popolato (#82)
|
||||
thread = avvia_server(args.host, dati)
|
||||
if not args.senza_browser:
|
||||
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
|
||||
url = (f"http://127.0.0.1:{PORTA_BACKEND}/api/setup" if _nessun_utente(dati / "tmf.db")
|
||||
else f"http://127.0.0.1:{PORTA_WEB}/")
|
||||
threading.Thread(target=_apri_browser_quando_pronto,
|
||||
args=(url, (PORTA_WEB, PORTA_BACKEND)), daemon=True).start()
|
||||
|
||||
# Si resta vivi finche' tutti i server girano; se uno muore si ferma tutto
|
||||
try:
|
||||
while thread and all(t.is_alive() for t in thread):
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
morti = [t for t in thread if not t.is_alive()]
|
||||
for t in thread:
|
||||
t.ferma()
|
||||
if morti:
|
||||
messaggio("Un componente si e' fermato: " + ", ".join(f"{t.name} ({t.errore})" for t in morti)
|
||||
+ f"\nDettagli in {dati / 'log' / 'tiemeasureflow.log'}", senza_finestre=args.senza_finestre)
|
||||
return 4
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,247 @@
|
||||
<#
|
||||
Pacchetto Windows di TieMeasureFlow (TieMeasureFlow#76).
|
||||
|
||||
Cosa fa: uv sync in un ambiente Python 3.13 separato (build\venv313), frontend (Tailwind +
|
||||
traduzioni), compila avvio.py con Nuitka in modalita' --standalone (cartella, NON onefile:
|
||||
l'onefile si decomprime in %TEMP% ad ogni avvio), aggiunge le migrazioni Alembic come soli
|
||||
.pyc, le DLL di WeasyPrint (piano B) e il commit del motore di visione, poi Inno Setup
|
||||
produce dist\TieMeasureFlow-setup-<versione>.exe. Sul PC del cliente non finisce nessun
|
||||
.py; i .pyc ci sono solo in src\backend\migrations (Alembic le carica dal disco).
|
||||
|
||||
Come si usa (dalla radice del repo, con il submodule vendor/visionsuite inizializzato):
|
||||
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 [-Versione 3.0.0]
|
||||
-Versione accetta anche un tag (v3.0.0, v3.0.0-rc1): si usano i numeri iniziali.
|
||||
Senza -Versione si usa la variabile GITHUB_REF_NAME (tag nella CI) o 0.0.0.
|
||||
-SenzaSetup si ferma dopo Nuitka (cartella build\nuitka\avvio.dist).
|
||||
|
||||
Dipende da: uv, node/npx (Tailwind 3.4.19 da npm: serve Internet SOLO a chi costruisce),
|
||||
Visual Studio con carico C++ (MSVC; Nuitka lo trova da solo), Inno Setup 6
|
||||
(`winget install --id JRSoftware.InnoSetup -e`; ISCC.exe nel PATH, in Program Files o in
|
||||
%LOCALAPPDATA%\Programs). Nuitka e' nel gruppo uv "windows" (pyproject.toml).
|
||||
|
||||
⚠️ La prima compilazione e' lunga (decine di minuti: numpy/opencv/scipy/plotly); le successive
|
||||
riusano la cache di Nuitka (%LOCALAPPDATA%\Nuitka) e la cartella build\nuitka.
|
||||
⚠️ I pacchetti di Core condividono il namespace visionsuite (extend_path): Nuitka non lo
|
||||
segue da solo, quindi i sottopacchetti si elencano uno per uno dagli __path__ installati.
|
||||
⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba
|
||||
non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False):
|
||||
funziona ma e' piu' lento nel matching.
|
||||
⚠️ Gli strumenti di vs-tools si registrano solo come entry point (visionsuite.task.tools) e i
|
||||
metadati di vs-tools non stanno nel binario (TieMeasureFlow#82): il build genera
|
||||
packaging\windows\_strumenti.py (MODULI, gitignorato) che avvio.py importa, e prima di
|
||||
Inno Setup esegue l'exe con --controlla-strumenti: se il catalogo e' vuoto il build fallisce.
|
||||
⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`):
|
||||
si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo.
|
||||
#>
|
||||
param(
|
||||
[string]$Versione = $env:GITHUB_REF_NAME,
|
||||
[switch]$SenzaSetup
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$radice = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
Set-Location $radice
|
||||
|
||||
# Versione numerica a 4 parti per i metadati dell'exe (Nuitka la vuole cosi')
|
||||
if (-not $Versione) { $Versione = '0.0.0' }
|
||||
if ($Versione -notmatch '^v?(\d+)\.(\d+)\.(\d+)') { throw "Versione non valida: '$Versione' (atteso es. v3.0.0)" }
|
||||
$ver3 = "$($Matches[1]).$($Matches[2]).$($Matches[3])"
|
||||
$ver4 = "$ver3.0"
|
||||
Write-Host "== TieMeasureFlow $ver3 =="
|
||||
|
||||
# Controlli: se manca qualcosa il messaggio dice come installarlo
|
||||
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
||||
throw "uv non trovato. Installarlo: https://docs.astral.sh/uv/getting-started/installation/"
|
||||
}
|
||||
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
|
||||
throw "node/npx non trovato (serve a compilare Tailwind). Installarlo da https://nodejs.org"
|
||||
}
|
||||
if (-not (Test-Path (Join-Path $radice 'vendor\visionsuite\packages\vs-license'))) {
|
||||
throw "Submodule vendor/visionsuite mancante o vecchio: git submodule update --init --recursive"
|
||||
}
|
||||
$iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source
|
||||
if (-not $iscc) {
|
||||
foreach ($c in @("${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", "$env:ProgramFiles\Inno Setup 6\ISCC.exe",
|
||||
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe")) {
|
||||
if (Test-Path $c) { $iscc = $c; break }
|
||||
}
|
||||
}
|
||||
if (-not $iscc -and -not $SenzaSetup) { throw "Inno Setup 6 non trovato. Installarlo: winget install --id JRSoftware.InnoSetup -e" }
|
||||
|
||||
# Ambiente separato da quello di sviluppo (3.11): extra server + client + vision-worker + gruppo windows
|
||||
$env:UV_PROJECT_ENVIRONMENT = Join-Path $radice 'build\venv313'
|
||||
uv sync --python 3.13 --frozen --extra server --extra client --extra vision-worker --group windows
|
||||
if ($LASTEXITCODE -ne 0) { throw "uv sync fallito" }
|
||||
$py = Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\python.exe'
|
||||
$vn = & $py -m nuitka --version # niente Select-Object -First: chiude la pipe e falsa l'exit code
|
||||
if ($LASTEXITCODE -ne 0) { throw "Nuitka non disponibile (serve anche un compilatore C: Visual Studio con 'Sviluppo di applicazioni desktop con C++')" }
|
||||
|
||||
# Frontend: tailwind.css non e' nel repo (stessa versione esatta di Dockerfile.frontend) e traduzioni .mo
|
||||
$flask = Join-Path $radice 'src\frontend\flask_app'
|
||||
Push-Location $flask
|
||||
try {
|
||||
npx --yes tailwindcss@3.4.19 -i static/css/input.css -o static/css/tailwind.css --minify
|
||||
if ($LASTEXITCODE -ne 0) { throw "Tailwind fallito" }
|
||||
& (Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\pybabel.exe') compile -d translations
|
||||
if ($LASTEXITCODE -ne 0) { throw "pybabel compile fallito" }
|
||||
} finally { Pop-Location }
|
||||
|
||||
# Sottopacchetti del namespace visionsuite (core, camera, task, tools, license, ...)
|
||||
$pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))"
|
||||
if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" }
|
||||
$includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" })
|
||||
# Nessun --include-distribution-metadata per vs-*: Nuitka rifiuta i metadati di una distribuzione il cui
|
||||
# pacchetto (visionsuite.camera, namespace) non e' di primo livello. Gli entry point di vs-tools, che
|
||||
# load_tools() cercherebbe nei metadati, si rimpiazzano con _strumenti.py (sotto, TieMeasureFlow#82).
|
||||
Write-Host "Pacchetti visionsuite: $($includi -join ' ')"
|
||||
|
||||
# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..."
|
||||
# del LEGGIMI.md, con l'eventuale commento "# ..." in coda. I dati di src/backend/templates sono gia' sopra.
|
||||
$flagPdf = @()
|
||||
$leggimi = Join-Path $PSScriptRoot 'pdf\LEGGIMI.md'
|
||||
if (Test-Path $leggimi) {
|
||||
$flagPdf = @(Get-Content $leggimi | ForEach-Object {
|
||||
if ($_ -match '^\s*(--(?:include|nofollow|noinclude|enable)[^\s`#]*)\s*(?:#.*)?$' -and $Matches[1] -notlike '--include-data-dir=src/backend/templates*') { $Matches[1] }
|
||||
})
|
||||
Write-Host "Flag Nuitka da pdf\LEGGIMI.md: $($flagPdf -join ' ')"
|
||||
}
|
||||
|
||||
# Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH
|
||||
$env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';')
|
||||
|
||||
# TieMeasureFlow#82: i moduli-strumento dagli entry point del venv del build (nel binario non ci sono i
|
||||
# metadati di vs-tools, quindi load_tools() non li troverebbe). Si usa $py, non `uv run`.
|
||||
$strumenti = Join-Path $radice 'packaging\windows\_strumenti.py'
|
||||
$genera = @'
|
||||
import sys
|
||||
from importlib.metadata import entry_points
|
||||
moduli = sorted({ep.value for ep in entry_points(group="visionsuite.task.tools")})
|
||||
if not moduli:
|
||||
sys.exit(1)
|
||||
righe = ["# Generato da build.ps1 (TieMeasureFlow#82): non modificare, non committare.", "MODULI = ("]
|
||||
righe += [f" {m!r}," for m in moduli] + [")"]
|
||||
with open(sys.argv[1], "w", encoding="utf-8", newline="") as f:
|
||||
print(*righe, sep=chr(10), file=f)
|
||||
print(len(moduli))
|
||||
'@
|
||||
$nModuli = $genera | & $py - $strumenti
|
||||
if ($LASTEXITCODE -ne 0) { throw "Entry point visionsuite.task.tools non trovati nel venv: nessuno strumento da includere" }
|
||||
Write-Host "Moduli-strumento ($nModuli) in $strumenti"
|
||||
|
||||
$out = Join-Path $radice 'build\nuitka'
|
||||
New-Item -ItemType Directory -Force $out | Out-Null
|
||||
$nuitka = @(
|
||||
'--standalone',
|
||||
'--assume-yes-for-downloads',
|
||||
'--msvc=latest',
|
||||
'--windows-console-mode=disable', # nessuna finestra nera: gli errori escono in MessageBox e nel log
|
||||
"--output-dir=$out",
|
||||
'--output-filename=TieMeasureFlow.exe',
|
||||
'--windows-icon-from-ico=packaging\windows\TieMeasureFlow.ico',
|
||||
'--company-name=Tielogic',
|
||||
'--product-name=TieMeasureFlow',
|
||||
"--file-version=$ver4",
|
||||
"--product-version=$ver4",
|
||||
'--file-description=TieMeasureFlow',
|
||||
'--copyright=Tielogic',
|
||||
# codice del prodotto: backend, worker di visione, runner (src e' un pacchetto, flask_app no)
|
||||
'--include-package=src.backend',
|
||||
'--include-package=src.vision',
|
||||
'--include-package=src.vision_worker',
|
||||
# le migrazioni NON si compilano: Alembic le carica dal disco, vanno come .pyc (sotto)
|
||||
'--nofollow-import-to=src.backend.migrations',
|
||||
'--nofollow-import-to=src.backend.tests',
|
||||
'--nofollow-import-to=src.vision.tests',
|
||||
'--nofollow-import-to=src.vision_worker.tests',
|
||||
# frontend Flask e servizio camera: moduli di primo livello trovati via PYTHONPATH
|
||||
'--include-module=app',
|
||||
'--include-module=config',
|
||||
'--include-package=blueprints',
|
||||
'--include-package=services',
|
||||
'--include-module=camera_service',
|
||||
'--nofollow-import-to=blueprints.tests',
|
||||
'--nofollow-import-to=tests',
|
||||
'--include-package=uvicorn', # loop e protocolli di uvicorn si caricano per nome
|
||||
'--include-package=waitress',
|
||||
'--include-package=alembic',
|
||||
'--include-module=aiosqlite',
|
||||
'--include-module=sqlalchemy.dialects.sqlite.aiosqlite',
|
||||
'--include-package=harvesters', # camere GenTL (GigE): il producer .cti e' del vendor
|
||||
'--include-package=genicam',
|
||||
'--include-package-data=genicam',
|
||||
'--include-package-data=babel', # locale-data di Babel (flask-babel)
|
||||
'--nofollow-import-to=numba', # vedi nota in testa: il ripiego in Python puro
|
||||
'--nofollow-import-to=llvmlite',
|
||||
# dati letti dal disco con __file__: setup page, report, Flask (templates/static/translations alla radice)
|
||||
'--include-data-dir=src\backend\templates=src\backend\templates',
|
||||
'--include-data-files=src\backend\migrations\alembic.ini=src\backend\migrations\alembic.ini',
|
||||
'--include-data-dir=src\frontend\flask_app\templates=templates',
|
||||
'--include-data-dir=src\frontend\flask_app\static=static',
|
||||
'--include-data-dir=src\frontend\flask_app\translations=translations',
|
||||
'--include-data-files=pyproject.toml=pyproject.toml' # config.py ne legge la versione per il piede di pagina
|
||||
) + $includi + $flagPdf + @('packaging\windows\avvio.py')
|
||||
|
||||
$t0 = Get-Date
|
||||
& $py -m nuitka @nuitka
|
||||
if ($LASTEXITCODE -ne 0) { throw "Nuitka fallito (codice $LASTEXITCODE)" }
|
||||
Write-Host ("Nuitka: {0:N0} minuti" -f ((Get-Date) - $t0).TotalMinutes)
|
||||
|
||||
$sorgente = Join-Path $out 'avvio.dist'
|
||||
if (-not (Test-Path (Join-Path $sorgente 'TieMeasureFlow.exe'))) { throw "exe non trovato in $sorgente" }
|
||||
|
||||
# Migrazioni Alembic: env.py e versions\ come soli .pyc (compileall -b: .pyc accanto al sorgente, che si cancella)
|
||||
$mig = Join-Path $sorgente 'src\backend\migrations'
|
||||
New-Item -ItemType Directory -Force (Join-Path $mig 'versions') | Out-Null
|
||||
Copy-Item 'src\backend\migrations\env.py' $mig -Force
|
||||
Copy-Item 'src\backend\migrations\versions\*.py' (Join-Path $mig 'versions') -Force
|
||||
& $py -m compileall -b -q $mig
|
||||
if ($LASTEXITCODE -ne 0) { throw "compileall delle migrazioni fallito" }
|
||||
Get-ChildItem $mig -Recurse -Filter *.py -File | Remove-Item -Force
|
||||
Get-ChildItem $mig -Recurse -Directory -Filter __pycache__ | Remove-Item -Recurse -Force
|
||||
|
||||
# alembic.ini del pacchetto: sourceless, perche' anche la pagina di setup (command.stamp) apre
|
||||
# alembic.ini con un Config suo e non troverebbe env.py/versions (solo .pyc)
|
||||
$ini = Join-Path $mig 'alembic.ini'
|
||||
$testoIni = [System.IO.File]::ReadAllText($ini)
|
||||
if ($testoIni -notmatch '(?m)^script_location') { throw "alembic.ini senza script_location" }
|
||||
$testoIni = $testoIni -replace '(?m)^(script_location[^\r\n]*)', "`$1`nsourceless = true"
|
||||
[System.IO.File]::WriteAllText($ini, $testoIni, (New-Object System.Text.UTF8Encoding $false))
|
||||
|
||||
# Commit del motore di visione: avvio.py lo legge in VISION_ENGINE_VERSION
|
||||
$motore = (git -C vendor/visionsuite rev-parse HEAD).Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or -not $motore) { throw "Commit di vendor/visionsuite non leggibile" }
|
||||
[System.IO.File]::WriteAllText((Join-Path $sorgente 'vision-engine-version.txt'), $motore, (New-Object System.Text.UTF8Encoding $false))
|
||||
|
||||
# DLL di WeasyPrint (piano PDF): lo script le scarica con sha256 pinnato
|
||||
$gtk = Join-Path $PSScriptRoot 'pdf\prendi-gtk.ps1'
|
||||
if (Test-Path $gtk) {
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File $gtk -Destinazione (Join-Path $sorgente 'gtk')
|
||||
if ($LASTEXITCODE -ne 0) { throw "prendi-gtk.ps1 fallito" }
|
||||
} else {
|
||||
Write-Warning "packaging\windows\pdf\prendi-gtk.ps1 assente: il pacchetto non avra' le DLL di WeasyPrint (niente report PDF)"
|
||||
}
|
||||
|
||||
# Il pacchetto non deve contenere codice sorgente o bytecode, tranne i .pyc delle migrazioni
|
||||
$sporchi = Get-ChildItem $sorgente -Recurse -Include *.py, *.pyc -File |
|
||||
Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' }
|
||||
if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" }
|
||||
|
||||
# TieMeasureFlow#82: l'exe deve vedere gli strumenti (--controlla-strumenti non vuole licenza ne' apre server/DB)
|
||||
$exe = Join-Path $sorgente 'TieMeasureFlow.exe'
|
||||
$jsonControllo = Join-Path $env:PROGRAMDATA 'Tielogic\TieMeasureFlow\log\controllo-strumenti.json'
|
||||
Remove-Item $jsonControllo -ErrorAction SilentlyContinue # un JSON vecchio non deve far passare il controllo
|
||||
# l'exe e' GUI-subsystem: con & non si aspetta, serve Start-Process -Wait
|
||||
$proc = Start-Process -FilePath $exe -ArgumentList '--controlla-strumenti' -Wait -PassThru
|
||||
$esito = if (Test-Path $jsonControllo) { Get-Content $jsonControllo -Raw } else { '(controllo-strumenti.json non scritto)' }
|
||||
if ($proc.ExitCode -ne 0) { throw "Controllo strumenti fallito (codice $($proc.ExitCode)): $esito" }
|
||||
$nStrumenti = ($esito | ConvertFrom-Json).strumenti
|
||||
Write-Host "Controllo strumenti: $nStrumenti tipi registrati nell'exe"
|
||||
|
||||
if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return }
|
||||
|
||||
$dist = Join-Path $radice 'dist'
|
||||
New-Item -ItemType Directory -Force $dist | Out-Null
|
||||
& $iscc "/DVersione=$ver3" "/DVersione4=$ver4" "/DOrigine=$sorgente" "/DUscita=$dist" (Join-Path $radice 'packaging\windows\TieMeasureFlow.iss')
|
||||
if ($LASTEXITCODE -ne 0) { throw "Inno Setup fallito" }
|
||||
|
||||
$setup = Join-Path $dist "TieMeasureFlow-setup-$ver3.exe"
|
||||
Write-Host ("Pronto: {0} ({1:N1} MB)" -f $setup, ((Get-Item $setup).Length / 1MB))
|
||||
@@ -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,53 @@
|
||||
<#
|
||||
Cosa fa: avvia TieMeasureFlow DA SORGENTE su una macchina di sviluppo Windows, senza la build
|
||||
Nuitka (che richiede ~20 minuti): stessi quattro server e stesse porte dell'exe (8780 web,
|
||||
8000 backend, 8100 visione, 8765 camera), stessa cartella dati %PROGRAMDATA%\Tielogic\TieMeasureFlow.
|
||||
Prima dell'avvio: ambiente Python 3.13 in build\venv313 (uv sync, solo con -Sincronizza o se manca),
|
||||
tailwind.css e traduzioni .mo ricompilati, DLL di WeasyPrint in build\gtk (scaricate con
|
||||
pdf\prendi-gtk.ps1 se mancano).
|
||||
|
||||
Come si usa (dalla radice del repo):
|
||||
powershell -ExecutionPolicy Bypass -File packaging\windows\sviluppo.ps1 [-Sincronizza] [-Ascolto 0.0.0.0] [-EscludiCamere OECS]
|
||||
Dopo una modifica: Ctrl+C e rilancio (pochi secondi). Template e JS non richiedono altro;
|
||||
classi Tailwind nuove e msgid nuovi vengono ricompilati a ogni avvio.
|
||||
|
||||
Dipende da: uv, npx (Node), la licenza tiemeasureflow di QUESTA macchina in
|
||||
%PROGRAMDATA%\Tielogic\licenze\tiemeasureflow.json (impronta: avvio.py --impronta).
|
||||
#>
|
||||
param(
|
||||
[switch]$Sincronizza, # forza uv sync (dipendenze cambiate)
|
||||
[string]$Ascolto = '127.0.0.1', # indirizzo di ascolto della UI web ($Host e' riservato in PowerShell)
|
||||
[string]$EscludiCamere = '' # backend nativi da saltare (TieVision-Core#339): OECS se c'e' il runtime OECS ma in fiera si usa GenTL
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$radice = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$env:UV_PROJECT_ENVIRONMENT = Join-Path $radice 'build\venv313' # stesso ambiente della build
|
||||
$py = Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\python.exe'
|
||||
|
||||
if ($Sincronizza -or -not (Test-Path $py)) {
|
||||
uv sync --python 3.13 --frozen --extra server --extra client --extra vision-worker --group windows
|
||||
if ($LASTEXITCODE -ne 0) { throw "uv sync fallito" }
|
||||
}
|
||||
|
||||
# CSS e traduzioni: non sono nel repo, come in build.ps1
|
||||
$flask = Join-Path $radice 'src\frontend\flask_app'
|
||||
Push-Location $flask
|
||||
try {
|
||||
npx --yes tailwindcss@3.4.19 -i static/css/input.css -o static/css/tailwind.css --minify
|
||||
if ($LASTEXITCODE -ne 0) { throw "Tailwind fallito" }
|
||||
& (Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\pybabel.exe') -q compile -d translations
|
||||
if ($LASTEXITCODE -ne 0) { throw "pybabel compile fallito" }
|
||||
} finally { Pop-Location }
|
||||
|
||||
# DLL di WeasyPrint fuori dalla radice (gtk\ nella radice non e' ignorata da git): build\gtk
|
||||
$gtk = Join-Path $radice 'build\gtk'
|
||||
if (-not (Test-Path (Join-Path $gtk '*.dll'))) {
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'pdf\prendi-gtk.ps1') -Destinazione $gtk
|
||||
if ($LASTEXITCODE -ne 0) { throw "prendi-gtk.ps1 fallito" }
|
||||
}
|
||||
$env:WEASYPRINT_DLL_DIRECTORIES = $gtk
|
||||
|
||||
if ($EscludiCamere) { $env:VS_CAMERA_ESCLUDI = $EscludiCamere } # #339: stesso percorso camera del PC fiera
|
||||
Set-Location $radice
|
||||
& $py (Join-Path $PSScriptRoot 'avvio.py') --senza-browser --senza-finestre --host $Ascolto # errori in console, niente MessageBox
|
||||
exit $LASTEXITCODE
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Test di packaging/windows/avvio.py (TieMeasureFlow#76): licenza firmata con la chiave di prova
|
||||
del contratto v1, nessun server vero (avvia_server e migrazioni sono sostituiti).
|
||||
|
||||
Si lanciano con l'ambiente 3.13 del pacchetto (serve visionsuite.license):
|
||||
UV_PROJECT_ENVIRONMENT=build/venv313 uv run --python 3.13 --frozen --extra server --extra client \
|
||||
--extra vision-worker --group windows --with pytest pytest packaging/windows/tests -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import functools
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import types
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
RADICE = Path(__file__).resolve().parents[3]
|
||||
# vettore di prova del contratto licenza v1 (chiave finta, solo test)
|
||||
SEED_TEST = base64.b64decode("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
|
||||
PUBBLICA_TEST = base64.b64decode("A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=")
|
||||
IMPRONTA = "ab" * 32
|
||||
|
||||
|
||||
def _firma(dati: dict) -> str:
|
||||
# stessa canonicalizzazione del contratto (byte firmati)
|
||||
byte = json.dumps(dati, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
return base64.b64encode(Ed25519PrivateKey.from_private_bytes(SEED_TEST).sign(byte)).decode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def avvio(monkeypatch, tmp_path):
|
||||
# avvio.py non e' in un pacchetto: lo si carica dal percorso
|
||||
spec = importlib.util.spec_from_file_location("avvio_tmf", RADICE / "packaging/windows/avvio.py")
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
monkeypatch.setenv("PROGRAMDATA", str(tmp_path)) # ProgramData finta
|
||||
monkeypatch.delenv("TIELOGIC_LICENZA", raising=False)
|
||||
monkeypatch.setattr(os, "environ", os.environ.copy()) # le variabili impostate da avvio non escono dal test
|
||||
monkeypatch.setattr(m.lic, "impronta", lambda: IMPRONTA)
|
||||
# verifica reale (formato, firma, prodotto, macchina, scadenza) ma con la chiave di prova
|
||||
verifica_reale = m.lic.verifica
|
||||
monkeypatch.setattr(m.lic, "verifica", functools.partial(
|
||||
verifica_reale, chiave_pubblica=PUBBLICA_TEST, impronta_attesa=IMPRONTA))
|
||||
m.chiamate = []
|
||||
monkeypatch.setattr(m, "avvia_server", lambda host, dati: m.chiamate.append((host, dati)) or [])
|
||||
monkeypatch.setattr(m, "esegui_migrazioni", lambda: m.chiamate.append("migrazioni"))
|
||||
m.aperti = []
|
||||
monkeypatch.setattr(m.webbrowser, "open", lambda url: m.aperti.append(url))
|
||||
monkeypatch.setattr(m, "_prepara_log", lambda dati=None: None)
|
||||
monkeypatch.setattr(m, "_porta_aperta", lambda porta: False)
|
||||
return m
|
||||
|
||||
|
||||
def _scrivi_licenza(tmp_path: Path, **modifiche) -> Path:
|
||||
dati = {"versione": 1, "prodotto": "tiemeasureflow", "cliente": "Prova Srl", "impronta": IMPRONTA,
|
||||
"emessa": date.today().isoformat(), "scadenza": (date.today() + timedelta(days=30)).isoformat()}
|
||||
dati.update(modifiche)
|
||||
p = tmp_path / "Tielogic" / "licenze" / "tiemeasureflow.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps({"dati": dati, "firma": _firma(dati)}), encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def test_licenza_mancante_non_avvia_e_scrive_impronta(avvio, capsys):
|
||||
assert avvio.main(["--senza-finestre"]) == 1
|
||||
assert avvio.chiamate == [] and avvio.aperti == []
|
||||
assert avvio.percorso_impronta().read_text(encoding="utf-8").strip() == IMPRONTA
|
||||
err = capsys.readouterr().err
|
||||
assert "Licenza non trovata" in err and IMPRONTA in err and "tiemeasureflow.json" in err
|
||||
|
||||
|
||||
@pytest.mark.parametrize("modifica,testo", [
|
||||
({"scadenza": "2020-01-01"}, "scaduta"),
|
||||
({"impronta": "cd" * 32}, "altra macchina"),
|
||||
({"prodotto": "tievision-studio"}, "altro prodotto"),
|
||||
])
|
||||
def test_licenza_non_valida_non_avvia(avvio, tmp_path, capsys, modifica, testo):
|
||||
_scrivi_licenza(tmp_path, **modifica)
|
||||
assert avvio.main(["--senza-finestre"]) == 1
|
||||
assert avvio.chiamate == []
|
||||
assert testo in capsys.readouterr().err
|
||||
assert avvio.percorso_impronta().read_text(encoding="utf-8").strip() == IMPRONTA
|
||||
|
||||
|
||||
def test_licenza_manomessa_non_avvia(avvio, tmp_path, capsys):
|
||||
p = _scrivi_licenza(tmp_path)
|
||||
doc = json.loads(p.read_text(encoding="utf-8"))
|
||||
doc["dati"]["cliente"] = "Altro Cliente" # dati cambiati dopo la firma
|
||||
p.write_text(json.dumps(doc), encoding="utf-8")
|
||||
assert avvio.main(["--senza-finestre"]) == 1
|
||||
assert "Firma" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_licenza_valida_prepara_dati_e_avvia(avvio, tmp_path):
|
||||
_scrivi_licenza(tmp_path)
|
||||
assert avvio.main(["--senza-finestre", "--senza-browser", "--host", "0.0.0.0"]) == 0
|
||||
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||
# prima le migrazioni, poi i server
|
||||
assert avvio.chiamate == ["migrazioni", ("0.0.0.0", dati)]
|
||||
assert (dati / "config.env").is_file() and (dati / "uploads").is_dir() and (dati / "camera").is_dir()
|
||||
assert not avvio.percorso_impronta().exists() # con licenza valida non si scrive nulla
|
||||
assert avvio.aperti == []
|
||||
|
||||
|
||||
def test_variabili_del_contratto(avvio, tmp_path):
|
||||
_scrivi_licenza(tmp_path)
|
||||
avvio.main(["--senza-finestre", "--senza-browser"])
|
||||
e = os.environ
|
||||
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||
assert e["APP_ENV"] == "production"
|
||||
assert e["DB_URL"] == f"sqlite+aiosqlite:///{(dati / 'tmf.db').as_posix()}" and "\\" not in e["DB_URL"]
|
||||
assert e["UPLOAD_DIR"] == str(dati / "uploads") == e["VISION_RECIPES_ROOT"]
|
||||
assert e["VISION_WORKER_URL"] == "http://127.0.0.1:8100"
|
||||
assert e["API_SERVER_URL"] == "http://127.0.0.1:8000"
|
||||
assert e["SESSION_COOKIE_SECURE"] == "0"
|
||||
for k in ("SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD"):
|
||||
assert len(e[k]) >= 32
|
||||
|
||||
|
||||
def test_config_env_generato_una_volta_e_riletto(avvio, tmp_path):
|
||||
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||
primo = avvio.carica_segreti(dati)
|
||||
testo = (dati / "config.env").read_text(encoding="utf-8")
|
||||
assert set(primo) == {"SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD", "STATION_CODE"}
|
||||
assert len({primo[k] for k in avvio.SEGRETI}) == 4 # segreti distinti
|
||||
assert primo["STATION_CODE"] == "ST-DEFAULT"
|
||||
secondo = avvio.carica_segreti(dati)
|
||||
assert secondo == primo and (dati / "config.env").read_text(encoding="utf-8") == testo
|
||||
|
||||
|
||||
def test_config_env_completa_i_segreti_mancanti(avvio, tmp_path):
|
||||
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||
dati.mkdir(parents=True)
|
||||
(dati / "config.env").write_text("# nota\nSETUP_PASSWORD=scelta-da-me\n", encoding="utf-8")
|
||||
v = avvio.carica_segreti(dati)
|
||||
assert v["SETUP_PASSWORD"] == "scelta-da-me" and len(v["SERVER_SECRET_KEY"]) >= 32
|
||||
assert avvio.carica_segreti(dati) == v
|
||||
|
||||
|
||||
def test_seconda_istanza_apre_solo_il_browser(avvio, tmp_path, monkeypatch):
|
||||
_scrivi_licenza(tmp_path)
|
||||
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
|
||||
monkeypatch.setattr(avvio, "_e_tmf", lambda: True)
|
||||
assert avvio.main(["--senza-finestre"]) == 0
|
||||
assert avvio.aperti == ["http://127.0.0.1:8780/"] and avvio.chiamate == []
|
||||
|
||||
|
||||
def test_porta_occupata_da_altro_programma(avvio, tmp_path, monkeypatch, capsys):
|
||||
_scrivi_licenza(tmp_path)
|
||||
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
|
||||
monkeypatch.setattr(avvio, "_e_tmf", lambda: False)
|
||||
assert avvio.main(["--senza-finestre"]) == 2
|
||||
assert "8780" in capsys.readouterr().err and avvio.chiamate == []
|
||||
|
||||
|
||||
def test_migrazione_fallita_esce_con_3(avvio, tmp_path, monkeypatch, capsys):
|
||||
_scrivi_licenza(tmp_path)
|
||||
|
||||
def rotta():
|
||||
raise RuntimeError("db bloccato")
|
||||
|
||||
monkeypatch.setattr(avvio, "esegui_migrazioni", rotta)
|
||||
assert avvio.main(["--senza-finestre"]) == 3
|
||||
assert "db bloccato" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_impronta_si_stampa_senza_licenza(avvio, capsys):
|
||||
assert avvio.main(["--impronta", "--senza-finestre"]) == 0
|
||||
assert IMPRONTA in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_nessun_utente_rileva_il_primo_avvio(avvio, tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
assert avvio._nessun_utente(db) is False # file assente: nessuna conclusione
|
||||
con = sqlite3.connect(db)
|
||||
con.execute("CREATE TABLE users (id INTEGER)")
|
||||
con.commit()
|
||||
assert avvio._nessun_utente(db) is True
|
||||
con.execute("INSERT INTO users VALUES (1)")
|
||||
con.commit()
|
||||
con.close()
|
||||
assert avvio._nessun_utente(db) is False
|
||||
|
||||
|
||||
def test_chiavi_extra_di_config_env_passano_ma_non_sovrascrivono_il_contratto(avvio, tmp_path):
|
||||
_scrivi_licenza(tmp_path)
|
||||
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||
dati.mkdir(parents=True)
|
||||
(dati / "config.env").write_text("STATION_CODE=ST-FIERA\nSTATION_SWITCH_ENABLED=1\nAPP_ENV=dev\n", encoding="utf-8")
|
||||
avvio.main(["--senza-finestre", "--senza-browser"])
|
||||
assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1"
|
||||
assert os.environ["APP_ENV"] == "production" # le variabili fisse vincono
|
||||
|
||||
|
||||
# --- TieMeasureFlow#82: strumenti nel pacchetto (nel binario non ci sono gli entry point) ---
|
||||
|
||||
def _modulo_strumenti(monkeypatch, moduli):
|
||||
# finto _strumenti.py generato da build.ps1
|
||||
finto = types.ModuleType("_strumenti")
|
||||
finto.MODULI = tuple(moduli)
|
||||
monkeypatch.setitem(sys.modules, "_strumenti", finto)
|
||||
|
||||
|
||||
def test_registra_strumenti_da_sorgente_senza_modulo_generato(avvio, monkeypatch):
|
||||
# _strumenti.py esiste solo dopo build.ps1: se manca (sorgente) bastano gli entry point
|
||||
monkeypatch.setitem(sys.modules, "_strumenti", None) # None in sys.modules = ImportError all'import
|
||||
from visionsuite.task.graph import registry
|
||||
assert avvio.registra_strumenti() == []
|
||||
assert len(registry()) > 0
|
||||
|
||||
|
||||
def test_registra_strumenti_modulo_inesistente_non_solleva(avvio, monkeypatch):
|
||||
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image", "visionsuite.tools.non_esiste"))
|
||||
assert avvio.registra_strumenti() == ["visionsuite.tools.non_esiste"] # raccolto, non sollevato
|
||||
|
||||
|
||||
def test_registra_strumenti_importa_i_moduli_elencati(avvio, monkeypatch):
|
||||
# senza entry point (come nell'exe) i moduli di MODULI sono l'unica via: li si importa per nome
|
||||
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image",))
|
||||
visti = []
|
||||
monkeypatch.setattr(avvio.importlib, "import_module", lambda nome: visti.append(nome))
|
||||
assert avvio.registra_strumenti() == [] and visti == ["visionsuite.tools.image"]
|
||||
|
||||
|
||||
def test_controlla_strumenti_senza_licenza_scrive_json(avvio, monkeypatch, capsys, tmp_path):
|
||||
monkeypatch.setitem(sys.modules, "_strumenti", None)
|
||||
|
||||
# la licenza non deve essere nemmeno chiesta, e server e migrazioni non devono partire
|
||||
def mai(*a, **k):
|
||||
raise AssertionError("licenza, server o migrazioni chiamati da --controlla-strumenti")
|
||||
|
||||
monkeypatch.setattr(avvio.lic, "verifica", mai)
|
||||
monkeypatch.setattr(avvio.lic, "cerca_licenza", mai)
|
||||
monkeypatch.setattr(avvio, "avvia_server", mai)
|
||||
monkeypatch.setattr(avvio, "esegui_migrazioni", mai)
|
||||
assert avvio.main(["--controlla-strumenti", "--senza-finestre"]) == 0
|
||||
dati = json.loads(capsys.readouterr().out)
|
||||
assert dati["strumenti"] > 0 and dati["strumenti"] == len(dati["kind"])
|
||||
assert dati["falliti"] == []
|
||||
scritto = tmp_path / "Tielogic" / "TieMeasureFlow" / "log" / "controllo-strumenti.json"
|
||||
assert json.loads(scritto.read_text(encoding="utf-8")) == dati
|
||||
assert avvio.aperti == [] and not (tmp_path / "Tielogic" / "TieMeasureFlow" / "tmf.db").exists() # nessun DB
|
||||
|
||||
|
||||
def test_controlla_strumenti_exit_3_se_un_modulo_fallisce(avvio, monkeypatch, capsys):
|
||||
_modulo_strumenti(monkeypatch, ("visionsuite.tools.non_esiste",))
|
||||
assert avvio.main(["--controlla-strumenti"]) == 3
|
||||
assert json.loads(capsys.readouterr().out)["falliti"] == ["visionsuite.tools.non_esiste"]
|
||||
|
||||
|
||||
def test_controlla_strumenti_exit_3_se_il_catalogo_e_vuoto(avvio, monkeypatch, capsys):
|
||||
# niente strumenti registrati (es. entry point assenti e _strumenti vuoto): il controllo deve fallire
|
||||
import visionsuite.task.graph as graph
|
||||
monkeypatch.setattr(avvio, "registra_strumenti", lambda: [])
|
||||
monkeypatch.setattr(graph, "registry", lambda: {})
|
||||
assert avvio.main(["--controlla-strumenti"]) == 3
|
||||
assert json.loads(capsys.readouterr().out)["strumenti"] == 0
|
||||
|
||||
|
||||
def test_avvio_normale_registra_gli_strumenti_prima_del_server(avvio, tmp_path, monkeypatch):
|
||||
# main() chiama registra_strumenti() dopo licenza e migrazioni e prima di avvia_server
|
||||
_scrivi_licenza(tmp_path)
|
||||
monkeypatch.setattr(avvio, "registra_strumenti", lambda: avvio.chiamate.append("strumenti") or [])
|
||||
assert avvio.main(["--senza-finestre", "--senza-browser"]) == 0
|
||||
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||
assert avvio.chiamate == ["migrazioni", "strumenti", ("127.0.0.1", dati)]
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
[project]
|
||||
name = "tiemeasureflow"
|
||||
version = "3.0.0"
|
||||
description = "TieMeasureFlow by Tielogic — manual caliper measurement task management for industrial QA stations."
|
||||
requires-python = ">=3.11"
|
||||
authors = [
|
||||
{ name = "Adriano Dal Pastro", email = "adrianodalpastro@tielogic.com" },
|
||||
]
|
||||
|
||||
# Shared core deps used by both backend and the Flask frontend.
|
||||
dependencies = [
|
||||
"pydantic>=2.0.0",
|
||||
"pydantic-settings>=2.0.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
# Backend (FastAPI + DB + reports).
|
||||
server = [
|
||||
"fastapi>=0.110.0",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
"sqlalchemy[asyncio]>=2.0.0",
|
||||
"asyncmy>=0.2.0",
|
||||
"alembic>=1.13.0",
|
||||
"bcrypt>=4.0.0",
|
||||
"pillow>=10.0.0",
|
||||
"python-multipart>=0.0.6",
|
||||
"jinja2>=3.1.0",
|
||||
"plotly>=5.0.0",
|
||||
"kaleido>=0.2.0",
|
||||
"weasyprint>=62.0",
|
||||
"pdfplumber>=0.10.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
# Frontend (Flask tablet UI).
|
||||
client = [
|
||||
"flask>=3.0.0",
|
||||
"flask-babel>=4.0.0",
|
||||
"flask-wtf>=1.2.0",
|
||||
"requests>=2.31.0",
|
||||
"urllib3>=2.0.0",
|
||||
"gunicorn>=21.0.0",
|
||||
]
|
||||
|
||||
# Dev / test (covers both server and client tests).
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"httpx>=0.27.0",
|
||||
"aiosqlite>=0.20.0",
|
||||
"coverage>=7.0.0",
|
||||
]
|
||||
|
||||
# Vision runner (VisionSuite submodule): what src/vision/runner.py needs to
|
||||
# execute a graph. vs-task/vs-measure/vs-pm2d require Python >=3.13 upstream;
|
||||
# marker keeps the base 3.11 resolution untouched. numpy is declared
|
||||
# explicitly - runner.py imports it directly, and it must not depend on
|
||||
# being pulled in as a side effect of vs-pm2d's own dependencies.
|
||||
vision = [
|
||||
"visionsuite; python_version>='3.13'",
|
||||
"vs-task; python_version>='3.13'",
|
||||
"vs-measure; python_version>='3.13'",
|
||||
"vs-pm2d; python_version>='3.13'",
|
||||
# #46: tutti gli strumenti della suite (locate/MatchPattern e dxf/CompareDxf)
|
||||
"vs-tools[locate,dxf]; python_version>='3.13'",
|
||||
"numpy; python_version>='3.13'",
|
||||
]
|
||||
|
||||
# Vision worker (src/vision_worker/): the FastAPI shell around the runner,
|
||||
# on top of `vision`. Kept separate on purpose (Ruling R9) - VisionSuite's
|
||||
# whole structure exists so a consumer installs only what it needs, and a
|
||||
# later station agent embeds the same runner without being a web service.
|
||||
# It must not drag fastapi/uvicorn onto shop-floor PCs.
|
||||
vision-worker = [
|
||||
"tiemeasureflow[vision]",
|
||||
"fastapi>=0.110.0; python_version>='3.13'",
|
||||
"uvicorn[standard]>=0.30.0; python_version>='3.13'",
|
||||
"pillow>=10.0.0; python_version>='3.13'",
|
||||
"python-multipart>=0.0.6; python_version>='3.13'",
|
||||
]
|
||||
|
||||
# Pacchetto Windows (packaging/windows, TieMeasureFlow#76): solo per build.ps1, non per
|
||||
# i container. Si usa con --python 3.13 insieme agli extra server, client e vision-worker.
|
||||
[dependency-groups]
|
||||
windows = [
|
||||
"tzdata; sys_platform == 'win32'", # ZoneInfo("Europe/Rome") (backend e frontend): Windows non ha il database IANA
|
||||
"aiosqlite>=0.20.0", # database SQLite su file (DB_URL, piano A)
|
||||
"waitress>=3.0.0", # server WSGI del frontend Flask (gunicorn non gira su Windows)
|
||||
"vs-camera[genicam]; python_version>='3.13'", # scripts/camera_service.py: camere GigE/GenTL (producer .cti del vendor)
|
||||
"vs-license; python_version>='3.13'", # licenza Tielogic v1 (impronta macchina, firma Ed25519)
|
||||
"nuitka>=2.5", # compilazione standalone
|
||||
"ordered-set>=4.1", # richiesto da Nuitka per la modalita' standalone
|
||||
"zstandard>=0.22", # compressione dei dati incorporati di Nuitka
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
# Backend
|
||||
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
# Source layout will be src/backend/ and src/frontend/flask_app/ after the
|
||||
# folder restructure (Phase 2 of the V2.0.0 plan).
|
||||
packages = ["src/backend", "src/frontend"]
|
||||
|
||||
[tool.uv]
|
||||
# Pin the resolver to the deps we declared; reproducible builds.
|
||||
package = false
|
||||
|
||||
[tool.uv.sources]
|
||||
visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true }
|
||||
vs-task = { path = "vendor/visionsuite/packages/vs-task", editable = true }
|
||||
vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true }
|
||||
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
|
||||
vs-tools = { path = "vendor/visionsuite/packages/vs-tools", editable = true } # #46
|
||||
vs-dxf = { path = "vendor/visionsuite/packages/vs-dxf", editable = true } # #46
|
||||
vs-camera = { path = "vendor/visionsuite/packages/vs-camera", editable = true } # #76 camera di stazione
|
||||
vs-license = { path = "vendor/visionsuite/packages/vs-license", editable = true } # #76 licenza del pacchetto Windows
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests", "src/vision/tests", "src/vision_worker/tests"]
|
||||
@@ -0,0 +1,781 @@
|
||||
"""Calibrazione a scacchiera dell'acquisitore (TieMeasureFlow, #83).
|
||||
|
||||
Cosa fa: da una foto di scacchiera piana calcola la scala (px/mm) con
|
||||
TieVision-Core (visionsuite.calibration), e conserva un risultato per sorgente
|
||||
camera in un JSON (scrittura atomica).
|
||||
#91: le calibrazioni stanno in un archivio UNICO, salvate con nome (classe Archivio);
|
||||
una sorgente ne ha una attiva (attive.json) e il formato vecchio (una per sorgente)
|
||||
si migra da solo.
|
||||
#84: `calibra_scacchiera_densa(pixels, square_mm)` calibra da UNA foto di
|
||||
scacchiera che riempie tutto il campo (ottica telecentrica): trova da sola la
|
||||
griglia, costruisce la mappa di distorsione (DistortionMap) e il mm/px
|
||||
dell'immagine raddrizzata. L'Archivio salva la mappa accanto al JSON (.npz) e
|
||||
`raddrizza()` la applica ai frame.
|
||||
Uso: importato da scripts/camera_service.py (rotte /camera/calibrate e
|
||||
/camera/calibration); `calibra_scacchiera(gray, cols, rows, square_mm)` e
|
||||
`calibra_scacchiera_densa(gray, square_mm)` sono logica pura, senza FastAPI,
|
||||
e si provano da sole.
|
||||
Dipende da: numpy, opencv, visionsuite (vs-core). Niente altro (no scipy).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import unicodedata # #91 caratteri di controllo nei nomi
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from visionsuite.calibration import calibrate_planar, find_chessboard
|
||||
from visionsuite.calibration.distortion_map import DistortionMap # #84 mappa densa
|
||||
from visionsuite.core.image import Image
|
||||
|
||||
|
||||
class CalibrazioneError(ValueError):
|
||||
"""Parametri invalidi o scacchiera non trovata: input sbagliato, non un guasto (422)."""
|
||||
|
||||
|
||||
def _gray_u8(pixels: np.ndarray) -> np.ndarray:
|
||||
"""Immagine -> grigio uint8 2D."""
|
||||
px = np.asarray(pixels)
|
||||
if px.ndim == 3:
|
||||
px = cv2.cvtColor(px, cv2.COLOR_BGRA2GRAY if px.shape[2] == 4 else cv2.COLOR_BGR2GRAY)
|
||||
if px.dtype != np.uint8:
|
||||
# float/uint16 -> scala sul massimo, come fa vs-core
|
||||
m = float(px.max()) if px.size else 1.0
|
||||
px = np.clip(px.astype(np.float32) / max(m, 1e-9) * 255, 0, 255).astype(np.uint8)
|
||||
return np.ascontiguousarray(px)
|
||||
|
||||
|
||||
def calibra_scacchiera(pixels, cols: int, rows: int, square_mm: float) -> dict:
|
||||
"""Scala (px/mm) da una foto di scacchiera piana. cols/rows = angoli INTERNI.
|
||||
|
||||
Ritorna {px_per_mm, mm_per_px, rms_px, n_corners, cols, rows, square_mm,
|
||||
image_width, image_height, scale_spread_pct, metodo, distorsione,
|
||||
overlay_png (b64)}.
|
||||
"""
|
||||
if cols < 2 or rows < 2:
|
||||
raise CalibrazioneError("cols e rows (angoli interni) devono essere almeno 2")
|
||||
if not (square_mm > 0 and math.isfinite(square_mm)):
|
||||
raise CalibrazioneError("square_mm deve essere > 0")
|
||||
|
||||
frame = _gray_u8(pixels)
|
||||
image = Image(frame)
|
||||
try:
|
||||
image_pts, world_pts = find_chessboard(image, (cols, rows), square_mm)
|
||||
except RuntimeError as exc:
|
||||
# find_chessboard solleva RuntimeError quando la scacchiera non c'e'
|
||||
raise CalibrazioneError(
|
||||
f"scacchiera {cols}x{rows} non trovata nell'immagine: {exc}") from exc
|
||||
model = calibrate_planar(image, image_pts, world_pts) # omografia pura
|
||||
|
||||
# Scala al centro della scacchiera (puo' non stare al centro immagine)
|
||||
x0, y0 = image_pts.min(axis=0)
|
||||
x1, y1 = image_pts.max(axis=0)
|
||||
mm_per_px = model.pixel_size((x0 + x1) / 2.0, (y0 + y1) / 2.0)
|
||||
if not (mm_per_px > 0 and math.isfinite(mm_per_px)):
|
||||
raise CalibrazioneError("calibrazione degenere: scala non valida")
|
||||
# Scarto massimo % della scala ai quattro angoli dell'area della scacchiera
|
||||
# rispetto al centro: ottica telecentrica/piana ~ 0, prospettiva = grande.
|
||||
spread = max(abs(model.pixel_size(x, y) - mm_per_px) / mm_per_px * 100.0
|
||||
for x in (x0, x1) for y in (y0, y1))
|
||||
|
||||
# Overlay: angoli disegnati sull'immagine (PNG base64)
|
||||
bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
|
||||
cv2.drawChessboardCorners(
|
||||
bgr, (cols, rows), image_pts.reshape(-1, 1, 2).astype(np.float32), True)
|
||||
ok, png = cv2.imencode(".png", bgr)
|
||||
if not ok:
|
||||
raise RuntimeError("PNG dell'overlay non codificabile")
|
||||
|
||||
h, w = frame.shape
|
||||
return {
|
||||
"px_per_mm": 1.0 / mm_per_px,
|
||||
"mm_per_px": mm_per_px,
|
||||
"rms_px": float(model.rms_reprojection),
|
||||
"n_corners": int(len(image_pts)),
|
||||
"cols": int(cols),
|
||||
"rows": int(rows),
|
||||
"square_mm": float(square_mm),
|
||||
"image_width": int(w),
|
||||
"image_height": int(h),
|
||||
"scale_spread_pct": float(spread),
|
||||
"metodo": "planare", # #84: il metodo con cols/rows resta quello di prima
|
||||
"distorsione": False,
|
||||
"overlay_png": base64.b64encode(png.tobytes()).decode("ascii"),
|
||||
}
|
||||
|
||||
|
||||
# ---- #84: calibrazione densa (scacchiera che riempie il campo, griglia non nota)
|
||||
|
||||
# #84: findChessboardCornersSBWithMeta (verificato su opencv 5.0.0) su una scacchiera
|
||||
# grande quanto l'immagine trova solo frammenti (2064x1544 con quadrati da 40 px:
|
||||
# 5x5, 28x6...). Per questo la griglia si cerca qui: sellini della Hessiana a piu'
|
||||
# scale + crescita del reticolo; poi cornerSubPix.
|
||||
_SIGME = (1.5, 2.2, 3.2, 4.5, 6.5, 9.0, 13.0) # scale dei sellini (passo ~ 5..120 px)
|
||||
_MAX_CANDIDATI = 6000
|
||||
|
||||
|
||||
def _candidati(gray: np.ndarray, sigma: float) -> np.ndarray:
|
||||
"""Massimi locali del 'selliness' (-det Hessiana) = angoli di scacchiera, (n,2) x,y."""
|
||||
b = cv2.GaussianBlur(gray.astype(np.float32), (0, 0), sigma)
|
||||
ixx = cv2.Sobel(b, cv2.CV_32F, 2, 0, ksize=3)
|
||||
iyy = cv2.Sobel(b, cv2.CV_32F, 0, 2, ksize=3)
|
||||
ixy = cv2.Sobel(b, cv2.CV_32F, 1, 1, ksize=3)
|
||||
r = ixy * ixy - ixx * iyy # >0 solo dove l'immagine ha un sellino
|
||||
massimo = float(r.max())
|
||||
if massimo <= 0:
|
||||
return np.zeros((0, 2))
|
||||
k = 2 * int(math.ceil(1.5 * sigma)) + 1
|
||||
picchi = (r >= cv2.dilate(r, np.ones((k, k), np.uint8))) & (r > 0.1 * massimo)
|
||||
ys, xs = np.nonzero(picchi)
|
||||
if len(xs) > _MAX_CANDIDATI: # tiene i piu' forti
|
||||
forti = np.argsort(r[ys, xs])[::-1][:_MAX_CANDIDATI]
|
||||
ys, xs = ys[forti], xs[forti]
|
||||
return np.stack([xs, ys], axis=1).astype(np.float64)
|
||||
|
||||
|
||||
def _e_angolo_x(lisc: np.ndarray, delta: float, p: np.ndarray, u: np.ndarray, v: np.ndarray) -> bool:
|
||||
"""True se attorno a p i quattro quadranti alternano chiaro/scuro (vero angolo X).
|
||||
|
||||
Scarta le giunzioni a L/T al bordo della scacchiera (3 quadranti uguali), che
|
||||
il selliness da solo accetta e che spostano la griglia.
|
||||
"""
|
||||
h, w = lisc.shape
|
||||
campioni = []
|
||||
for q in (0.25 * (u + v), -0.25 * (u + v), 0.25 * (u - v), -0.25 * (u - v)):
|
||||
x, y = int(round(p[0] + q[0])), int(round(p[1] + q[1]))
|
||||
if not (0 <= x < w and 0 <= y < h):
|
||||
return False
|
||||
campioni.append(float(lisc[y, x]))
|
||||
s1, s2, t1, t2 = campioni
|
||||
return (min(s1, s2) > max(t1, t2) + delta) or (max(s1, s2) < min(t1, t2) - delta)
|
||||
|
||||
|
||||
def _cresci_reticolo(pts: np.ndarray, centro: tuple[float, float],
|
||||
lisc: np.ndarray, delta: float) -> dict | None:
|
||||
"""Assegna un indice (riga, colonna) ai candidati che stanno su un reticolo.
|
||||
|
||||
Parte dal candidato piu' vicino al centro, ricava i due vettori di passo dai
|
||||
vicini e cresce a macchia d'olio: la base si aggiorna a ogni passo, quindi
|
||||
segue una distorsione lenta. Ritorna {(i, j): indice candidato} o None.
|
||||
"""
|
||||
if len(pts) < 9:
|
||||
return None
|
||||
seme = int(np.argmin(np.hypot(pts[:, 0] - centro[0], pts[:, 1] - centro[1])))
|
||||
d = np.hypot(pts[:, 0] - pts[seme, 0], pts[:, 1] - pts[seme, 1])
|
||||
vicini = np.argsort(d)[1:9]
|
||||
u = pts[vicini[0]] - pts[seme]
|
||||
lu = float(np.hypot(*u))
|
||||
v = None
|
||||
for k in vicini[1:]:
|
||||
w = pts[k] - pts[seme]
|
||||
lw = float(np.hypot(*w))
|
||||
# il secondo vettore: quasi ortogonale al primo e di lunghezza simile
|
||||
if lu > 0 and lw > 0 and abs(float(u @ w)) / (lu * lw) < 0.4 and lw < 1.5 * lu:
|
||||
v = w
|
||||
break
|
||||
if v is None or lu < 3:
|
||||
return None
|
||||
posto = {(0, 0): seme}
|
||||
indice = {seme: (0, 0)}
|
||||
basi = {seme: (u, v)}
|
||||
coda = deque([seme])
|
||||
while coda:
|
||||
a = coda.popleft()
|
||||
i, j = indice[a]
|
||||
u, v = basi[a]
|
||||
tol = 0.3 * min(float(np.hypot(*u)), float(np.hypot(*v)))
|
||||
for di, dj, vec in ((0, 1, u), (0, -1, -u), (1, 0, v), (-1, 0, -v)):
|
||||
if (i + di, j + dj) in posto:
|
||||
continue
|
||||
prev = pts[a] + vec
|
||||
dist = np.hypot(pts[:, 0] - prev[0], pts[:, 1] - prev[1])
|
||||
b = int(np.argmin(dist))
|
||||
if dist[b] > tol or b in indice:
|
||||
continue
|
||||
if not _e_angolo_x(lisc, delta, pts[b], u, v): # niente L/T del bordo
|
||||
continue
|
||||
posto[(i + di, j + dj)] = b
|
||||
indice[b] = (i + di, j + dj)
|
||||
reale = pts[b] - pts[a]
|
||||
basi[b] = (reale * dj, v) if dj else (u, reale * di) # base locale aggiornata
|
||||
coda.append(b)
|
||||
return posto
|
||||
|
||||
|
||||
def _rettangolo_pieno(posto: dict) -> tuple[int, int, int, int] | None:
|
||||
"""Il rettangolo di indici tutto occupato di area massima: (i0, j0, righe, colonne)."""
|
||||
if not posto:
|
||||
return None
|
||||
i_min = min(i for i, _ in posto)
|
||||
j_min = min(j for _, j in posto)
|
||||
nr = max(i for i, _ in posto) - i_min + 1
|
||||
nc = max(j for _, j in posto) - j_min + 1
|
||||
m = np.zeros((nr, nc), bool)
|
||||
for (i, j) in posto:
|
||||
m[i - i_min, j - j_min] = True
|
||||
migliore = (0, 0, 0, 0)
|
||||
alt = np.zeros(nc, int)
|
||||
for r in range(nr): # istogramma delle altezze + pila, O(righe*colonne)
|
||||
alt = np.where(m[r], alt + 1, 0)
|
||||
pila: list[int] = []
|
||||
for c in range(nc + 1):
|
||||
h = int(alt[c]) if c < nc else 0
|
||||
while pila and alt[pila[-1]] >= h:
|
||||
altezza = int(alt[pila.pop()])
|
||||
sx = pila[-1] + 1 if pila else 0
|
||||
larg = c - sx
|
||||
if altezza * larg > migliore[2] * migliore[3]:
|
||||
migliore = (r - altezza + 1 + i_min, sx + j_min, altezza, larg)
|
||||
pila.append(c)
|
||||
return migliore if migliore[2] * migliore[3] else None
|
||||
|
||||
|
||||
def _orienta(g: np.ndarray) -> np.ndarray:
|
||||
"""Riordina (nr,nc,2) con colonne verso destra e righe verso il basso."""
|
||||
a = (g[:, -1] - g[:, 0]).mean(axis=0) # direzione dell'indice di colonna
|
||||
b = (g[-1, :] - g[0, :]).mean(axis=0) # direzione dell'indice di riga
|
||||
if abs(b[0]) > abs(a[0]): # assi scambiati (scacchiera ruotata di ~90 gradi)
|
||||
g = g.transpose(1, 0, 2)
|
||||
a, b = b, a
|
||||
if a[0] < 0:
|
||||
g = g[:, ::-1]
|
||||
if b[1] < 0:
|
||||
g = g[::-1]
|
||||
return np.ascontiguousarray(g)
|
||||
|
||||
|
||||
def _passo_mediano(g: np.ndarray) -> float:
|
||||
"""Distanza mediana (px) fra corner adiacenti."""
|
||||
passi = np.concatenate([np.diff(g, axis=1).reshape(-1, 2), np.diff(g, axis=0).reshape(-1, 2)])
|
||||
return float(np.median(np.hypot(passi[:, 0], passi[:, 1])))
|
||||
|
||||
|
||||
def _raffina(img: np.ndarray, pts: np.ndarray, passo: float) -> np.ndarray:
|
||||
"""cornerSubPix su (n,2) con finestra ~0.3 passo, ristretta per chi sta vicino al bordo.
|
||||
|
||||
Una finestra che esce dall'immagine vede il bordo replicato e sposta il corner
|
||||
di ~1 px (visto sull'ultima colonna di una scacchiera a 14 px dal bordo): per
|
||||
questi punti si usa la finestra piu' grande che sta dentro; sotto 3 px resta
|
||||
la posizione grezza.
|
||||
"""
|
||||
h, w = img.shape
|
||||
out = pts.astype(np.float32).copy()
|
||||
win = int(min(20, max(3, 0.3 * passo)))
|
||||
dentro = np.minimum.reduce([out[:, 0], w - 1 - out[:, 0], out[:, 1], h - 1 - out[:, 1]])
|
||||
per_punto = np.minimum(win, np.floor(dentro).astype(int) - 2)
|
||||
crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_COUNT, 40, 0.001)
|
||||
for wi in np.unique(per_punto[per_punto >= 3]):
|
||||
sel = per_punto == wi
|
||||
out[sel] = cv2.cornerSubPix(img, out[sel].reshape(-1, 1, 2).copy(),
|
||||
(int(wi), int(wi)), (-1, -1), crit).reshape(-1, 2)
|
||||
return out
|
||||
|
||||
|
||||
def _trova_griglia(frame: np.ndarray) -> np.ndarray:
|
||||
"""Griglia di angoli (n_righe, n_colonne, 2) x,y, in ordine di riga dall'alto."""
|
||||
h, w = frame.shape
|
||||
migliore = None
|
||||
lisc = cv2.GaussianBlur(frame.astype(np.float32), (0, 0), 1.0)
|
||||
p5, p95 = np.percentile(frame, (5, 95))
|
||||
delta = 0.25 * float(p95 - p5) # contrasto minimo fra quadranti opposti
|
||||
for sigma in _SIGME:
|
||||
pts = _candidati(frame, sigma)
|
||||
posto = _cresci_reticolo(pts, (w / 2.0, h / 2.0), lisc, delta)
|
||||
rett = _rettangolo_pieno(posto) if posto else None
|
||||
if rett is None:
|
||||
continue
|
||||
i0, j0, nr, nc = rett
|
||||
if nr < 3 or nc < 3:
|
||||
continue
|
||||
if migliore is not None and nr * nc < 0.5 * migliore[0] * migliore[1]:
|
||||
break # oltre il picco: le scale piu' grosse fondono gli angoli
|
||||
if migliore is None or nr * nc > migliore[0] * migliore[1]:
|
||||
g = np.array([[pts[posto[(i0 + i, j0 + j)]] for j in range(nc)]
|
||||
for i in range(nr)])
|
||||
migliore = (nr, nc, g)
|
||||
if migliore is None:
|
||||
raise CalibrazioneError(
|
||||
"griglia della scacchiera non trovata: serve una scacchiera nitida "
|
||||
"che riempia il campo")
|
||||
g = _orienta(migliore[2])
|
||||
nr, nc, _ = g.shape
|
||||
passo = _passo_mediano(g)
|
||||
# sub-pixel: finestra proporzionale al passo, abbastanza piccola da non vedere i vicini
|
||||
grezzi = g.reshape(-1, 2).astype(np.float32)
|
||||
raff = _raffina(frame, grezzi, passo)
|
||||
spostati = np.hypot(*(raff - grezzi).T) > 0.3 * passo
|
||||
raff[spostati] = grezzi[spostati] # un raffinamento che scappa e' peggio del grezzo
|
||||
return raff.reshape(nr, nc, 2).astype(np.float64)
|
||||
|
||||
|
||||
def _estrapola(g: np.ndarray, k: int, prima: bool) -> np.ndarray:
|
||||
"""Aggiunge k colonne (asse 1) per estrapolazione lineare, prima o dopo."""
|
||||
if k <= 0:
|
||||
return g
|
||||
if prima:
|
||||
passo = g[:, 0] - g[:, 1]
|
||||
extra = np.stack([g[:, 0] + m * passo for m in range(k, 0, -1)], axis=1)
|
||||
return np.concatenate([extra, g], axis=1)
|
||||
passo = g[:, -1] - g[:, -2]
|
||||
extra = np.stack([g[:, -1] + m * passo for m in range(1, k + 1)], axis=1)
|
||||
return np.concatenate([g, extra], axis=1)
|
||||
|
||||
|
||||
def _estendi_asse(g: np.ndarray, lungh: int, c: int, passo: float,
|
||||
grid_step: int) -> tuple[np.ndarray, int]:
|
||||
"""Estende la griglia (asse 1) fino a coprire l'immagine; ritorna (griglia, k_prima).
|
||||
|
||||
`c` e' la coordinata (0 = x, 1 = y) e `lungh` la dimensione dell'immagine su
|
||||
quell'asse. `from_grid` (crop_domain=False) tiene costante la mappa fuori
|
||||
dalla griglia: senza anelli in piu' i bordi dell'immagine raddrizzata
|
||||
sarebbero sbavati.
|
||||
"""
|
||||
margine = max(0.0, float(g[:, 0, c].min()))
|
||||
k_pre = int(math.ceil(margine / passo)) + 1
|
||||
g = _estrapola(g, k_pre, True)
|
||||
offset = int(g[0, 0, c]) # lo stesso int() che usa from_grid
|
||||
# colonne totali perche' l'ultimo nodo, a passo grid_step, superi il bordo
|
||||
servono = int(math.ceil((lungh - 1 - offset) / grid_step)) + 2
|
||||
g = _estrapola(g, max(0, servono - g.shape[1]), False)
|
||||
return g, k_pre
|
||||
|
||||
|
||||
class MappaPronta(NamedTuple):
|
||||
"""Mappa gia' convertita in virgola fissa per un cv2.remap veloce (#84)."""
|
||||
map1: np.ndarray
|
||||
map2: np.ndarray
|
||||
pixel_size: float
|
||||
shape: tuple # (altezza, larghezza) dei frame per cui vale
|
||||
|
||||
|
||||
def _prepara_mappa(mappa: DistortionMap) -> MappaPronta:
|
||||
# CV_16SC2: remap bilineare in virgola fissa, piu' veloce di float32 su 5 MP
|
||||
m1, m2 = cv2.convertMaps(mappa.map_col, mappa.map_row, cv2.CV_16SC2)
|
||||
return MappaPronta(m1, m2, float(mappa.pixel_size), mappa.shape)
|
||||
|
||||
|
||||
def raddrizza(frame: np.ndarray, mappa: MappaPronta) -> np.ndarray | None:
|
||||
"""Frame raddrizzato, o None se le dimensioni non sono quelle della calibrazione."""
|
||||
if frame.shape[:2] != tuple(mappa.shape):
|
||||
return None
|
||||
return cv2.remap(frame, mappa.map1, mappa.map2, cv2.INTER_LINEAR,
|
||||
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
|
||||
|
||||
|
||||
def calibra_scacchiera_densa(pixels, square_mm: float) -> tuple[dict, DistortionMap]:
|
||||
"""Calibra da UNA foto di scacchiera densa (riempie il campo). Input: solo square_mm.
|
||||
|
||||
Ritorna (risultato, mappa). Risultato: {px_per_mm, mm_per_px (dell'immagine
|
||||
RADDRIZZATA), rms_px, residuo_rms_px, residuo_max_px, n_corners, cols, rows,
|
||||
square_mm, image_width, image_height, scale_spread_pct (0: la mappa uniforma
|
||||
la scala), copertura_pct, metodo, distorsione, overlay_png (b64)}.
|
||||
"""
|
||||
if not (square_mm > 0 and math.isfinite(square_mm)):
|
||||
raise CalibrazioneError("square_mm deve essere > 0")
|
||||
frame = _gray_u8(pixels)
|
||||
h, w = frame.shape
|
||||
g = _trova_griglia(frame)
|
||||
nr, nc, _ = g.shape
|
||||
passo = _passo_mediano(g)
|
||||
grid_step = int(round(passo))
|
||||
if grid_step < 2:
|
||||
raise CalibrazioneError("calibrazione degenere: passo della griglia troppo piccolo")
|
||||
|
||||
# anelli in piu' ai bordi (x e poi y), poi la mappa dai punti estesi
|
||||
e, kl = _estendi_asse(g, w, 0, passo, grid_step)
|
||||
et, kt = _estendi_asse(e.transpose(1, 0, 2), h, 1, passo, grid_step)
|
||||
e = et.transpose(1, 0, 2)
|
||||
try:
|
||||
mappa = DistortionMap.from_grid(
|
||||
e[..., 0].ravel(), e[..., 1].ravel(), e.shape[1], e.shape[0],
|
||||
image_size=(w, h), grid_step=grid_step, crop_domain=False,
|
||||
grid_step_measure=square_mm)
|
||||
except ValueError as exc:
|
||||
raise CalibrazioneError(f"mappa di distorsione non costruibile: {exc}") from exc
|
||||
mm_per_px = float(mappa.pixel_size)
|
||||
if not (mm_per_px > 0 and math.isfinite(mm_per_px)):
|
||||
raise CalibrazioneError("calibrazione degenere: scala non valida")
|
||||
|
||||
# qualita': si raddrizza la stessa foto e si rilegge ogni corner nel punto ideale
|
||||
off_c, off_r = int(e[0, 0, 0]), int(e[0, 0, 1])
|
||||
ideali = np.array([[(off_c + (kl + j) * grid_step, off_r + (kt + i) * grid_step)
|
||||
for j in range(nc)] for i in range(nr)], np.float32).reshape(-1, 1, 2)
|
||||
rett = cv2.remap(frame, mappa.map_col, mappa.map_row, cv2.INTER_LINEAR,
|
||||
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
|
||||
# solo i corner ideali dentro l'immagine raddrizzata (cornerSubPix rifiuta quelli fuori)
|
||||
xy = ideali.reshape(-1, 2)
|
||||
dentro = (xy[:, 0] >= 1) & (xy[:, 0] <= w - 2) & (xy[:, 1] >= 1) & (xy[:, 1] <= h - 2)
|
||||
if int(dentro.sum()) < 4:
|
||||
raise CalibrazioneError("griglia raddrizzata fuori dall'immagine: calibrazione non verificabile")
|
||||
ideali = ideali[dentro].reshape(-1, 2)
|
||||
letti = _raffina(rett, ideali, grid_step)
|
||||
scarto = (letti - ideali).astype(np.float64)
|
||||
scarto -= np.median(scarto, axis=0) # int() dell'offset sposta tutto di una traslazione
|
||||
dist = np.hypot(scarto[:, 0], scarto[:, 1])
|
||||
residuo_max = float(dist.max())
|
||||
residuo_rms = float(math.sqrt(float((dist ** 2).mean())))
|
||||
|
||||
# copertura: area del quadrilatero dei quattro angoli esterni / area immagine
|
||||
quad = np.array([g[0, 0], g[0, -1], g[-1, -1], g[-1, 0]], np.float32)
|
||||
copertura = float(cv2.contourArea(quad)) / float(w * h) * 100.0
|
||||
|
||||
# overlay: corner trovati sull'originale (ridotto a max 1600 px: 5 MP in PNG pesano)
|
||||
f = min(1.0, 1600.0 / w)
|
||||
bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
|
||||
pts = g.reshape(-1, 1, 2).astype(np.float32)
|
||||
if f < 1.0:
|
||||
bgr = cv2.resize(bgr, None, fx=f, fy=f, interpolation=cv2.INTER_AREA)
|
||||
pts = pts * np.float32(f)
|
||||
cv2.drawChessboardCorners(bgr, (nc, nr), pts, True)
|
||||
ok, png = cv2.imencode(".png", bgr)
|
||||
if not ok:
|
||||
raise RuntimeError("PNG dell'overlay non codificabile")
|
||||
|
||||
return {
|
||||
"px_per_mm": 1.0 / mm_per_px,
|
||||
"mm_per_px": mm_per_px,
|
||||
"rms_px": residuo_rms,
|
||||
"residuo_rms_px": residuo_rms,
|
||||
"residuo_max_px": residuo_max,
|
||||
"n_corners": int(nr * nc),
|
||||
"cols": int(nc),
|
||||
"rows": int(nr),
|
||||
"square_mm": float(square_mm),
|
||||
"image_width": int(w),
|
||||
"image_height": int(h),
|
||||
"scale_spread_pct": 0.0,
|
||||
"copertura_pct": copertura,
|
||||
"metodo": "densa",
|
||||
"distorsione": True,
|
||||
"overlay_png": base64.b64encode(png.tobytes()).decode("ascii"),
|
||||
}, mappa
|
||||
|
||||
|
||||
def ora_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
class NomeNonValido(ValueError):
|
||||
"""#91: nome di calibrazione vuoto, troppo lungo o con caratteri di controllo (400)."""
|
||||
|
||||
|
||||
class NomeGiaUsato(ValueError):
|
||||
"""#91: esiste gia' una calibrazione con questo nome (senza distinguere maiuscole) (409)."""
|
||||
|
||||
|
||||
class NomeSconosciuto(KeyError):
|
||||
"""#91: nessuna calibrazione con questo nome (404)."""
|
||||
|
||||
|
||||
def pulisci_nome(nome) -> str:
|
||||
"""#91: nome valido = strip, 1-60 caratteri, niente caratteri di controllo."""
|
||||
if not isinstance(nome, str):
|
||||
raise NomeNonValido("il nome della calibrazione deve essere un testo")
|
||||
nome = nome.strip()
|
||||
if not nome:
|
||||
raise NomeNonValido("il nome della calibrazione è vuoto")
|
||||
if len(nome) > 60:
|
||||
raise NomeNonValido("il nome della calibrazione supera i 60 caratteri")
|
||||
if any(unicodedata.category(c) == "Cc" for c in nome):
|
||||
raise NomeNonValido("il nome della calibrazione contiene caratteri di controllo")
|
||||
return nome
|
||||
|
||||
|
||||
# #91: campi che l'elenco espone per ogni calibrazione (il resto resta nel JSON)
|
||||
_CAMPI_ELENCO = ("nome", "origine", "image_width", "image_height", "px_per_mm",
|
||||
"mm_per_px", "metodo", "square_mm", "calibrated_at")
|
||||
|
||||
|
||||
class Archivio:
|
||||
"""#91: archivio UNICO di calibrazioni con nome, in `cartella`.
|
||||
|
||||
Libreria: <cartella>/calibrazioni/<nome ripulito>-<sha1 del nome>.json (+ .npz se
|
||||
densa, stesso nome). Attive: <cartella>/attive.json = {sorgente: nome}. Una
|
||||
calibrazione non appartiene a una sorgente: la sorgente ne sceglie una (`attiva`).
|
||||
#84: `leggi_mappa` tiene la mappa in memoria finche' il file non cambia.
|
||||
Il formato di prima (un JSON per sorgente, campo "source", in <cartella>) si
|
||||
migra da solo al costruttore (`migra`, idempotente).
|
||||
"""
|
||||
|
||||
def __init__(self, cartella: Path | str):
|
||||
self.cartella = Path(cartella)
|
||||
self._cache: dict = {} # percorso .npz -> (mtime_ns, MappaPronta)
|
||||
self._lock = threading.Lock()
|
||||
self._lock_dati = threading.RLock() # #91: libreria e attive.json (leggi-modifica-scrivi)
|
||||
self.migra()
|
||||
|
||||
# ---- percorsi
|
||||
|
||||
def _file(self, sorgente: str) -> Path:
|
||||
# caratteri sicuri + hash dell'originale: due sorgenti diverse non si pestano
|
||||
# (#91: oggi serve ai settaggi camera e al formato vecchio da migrare)
|
||||
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", sorgente).strip("._") or "sorgente"
|
||||
sigla = hashlib.sha1(sorgente.encode("utf-8")).hexdigest()[:8]
|
||||
return self.cartella / f"{pulito[:60]}-{sigla}.json"
|
||||
|
||||
@property
|
||||
def _libreria(self) -> Path:
|
||||
return self.cartella / "calibrazioni"
|
||||
|
||||
def _file_nome(self, nome: str) -> Path:
|
||||
# #91: stesso schema di _file, ma l'hash e' sul nome senza maiuscole/minuscole
|
||||
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", nome).strip("._") or "calibrazione"
|
||||
sigla = hashlib.sha1(nome.casefold().encode("utf-8")).hexdigest()[:8]
|
||||
return self._libreria / f"{pulito[:60]}-{sigla}.json"
|
||||
|
||||
def _file_mappa_nome(self, nome: str) -> Path:
|
||||
return self._file_nome(nome).with_suffix(".npz") # #84 stesso nome del JSON
|
||||
|
||||
@property
|
||||
def _file_attive(self) -> Path:
|
||||
return self.cartella / "attive.json"
|
||||
|
||||
# ---- scrittura atomica
|
||||
|
||||
def _scrivi_json(self, destinazione: Path, dati: dict) -> None:
|
||||
destinazione.parent.mkdir(parents=True, exist_ok=True)
|
||||
# scrittura atomica: file temporaneo nella stessa cartella, poi replace
|
||||
fd, tmp = tempfile.mkstemp(dir=destinazione.parent, suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(dati, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, destinazione)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def _leggi_json(self, percorso: Path) -> dict | None:
|
||||
try:
|
||||
dati = json.loads(percorso.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None # assente o illeggibile
|
||||
return dati if isinstance(dati, dict) else None
|
||||
|
||||
# ---- attive
|
||||
|
||||
def _leggi_attive(self) -> dict:
|
||||
dati = self._leggi_json(self._file_attive) or {}
|
||||
return {k: v for k, v in dati.items() if isinstance(k, str) and isinstance(v, str)}
|
||||
|
||||
def _scrivi_attive(self, attive: dict) -> None:
|
||||
self._scrivi_json(self._file_attive, attive)
|
||||
|
||||
# ---- migrazione dal formato vecchio
|
||||
|
||||
def migra(self) -> None:
|
||||
"""#91: ogni <cartella>/*.json con "source" (una per sorgente) entra in libreria.
|
||||
|
||||
Nome = valore di "source" (gia' preso: "<source> (2)", "(3)"...); il .npz
|
||||
accanto segue; la sorgente resta calibrata con quella voce (attive.json).
|
||||
Idempotente: il vecchio file sparisce, un secondo giro non trova nulla.
|
||||
"""
|
||||
if not self.cartella.is_dir():
|
||||
return
|
||||
with self._lock_dati:
|
||||
for vecchio in sorted(self.cartella.glob("*.json")):
|
||||
if vecchio.name == self._file_attive.name:
|
||||
continue
|
||||
dati = self._leggi_json(vecchio)
|
||||
sorgente = dati.get("source") if dati else None
|
||||
if not isinstance(sorgente, str) or not sorgente.strip():
|
||||
continue
|
||||
try:
|
||||
self._migra_uno(vecchio, dati, sorgente)
|
||||
except OSError:
|
||||
continue # un file bloccato non ferma il servizio: riprova al prossimo avvio
|
||||
|
||||
def _migra_uno(self, vecchio: Path, dati: dict, sorgente: str) -> None:
|
||||
base = "".join(c for c in sorgente if unicodedata.category(c) != "Cc").strip()[:60].strip() or "calibrazione"
|
||||
nome, n = base, 1
|
||||
while True:
|
||||
esistente = self.leggi_nome(nome)
|
||||
if esistente is None:
|
||||
break
|
||||
if (esistente.get("calibrated_at") == dati.get("calibrated_at")
|
||||
and esistente.get("px_per_mm") == dati.get("px_per_mm")):
|
||||
break # gia' copiata da un giro interrotto: si completa e basta
|
||||
n += 1
|
||||
suffisso = f" ({n})"
|
||||
nome = base[:60 - len(suffisso)].rstrip() + suffisso
|
||||
nuovo = {k: v for k, v in dati.items() if k != "source"}
|
||||
nuovo["nome"] = nome
|
||||
nuovo.setdefault("origine", sorgente) # l'origine vera non era registrata
|
||||
vecchia_mappa = vecchio.with_suffix(".npz")
|
||||
if vecchia_mappa.is_file(): # prima la mappa, poi il JSON che la dichiara
|
||||
self._libreria.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(vecchia_mappa, self._file_mappa_nome(nome))
|
||||
self._scrivi_json(self._file_nome(nome), nuovo)
|
||||
attive = self._leggi_attive()
|
||||
attive[sorgente.strip()] = nome
|
||||
self._scrivi_attive(attive)
|
||||
vecchio.unlink()
|
||||
|
||||
# ---- libreria
|
||||
|
||||
def leggi_nome(self, nome: str) -> dict | None:
|
||||
"""#91: la calibrazione con questo nome (senza distinguere maiuscole), o None."""
|
||||
try:
|
||||
nome = pulisci_nome(nome)
|
||||
except NomeNonValido:
|
||||
return None
|
||||
return self._leggi_json(self._file_nome(nome))
|
||||
|
||||
def _meta(self, dati: dict, attive: dict) -> dict:
|
||||
meta = {k: dati.get(k) for k in _CAMPI_ELENCO}
|
||||
chiave = str(dati.get("nome", "")).casefold()
|
||||
meta["attiva_su"] = sorted(s for s, n in attive.items() if n.casefold() == chiave)
|
||||
return meta
|
||||
|
||||
def elenco(self) -> list[dict]:
|
||||
"""#91: metadati di tutte le calibrazioni, dalla piu' recente."""
|
||||
with self._lock_dati:
|
||||
attive = self._leggi_attive()
|
||||
voci = []
|
||||
for percorso in self._libreria.glob("*.json") if self._libreria.is_dir() else []:
|
||||
dati = self._leggi_json(percorso)
|
||||
if dati and isinstance(dati.get("nome"), str):
|
||||
voci.append(self._meta(dati, attive))
|
||||
voci.sort(key=lambda m: str(m.get("calibrated_at") or ""), reverse=True)
|
||||
return voci
|
||||
|
||||
def metadati(self, nome: str) -> dict | None:
|
||||
"""#91: i metadati di una voce (come in `elenco`), o None."""
|
||||
dati = self.leggi_nome(nome)
|
||||
return self._meta(dati, self._leggi_attive()) if dati else None
|
||||
|
||||
def salva_nome(self, nome: str, dati: dict, mappa: DistortionMap | None = None,
|
||||
sovrascrivi: bool = False) -> dict:
|
||||
"""#91: salva `dati` (e la mappa, se densa) con questo nome; ritorna i metadati."""
|
||||
nome = pulisci_nome(nome)
|
||||
destinazione = self._file_nome(nome)
|
||||
with self._lock_dati:
|
||||
if destinazione.exists() and not sovrascrivi:
|
||||
esistente = self._leggi_json(destinazione) or {}
|
||||
raise NomeGiaUsato(f"esiste già una calibrazione «{esistente.get('nome', nome)}»")
|
||||
corpo = {k: v for k, v in dati.items() if k not in ("source", "overlay_png", "saved")}
|
||||
corpo["nome"] = nome
|
||||
self._libreria.mkdir(parents=True, exist_ok=True)
|
||||
# #84: prima la mappa (o la rimozione di una vecchia), poi il JSON che la dichiara
|
||||
if mappa is not None:
|
||||
self._salva_mappa(nome, mappa)
|
||||
else:
|
||||
self._togli_mappa(nome) # voce planare: una vecchia mappa non vale piu'
|
||||
self._scrivi_json(destinazione, corpo)
|
||||
return self._meta(corpo, self._leggi_attive())
|
||||
|
||||
def _salva_mappa(self, nome: str, mappa: DistortionMap) -> None:
|
||||
destinazione = self._file_mappa_nome(nome)
|
||||
fd, tmp = tempfile.mkstemp(dir=destinazione.parent, suffix=".tmp.npz") # .npz: numpy non aggiunge nulla
|
||||
os.close(fd)
|
||||
try:
|
||||
mappa.save(tmp)
|
||||
os.replace(tmp, destinazione) # atomica come il JSON
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
with self._lock:
|
||||
self._cache.pop(str(destinazione), None)
|
||||
|
||||
def _togli_mappa(self, nome: str) -> None:
|
||||
destinazione = self._file_mappa_nome(nome)
|
||||
try:
|
||||
destinazione.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
with self._lock:
|
||||
self._cache.pop(str(destinazione), None)
|
||||
|
||||
def elimina_nome(self, nome: str) -> bool:
|
||||
"""#91: toglie la voce (JSON e mappa) e la scollega da ogni sorgente. False se non c'era."""
|
||||
dati = self.leggi_nome(nome)
|
||||
if dati is None:
|
||||
return False
|
||||
canonico = str(dati.get("nome", nome))
|
||||
with self._lock_dati:
|
||||
try:
|
||||
self._file_nome(canonico).unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
self._togli_mappa(canonico)
|
||||
attive = self._leggi_attive()
|
||||
restanti = {s: n for s, n in attive.items() if n.casefold() != canonico.casefold()}
|
||||
if restanti != attive:
|
||||
self._scrivi_attive(restanti)
|
||||
return True
|
||||
|
||||
# ---- sorgenti
|
||||
|
||||
def attiva(self, sorgente: str, nome: str) -> str:
|
||||
"""#91: la sorgente usa questa calibrazione; ritorna il nome com'e' in libreria."""
|
||||
dati = self.leggi_nome(nome)
|
||||
if dati is None:
|
||||
raise NomeSconosciuto(nome)
|
||||
canonico = str(dati["nome"])
|
||||
with self._lock_dati:
|
||||
attive = self._leggi_attive()
|
||||
attive[sorgente] = canonico
|
||||
self._scrivi_attive(attive)
|
||||
return canonico
|
||||
|
||||
def scollega(self, sorgente: str) -> None:
|
||||
"""#91: la sorgente non ha piu' una calibrazione attiva; la libreria resta."""
|
||||
with self._lock_dati:
|
||||
attive = self._leggi_attive()
|
||||
if sorgente in attive:
|
||||
del attive[sorgente]
|
||||
self._scrivi_attive(attive)
|
||||
|
||||
def attiva_di(self, sorgente: str) -> str | None:
|
||||
"""#91: il nome della calibrazione attiva della sorgente (se esiste ancora)."""
|
||||
nome = self._leggi_attive().get(sorgente)
|
||||
dati = self.leggi_nome(nome) if nome else None
|
||||
return str(dati["nome"]) if dati else None
|
||||
|
||||
def leggi(self, sorgente: str) -> dict | None:
|
||||
"""#91: la calibrazione ATTIVA della sorgente, con "nome" e "source"; None se nessuna."""
|
||||
nome = self.attiva_di(sorgente)
|
||||
dati = self.leggi_nome(nome) if nome else None
|
||||
return {**dati, "source": sorgente} if dati else None
|
||||
|
||||
def leggi_mappa(self, sorgente: str) -> MappaPronta | None:
|
||||
"""La mappa della calibrazione attiva, pronta per `raddrizza`; None se non ne ha una."""
|
||||
nome = self.attiva_di(sorgente) # #91: la mappa e' della voce attiva, non della sorgente
|
||||
if nome is None:
|
||||
return None
|
||||
percorso = self._file_mappa_nome(nome)
|
||||
chiave = str(percorso)
|
||||
try:
|
||||
mtime = percorso.stat().st_mtime_ns
|
||||
except OSError:
|
||||
with self._lock:
|
||||
self._cache.pop(chiave, None)
|
||||
return None
|
||||
with self._lock:
|
||||
voce = self._cache.get(chiave)
|
||||
if voce is not None and voce[0] == mtime:
|
||||
return voce[1]
|
||||
try:
|
||||
pronta = _prepara_mappa(DistortionMap.load(percorso))
|
||||
except (OSError, ValueError, KeyError):
|
||||
return None # illeggibile = nessuna correzione
|
||||
with self._lock:
|
||||
self._cache[chiave] = (mtime, pronta)
|
||||
return pronta
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,710 @@
|
||||
"""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,
|
||||
grezzo=false} -> PNG
|
||||
#84: se la sorgente ha una mappa di distorsione salvata il frame esce
|
||||
raddrizzato (header X-Raddrizzato: si|no); "grezzo": true lo dà com'è.
|
||||
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
|
||||
|
||||
Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py).
|
||||
#91: archivio UNICO di calibrazioni con nome; una sorgente ne ha una attiva:
|
||||
POST /camera/calibrate multipart: source (obbligatorio solo senza image),
|
||||
square_mm, image (file, opzionale: senza, grab GREZZO dalla sorgente),
|
||||
cols/rows (opzionali). NON salva mai (il vecchio campo save e' ignorato).
|
||||
#84: SENZA cols/rows = calibrazione densa (default): una foto di
|
||||
scacchiera che riempie il campo, griglia trovata da sola, mappa di
|
||||
distorsione; mm/px e px/mm sono quelli dell'immagine RADDRIZZATA. CON
|
||||
cols/rows (angoli interni) = metodo planare di prima. Risposta: i campi
|
||||
del calcolo + overlay_png (b64), calibrated_at, origine ("camera:<src>" |
|
||||
"file:<nome>"), calcolo_id (il servizio tiene in memoria gli ultimi 8
|
||||
calcoli) e metodo ("densa"|"planare"), distorsione (bool) e, per la
|
||||
densa, residuo_rms_px, residuo_max_px, copertura_pct;
|
||||
422 scacchiera non trovata o parametri invalidi
|
||||
GET /camera/calibrazioni[?source=S] -> {calibrazioni: [{nome, origine,
|
||||
image_width, image_height, px_per_mm, mm_per_px, metodo, square_mm,
|
||||
calibrated_at, attiva_su}], attiva: nome|null} (dalla piu' recente)
|
||||
POST /camera/calibrazioni json {calcolo_id, nome, sovrascrivi=false} -> 201
|
||||
metadati. 404 calcolo scaduto, 400 nome non valido, 409 nome gia' usato
|
||||
POST /camera/calibrazioni/manuale json {source, nome, mm_per_px, sovrascrivi=false}
|
||||
-> 201 metadati (#95: mm/px impostato a mano, metodo "manuale", nessuna
|
||||
mappa; non la attiva). 400 mm/px fuori 0.00001..100 o nome non valido,
|
||||
409 nome gia' usato; la risoluzione e' quella del frame tenuto o di un grab
|
||||
DELETE /camera/calibrazioni?nome=X -> 204 (toglie anche la mappa e la scollega
|
||||
da ogni sorgente) | 404
|
||||
PUT /camera/calibrazione-attiva json {source, nome}: legge un frame dalla
|
||||
sorgente e carica la calibrazione solo se la risoluzione (larghezza x
|
||||
altezza) coincide: 409 altrimenti; 404 nome ignoto; 200 come GET sotto
|
||||
GET /camera/calibration?source=<src> -> 200 la calibrazione ATTIVA {source,
|
||||
nome, px_per_mm, mm_per_px, ..., image_width, image_height,
|
||||
calibrated_at} | 404 "sorgente non calibrata" (lo legge il backend)
|
||||
DELETE /camera/calibration?source=<src> -> 204 (SCOLLEGA la calibrazione attiva,
|
||||
la libreria resta)
|
||||
GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano
|
||||
GET /acquisizione -> stessa pagina (#87)
|
||||
Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default:
|
||||
POST /camera/anteprima json {source, grezzo=false, lato_max=1280 (320..4096),
|
||||
timeout_ms=5000} -> JPEG ridotto (lato lungo <= lato_max, mai ingrandito).
|
||||
Header: X-Raddrizzato si|no, X-Larghezza/X-Altezza (frame PIENO),
|
||||
X-Nitidezza (varianza del Laplaciano), X-Saturati-Pct (% pixel >= 250),
|
||||
X-Media (livello medio 0-255), tutti sul frame pieno in grigio.
|
||||
GET /camera/parametri?source=<src> -> {source, regolabile, parametri:
|
||||
{esposizione, esposizione_auto, guadagno, guadagno_auto: dominio}};
|
||||
dominio = GenicamSource.dominio (valore, tipo, min/max/passo o scelte).
|
||||
Esposizione in us, guadagno in dB. Sorgente senza capacita': regolabile=false.
|
||||
PUT /camera/parametri json {source, valori: {nome: valore}} -> come GET.
|
||||
Scrive prima gli *_auto, poi esposizione/guadagno (assesta=3).
|
||||
400 nome ignoto o corpo errato, 409 non regolabile, 422 camera rifiuta
|
||||
POST /camera/parametri/salva json {source} -> {source, salvati, file}
|
||||
scrive <config-dir>/settaggi/<nome>-<sha1>.json; alla prima apertura
|
||||
della camera nel servizio il file, se c'e', viene ricaricato.
|
||||
Le calibrazioni stanno in <config-dir>/calibrazioni, le attive in attive.json (default
|
||||
%ProgramData%/Tielogic/TieMeasureFlow/acquisitore su Windows, ./acquisitore altrove).
|
||||
|
||||
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>] \
|
||||
[--config-dir <cartella calibrazioni>]
|
||||
--folder abilita la sorgente finta `camera:folder` (loop sulle immagini).
|
||||
Immagini caricate (#92): POST/DELETE /camera/immagini, in <config-dir>/immagini; sono sorgenti
|
||||
a pieno titolo (/camera/sources, grab, anteprima, calibrate). POST /camera/anteprima/calibrata
|
||||
raddrizza l'ultimo frame grezzo dell'anteprima con la calibrazione attiva, senza leggerne uno nuovo.
|
||||
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, python-multipart (rotta /camera/calibrate),
|
||||
visionsuite.calibration (vs-core).
|
||||
|
||||
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 logging
|
||||
import math # #95: mm/px finito nella calibrazione manuale
|
||||
import os
|
||||
import re # #92: nome sicuro per le immagini caricate
|
||||
import sys
|
||||
import threading
|
||||
import time # #92: scadenza della cache delle camere
|
||||
import urllib.parse # #92: nome calibrazione nell'header X-Calibrazione
|
||||
import uuid # #91 id dei calcoli in attesa di nome
|
||||
from collections import OrderedDict
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from fastapi import Body, FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.concurrency import run_in_threadpool # #83: il grab bloccante fuori dall'event loop
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
|
||||
from visionsuite.camera.camera_interface import CameraError, CameraGrabError, CameraStateError
|
||||
from visionsuite.camera.sources import _ESTENSIONI, SourceNotFound, list_sources, open_source # #92: _ESTENSIONI = tipi ammessi per le immagini caricate
|
||||
|
||||
# #83: calibrazione e pagina stanno accanto a questo file (che non e' un pacchetto)
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import calibrazione # noqa: E402
|
||||
from calibrazione_pagina import PAGINA # noqa: E402
|
||||
|
||||
_TRIGGER = ("continuo",)
|
||||
_MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome
|
||||
_CACHE_CAMERE_S = 60.0 # #92: durata della cache della ricerca camere
|
||||
_log = logging.getLogger("camera_service") # #87: errori non bloccanti (settaggi)
|
||||
# #87: i quattro parametri regolabili, nell'ordine di presentazione
|
||||
_PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto")
|
||||
# #87: in scrittura prima gli automatismi (un valore manuale con auto acceso viene riscritto)
|
||||
_ORDINE_SCRITTURA = ("esposizione_auto", "guadagno_auto", "esposizione", "guadagno")
|
||||
|
||||
# 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 _config_dir_default() -> Path:
|
||||
"""#83: cartella delle calibrazioni salvate."""
|
||||
if os.name == "nt":
|
||||
base = os.environ.get("ProgramData") or "C:/ProgramData"
|
||||
return Path(base) / "Tielogic" / "TieMeasureFlow" / "acquisitore"
|
||||
return Path("acquisitore")
|
||||
|
||||
|
||||
def create_app(folder: Path | str | None = None,
|
||||
config_dir: Path | str | None = None) -> FastAPI:
|
||||
archivio = calibrazione.Archivio(config_dir if config_dir is not None
|
||||
else _config_dir_default()) # #83
|
||||
aperte: dict = {}
|
||||
grezzi: dict = {} # #92: sorgente -> ultimo frame pieno GREZZO dell'anteprima (per anteprima/calibrata)
|
||||
bloccati: dict = {} # #94: sorgente -> copia del frame grezzo pieno «bloccata» per il calcolo (stesso lock di grezzi)
|
||||
cartella_immagini = archivio.cartella / "immagini" # #92: immagini caricate dalla pagina (creata al bisogno)
|
||||
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
|
||||
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
|
||||
calcoli: OrderedDict = OrderedDict() # #91: calcolo_id -> (dati, mappa); gli ultimi _MAX_CALCOLI
|
||||
lock_calcoli = threading.Lock()
|
||||
|
||||
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())
|
||||
|
||||
# #92: la ricerca camere di list_sources dura ~10 s: il risultato (gia' filtrato) resta 60 s in cache
|
||||
cache_camere: dict = {"voci": None, "t": 0.0}
|
||||
lock_ricerca = threading.Lock() # due richieste insieme non lanciano due ricerche
|
||||
|
||||
def _camere(aggiorna: bool = False) -> list:
|
||||
with lock_ricerca:
|
||||
fresca = cache_camere["voci"] is not None and time.monotonic() - cache_camere["t"] < _CACHE_CAMERE_S
|
||||
if fresca and not aggiorna:
|
||||
return cache_camere["voci"]
|
||||
voci = list_sources(None, cameras=True, config=_config())
|
||||
# camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco
|
||||
cache_camere["voci"] = [{"name": v.name, "kind": v.kind, "label": v.label}
|
||||
for v in voci if v.kind in ("camera", "virtual") and v.name != "camera:file"]
|
||||
cache_camere["t"] = time.monotonic()
|
||||
return cache_camere["voci"]
|
||||
|
||||
def _prima_ricerca() -> None:
|
||||
try:
|
||||
_camere()
|
||||
except Exception: # noqa: BLE001 - e' solo un riscaldamento: la prima richiesta riprova
|
||||
_log.exception("prima ricerca camere fallita")
|
||||
|
||||
@asynccontextmanager
|
||||
async def _vita(_app):
|
||||
# #92: la prima ricerca parte all'avvio, cosi' la prima pagina non aspetta
|
||||
threading.Thread(target=_prima_ricerca, daemon=True, name="ricerca-camere").start()
|
||||
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(aggiorna: bool = False): # #92: aggiorna=true forza la ricerca (camera appena collegata)
|
||||
elenco = list(_camere(aggiorna))
|
||||
# #92: in coda le immagini caricate, sempre fresche dalla cartella (costano nulla)
|
||||
for v in list_sources(cartella_immagini, cameras=False):
|
||||
elenco.append({"name": v.name, "kind": "image", "label": f"immagine — {v.name}"})
|
||||
return {"sources": elenco}
|
||||
|
||||
def _chiudi(nome: str) -> None:
|
||||
"""#92: chiude la sorgente aperta e scorda il frame tenuto (immagine sostituita o tolta)."""
|
||||
with _lock_di(nome):
|
||||
with lock:
|
||||
s = aperte.pop(nome, None)
|
||||
grezzi.pop(nome, None)
|
||||
bloccati.pop(nome, None) # #94: tolta l'immagine/chiusa la sorgente, anche la copia bloccata
|
||||
if s is not None:
|
||||
try:
|
||||
s.close()
|
||||
except Exception: # noqa: BLE001 - la chiusura non deve far fallire la rotta
|
||||
pass
|
||||
|
||||
def _nome_immagine(grezzo: str) -> str:
|
||||
"""#92: solo il nome del file, coi caratteri sicuri di Archivio._file; "" se non ammesso."""
|
||||
base = Path((grezzo or "").replace("\\", "/")).name
|
||||
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", base).strip("._")
|
||||
radice, punto, estensione = pulito.rpartition(".")
|
||||
if not punto or not radice or f".{estensione.lower()}" not in _ESTENSIONI:
|
||||
return ""
|
||||
return f"{radice[:100]}.{estensione}"
|
||||
|
||||
@app.post("/camera/immagini", status_code=201)
|
||||
async def immagini_carica(image: UploadFile | None = File(None)):
|
||||
if image is None:
|
||||
raise HTTPException(400, "manca il file «image»")
|
||||
nome = _nome_immagine(image.filename or "")
|
||||
if not nome:
|
||||
raise HTTPException(400, "estensione non ammessa: " + ", ".join(_ESTENSIONI))
|
||||
contenuto = await image.read()
|
||||
# #92: 422 se non e' un'immagine; la verifica sta PRIMA della scrittura, cosi' non resta nulla su disco
|
||||
if not contenuto or cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED) is None:
|
||||
raise HTTPException(422, "il file caricato non è leggibile come immagine")
|
||||
cartella_immagini.mkdir(parents=True, exist_ok=True)
|
||||
(cartella_immagini / nome).write_bytes(contenuto) # stesso nome = sostituisce
|
||||
await run_in_threadpool(_chiudi, nome) # se era aperta, si riapre sul file nuovo
|
||||
return {"source": nome, "label": f"immagine — {nome}", "kind": "image"}
|
||||
|
||||
@app.delete("/camera/immagini", status_code=204)
|
||||
def immagini_elimina(nome: str = ""):
|
||||
percorso = cartella_immagini / nome
|
||||
# #92: solo il nome di un file dentro la cartella, niente percorsi
|
||||
if not nome or Path(nome).name != nome or not percorso.is_file():
|
||||
raise HTTPException(404, "immagine non trovata")
|
||||
_chiudi(nome)
|
||||
percorso.unlink()
|
||||
return Response(status_code=204)
|
||||
|
||||
def _apri(nome: str):
|
||||
"""#87: sorgente aperta (o da aprire); da chiamare SOLO sotto _lock_di(nome)."""
|
||||
with lock:
|
||||
sorgente = aperte.get(nome)
|
||||
if sorgente is None:
|
||||
sorgente = open_source(nome, cartella_immagini, config=_config()) # #92: un nome di file = immagine caricata
|
||||
if hasattr(sorgente, "loop"): # #92: l'immagine singola non deve "finire" al secondo grab (anteprima continua)
|
||||
sorgente.loop = True
|
||||
# TieVision-Core#341 / #89: free-run, serve la scena di adesso, non il frame vecchio in coda
|
||||
if hasattr(sorgente, "solo_ultimo_frame"):
|
||||
try:
|
||||
sorgente.solo_ultimo_frame()
|
||||
except Exception as e: # noqa: BLE001 - producer senza il nodo: si apre lo stesso
|
||||
_log.warning("frame piu' recente non impostabile su %s: %s", nome, e)
|
||||
_carica_settaggi(nome, sorgente) # #87: predefiniti salvati, se ci sono
|
||||
with lock:
|
||||
aperte[nome] = sorgente
|
||||
return sorgente
|
||||
|
||||
def _file_settaggi(nome: str) -> Path:
|
||||
# #87: stesso schema di nome della calibrazione, in una sottocartella
|
||||
return archivio.cartella / "settaggi" / archivio._file(nome).name
|
||||
|
||||
def _carica_settaggi(nome: str, sorgente) -> None:
|
||||
percorso = _file_settaggi(nome)
|
||||
if not percorso.is_file() or not hasattr(sorgente, "carica_settaggi"):
|
||||
return
|
||||
try:
|
||||
sorgente.carica_settaggi(percorso)
|
||||
except Exception as e: # noqa: BLE001 - un file rotto non blocca l'apertura
|
||||
_log.warning("settaggi di %s non caricati (%s): %s", nome, percorso, e)
|
||||
|
||||
def _con_sorgente(nome: str, fn):
|
||||
"""#87: fn(sorgente) sotto il lock della sorgente; errori camera -> HTTP."""
|
||||
try:
|
||||
with _lock_di(nome): # solo questa sorgente; le altre non aspettano
|
||||
return fn(_apri(nome))
|
||||
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)))
|
||||
|
||||
def _grab(nome: str, timeout: int):
|
||||
"""Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP."""
|
||||
return _con_sorgente(nome, lambda s: s.grab(timeout)) # #87: corpo in _con_sorgente
|
||||
|
||||
@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")
|
||||
grezzo = corpo.get("grezzo", False) # #84: true = frame non raddrizzato
|
||||
if not isinstance(grezzo, bool):
|
||||
raise HTTPException(400, "«grezzo» deve essere true o false")
|
||||
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")
|
||||
frame = _grab(nome, timeout) # #83: corpo estratto in _grab, invariato
|
||||
raddrizzato = False
|
||||
if not grezzo: # #84: sorgente calibrata (densa) -> frame raddrizzato dalla mappa in cache (#91: quella attiva)
|
||||
mappa = archivio.leggi_mappa(nome)
|
||||
if mappa is not None:
|
||||
corretto = calibrazione.raddrizza(frame, mappa)
|
||||
if corretto is not None: # None = dimensioni diverse da quelle calibrate
|
||||
frame, raddrizzato = corretto, True
|
||||
ok, buf = cv2.imencode(".png", frame)
|
||||
if not ok:
|
||||
raise HTTPException(500, "codifica PNG fallita")
|
||||
return Response(buf.tobytes(), media_type="image/png",
|
||||
headers={"X-Raddrizzato": "si" if raddrizzato else "no"})
|
||||
|
||||
# ---- #83: calibrazione a scacchiera per sorgente
|
||||
|
||||
def _sorgente(valore) -> str:
|
||||
if not isinstance(valore, str) or not valore.strip():
|
||||
raise HTTPException(400, "manca «source»")
|
||||
return valore.strip()
|
||||
|
||||
@app.get("/camera/calibration")
|
||||
def calibrazione_leggi(source: str = ""):
|
||||
dati = archivio.leggi(_sorgente(source)) # #91: la calibrazione ATTIVA della sorgente
|
||||
if dati is None:
|
||||
raise HTTPException(404, "sorgente non calibrata")
|
||||
return dati
|
||||
|
||||
@app.delete("/camera/calibration", status_code=204)
|
||||
def calibrazione_elimina(source: str = ""):
|
||||
archivio.scollega(_sorgente(source)) # #91: scollega soltanto, la libreria resta
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.post("/camera/calibrate")
|
||||
async def calibrazione_calcola(
|
||||
source: str | None = Form(None), # #91: serve solo se manca l'immagine
|
||||
square_mm: float = Form(...),
|
||||
cols: int | None = Form(None), # #84: opzionali, senza = calibrazione densa
|
||||
rows: int | None = Form(None),
|
||||
image: UploadFile | None = File(None),
|
||||
save: bool = Form(False), # #91: ignorato, il calcolo non salva mai (si salva con nome)
|
||||
bloccata: bool = Form(False), # #94: true = calcola sulla copia bloccata, senza grab
|
||||
):
|
||||
contenuto = await image.read() if image is not None else b""
|
||||
if contenuto:
|
||||
frame = cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||
if frame is None:
|
||||
raise HTTPException(422, "l'immagine caricata non è leggibile")
|
||||
origine = "file:" + (Path((image.filename or "").replace("\\", "/")).name or "immagine")
|
||||
elif bloccata: # #94: copia bloccata, nessun grab e nessun lock camera
|
||||
nome = _sorgente(source)
|
||||
with lock:
|
||||
frame = bloccati.get(nome)
|
||||
if frame is None:
|
||||
raise HTTPException(409, "nessuna immagine bloccata su questa sorgente")
|
||||
origine = nome
|
||||
else:
|
||||
nome = _sorgente(source)
|
||||
frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504)
|
||||
origine = nome
|
||||
if (cols is None) != (rows is None):
|
||||
raise HTTPException(422, "cols e rows vanno indicati insieme (o nessuno dei due)")
|
||||
mappa = None
|
||||
try:
|
||||
if cols is None: # #84: densa, nessuna griglia da dichiarare
|
||||
risultato, mappa = await run_in_threadpool(
|
||||
calibrazione.calibra_scacchiera_densa, frame, square_mm)
|
||||
else: # metodo planare di #83, invariato
|
||||
risultato = calibrazione.calibra_scacchiera(frame, cols, rows, square_mm)
|
||||
except calibrazione.CalibrazioneError as e:
|
||||
raise HTTPException(422, str(e))
|
||||
corpo = {**{k: v for k, v in risultato.items() if k != "overlay_png"},
|
||||
"calibrated_at": calibrazione.ora_iso(), "origine": origine}
|
||||
calcolo_id = uuid.uuid4().hex
|
||||
with lock_calcoli: # #91: il calcolo resta in memoria finche' non gli si da' un nome
|
||||
calcoli[calcolo_id] = (corpo, mappa)
|
||||
while len(calcoli) > _MAX_CALCOLI:
|
||||
calcoli.popitem(last=False)
|
||||
return {**corpo, "calcolo_id": calcolo_id, "overlay_png": risultato["overlay_png"],
|
||||
"bloccata": bool(bloccata and not contenuto)} # #94: solo nella risposta (non nel salvato): true se il frame e' la copia bloccata
|
||||
|
||||
# ---- #91: archivio unico di calibrazioni con nome
|
||||
|
||||
@app.get("/camera/calibrazioni")
|
||||
def calibrazioni_elenco(source: str | None = None):
|
||||
attiva = archivio.attiva_di(_sorgente(source)) if source is not None else None
|
||||
return {"calibrazioni": archivio.elenco(), "attiva": attiva}
|
||||
|
||||
@app.post("/camera/calibrazioni", status_code=201)
|
||||
def calibrazioni_salva(corpo: dict = Body(default=None)):
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
calcolo_id = corpo.get("calcolo_id")
|
||||
if not isinstance(calcolo_id, str) or not calcolo_id:
|
||||
raise HTTPException(400, "manca «calcolo_id»")
|
||||
sovrascrivi = corpo.get("sovrascrivi", False)
|
||||
if not isinstance(sovrascrivi, bool):
|
||||
raise HTTPException(400, "«sovrascrivi» deve essere true o false")
|
||||
try:
|
||||
nome = calibrazione.pulisci_nome(corpo.get("nome"))
|
||||
except calibrazione.NomeNonValido as e:
|
||||
raise HTTPException(400, str(e))
|
||||
with lock_calcoli:
|
||||
voce = calcoli.get(calcolo_id)
|
||||
if voce is None:
|
||||
raise HTTPException(404, "calcolo non più disponibile: ricalcolare")
|
||||
dati, mappa = voce
|
||||
try:
|
||||
return archivio.salva_nome(nome, dati, mappa, sovrascrivi)
|
||||
except calibrazione.NomeGiaUsato as e:
|
||||
raise HTTPException(409, str(e))
|
||||
|
||||
@app.post("/camera/calibrazioni/manuale", status_code=201)
|
||||
def calibrazioni_manuale(corpo: dict = Body(default=None)):
|
||||
"""#95: calibrazione con mm/px impostato a mano (nessuna scacchiera, nessuna mappa); NON la attiva."""
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
sorgente = _sorgente(corpo.get("source"))
|
||||
mm_px = corpo.get("mm_per_px")
|
||||
if (isinstance(mm_px, bool) or not isinstance(mm_px, (int, float))
|
||||
or not math.isfinite(mm_px) or not 0.00001 <= mm_px <= 100):
|
||||
raise HTTPException(400, "mm/px deve essere un numero positivo")
|
||||
sovrascrivi = corpo.get("sovrascrivi", False)
|
||||
if not isinstance(sovrascrivi, bool):
|
||||
raise HTTPException(400, "«sovrascrivi» deve essere true o false")
|
||||
try:
|
||||
nome = calibrazione.pulisci_nome(corpo.get("nome"))
|
||||
except calibrazione.NomeNonValido as e:
|
||||
raise HTTPException(400, str(e))
|
||||
with lock:
|
||||
frame = grezzi.get(sorgente)
|
||||
if frame is None: # nessun frame tenuto: un grab come /camera/grab (404/503/504)
|
||||
frame = _grab(sorgente, 5000)
|
||||
alt, larg = frame.shape[:2] # serve al controllo W×H di PUT /camera/calibrazione-attiva
|
||||
dati = {"px_per_mm": 1.0 / mm_px, "mm_per_px": float(mm_px), "metodo": "manuale",
|
||||
"image_width": larg, "image_height": alt, "origine": sorgente,
|
||||
"calibrated_at": calibrazione.ora_iso(), "rms_px": None, "square_mm": None}
|
||||
try:
|
||||
return archivio.salva_nome(nome, dati, None, sovrascrivi)
|
||||
except calibrazione.NomeGiaUsato as e:
|
||||
raise HTTPException(409, str(e))
|
||||
|
||||
@app.delete("/camera/calibrazioni", status_code=204)
|
||||
def calibrazioni_elimina(nome: str = ""):
|
||||
if not archivio.elimina_nome(nome):
|
||||
raise HTTPException(404, "calibrazione non trovata")
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.put("/camera/calibrazione-attiva")
|
||||
def calibrazione_attiva(corpo: dict = Body(default=None)):
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
sorgente = _sorgente(corpo.get("source"))
|
||||
richiesto = corpo.get("nome")
|
||||
dati = archivio.leggi_nome(richiesto) if isinstance(richiesto, str) else None
|
||||
if dati is None:
|
||||
raise HTTPException(404, "calibrazione non trovata")
|
||||
nome = dati["nome"]
|
||||
frame = _grab(sorgente, 5000) # stesso lock e stessi errori di /camera/grab (404/503/504)
|
||||
alt, larg = frame.shape[:2]
|
||||
cw, ch = dati.get("image_width"), dati.get("image_height")
|
||||
if not isinstance(cw, int) or not isinstance(ch, int):
|
||||
raise HTTPException(409, f"la calibrazione «{nome}» non ha la risoluzione registrata: "
|
||||
"non si può caricare")
|
||||
if (cw, ch) != (larg, alt): # la mappa vale solo per frame della stessa risoluzione
|
||||
raise HTTPException(409, f"la calibrazione «{nome}» è per immagini {cw}×{ch}, "
|
||||
f"la sorgente dà {larg}×{alt}: non si può caricare")
|
||||
archivio.attiva(sorgente, nome)
|
||||
return archivio.leggi(sorgente)
|
||||
|
||||
# ---- #87: acquisizione (anteprima, parametri, settaggi)
|
||||
|
||||
def _regolabile(s) -> bool:
|
||||
# #87: ha il vocabolario delle capacita' e ne supporta almeno una delle quattro
|
||||
if not (hasattr(s, "supporta") and hasattr(s, "dominio")):
|
||||
return False
|
||||
return any(s.supporta(n) for n in _PARAMETRI)
|
||||
|
||||
def _parametri(nome: str, s) -> dict:
|
||||
"""#87: forma di GET /camera/parametri; da chiamare sotto lock."""
|
||||
parametri: dict = {}
|
||||
if _regolabile(s):
|
||||
for n in _PARAMETRI:
|
||||
if not s.supporta(n):
|
||||
continue
|
||||
try:
|
||||
parametri[n] = s.dominio(n)
|
||||
except CameraStateError as e: # capacita' dichiarata ma non leggibile: si salta
|
||||
_log.warning("dominio di %s su %s non letto: %s", n, nome, e)
|
||||
return {"source": nome, "regolabile": bool(parametri), "parametri": parametri}
|
||||
|
||||
def _grigio8(frame):
|
||||
g = frame if frame.ndim == 2 else cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
if g.dtype == np.uint16: # #87: 16 bit -> 8 bit, per soglia e media coerenti
|
||||
g = (g >> 8).astype(np.uint8)
|
||||
elif g.dtype != np.uint8:
|
||||
g = cv2.convertScaleAbs(g, alpha=255.0 / max(float(g.max()), 1.0))
|
||||
return g
|
||||
|
||||
@app.post("/camera/anteprima")
|
||||
def camera_anteprima(corpo: dict = Body(default=None)):
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
nome = _sorgente(corpo.get("source"))
|
||||
grezzo = corpo.get("grezzo", False)
|
||||
if not isinstance(grezzo, bool):
|
||||
raise HTTPException(400, "«grezzo» deve essere true o false")
|
||||
lato = corpo.get("lato_max", 1280)
|
||||
if isinstance(lato, bool) or not isinstance(lato, int) or not 320 <= lato <= 4096:
|
||||
raise HTTPException(400, "«lato_max» deve essere un intero fra 320 e 4096")
|
||||
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")
|
||||
def leggi(s): # #92: il frame grezzo si tiene sotto il lock della sorgente
|
||||
f = s.grab(timeout)
|
||||
with lock:
|
||||
grezzi[nome] = f.copy()
|
||||
return f
|
||||
frame = _con_sorgente(nome, leggi)
|
||||
raddrizzato = False
|
||||
if not grezzo: # come /camera/grab
|
||||
mappa = archivio.leggi_mappa(nome)
|
||||
if mappa is not None:
|
||||
corretto = calibrazione.raddrizza(frame, mappa)
|
||||
if corretto is not None:
|
||||
frame, raddrizzato = corretto, True
|
||||
alt, larg = frame.shape[:2]
|
||||
grigio = _grigio8(frame) # misure sul frame PIENO, non sul ridotto
|
||||
nitidezza = float(cv2.Laplacian(grigio, cv2.CV_64F).var())
|
||||
saturati = float((grigio >= 250).mean() * 100.0)
|
||||
media = float(grigio.mean())
|
||||
if frame.dtype != np.uint8: # il JPEG vuole 8 bit
|
||||
frame = grigio
|
||||
scala = lato / max(alt, larg)
|
||||
if scala < 1.0: # mai ingrandita
|
||||
frame = cv2.resize(frame, (max(1, round(larg * scala)), max(1, round(alt * scala))),
|
||||
interpolation=cv2.INTER_AREA)
|
||||
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
if not ok:
|
||||
raise HTTPException(500, "codifica JPEG fallita")
|
||||
return Response(buf.tobytes(), media_type="image/jpeg", headers={
|
||||
"X-Raddrizzato": "si" if raddrizzato else "no",
|
||||
"X-Larghezza": str(larg), "X-Altezza": str(alt),
|
||||
"X-Nitidezza": f"{nitidezza:.1f}", "X-Saturati-Pct": f"{saturati:.2f}",
|
||||
"X-Media": f"{media:.1f}"})
|
||||
|
||||
@app.post("/camera/blocca")
|
||||
def camera_blocca(corpo: dict = Body(default=None)):
|
||||
"""#94: copia l'ultimo frame grezzo della sorgente; /camera/calibrate con bloccata=true calcola su quella."""
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
nome = _sorgente(corpo.get("source"))
|
||||
with lock:
|
||||
frame = grezzi.get(nome)
|
||||
if frame is None:
|
||||
raise HTTPException(404, "nessun frame: avviare l'anteprima o scattare")
|
||||
bloccati[nome] = frame.copy()
|
||||
alt, larg = frame.shape[:2]
|
||||
return {"source": nome, "larghezza": larg, "altezza": alt}
|
||||
|
||||
@app.delete("/camera/blocca", status_code=204)
|
||||
def camera_sblocca(source: str = ""):
|
||||
with lock: # #94: idempotente, 204 anche se non c'era nulla
|
||||
bloccati.pop(source.strip(), None)
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.post("/camera/anteprima/calibrata")
|
||||
def camera_anteprima_calibrata(corpo: dict = Body(default=None)):
|
||||
"""#92: l'ultimo frame grezzo dell'anteprima, raddrizzato; NON legge un frame nuovo. #95: senza mappa esce grezzo (X-Raddrizzato: no)."""
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
nome = _sorgente(corpo.get("source"))
|
||||
lato = corpo.get("lato_max", 1280)
|
||||
if isinstance(lato, bool) or not isinstance(lato, int) or not 320 <= lato <= 4096:
|
||||
raise HTTPException(400, "«lato_max» deve essere un intero fra 320 e 4096")
|
||||
with lock:
|
||||
frame = grezzi.get(nome)
|
||||
if frame is None:
|
||||
raise HTTPException(404, "nessun frame: avviare l'anteprima o scattare")
|
||||
attiva = archivio.attiva_di(nome)
|
||||
if attiva is None:
|
||||
raise HTTPException(409, "nessuna calibrazione attiva su questa sorgente")
|
||||
mappa = archivio.leggi_mappa(nome)
|
||||
if mappa is None: # #95: senza mappa (manuale o planare) solo scala: frame grezzo, nessuna correzione
|
||||
dati = archivio.leggi(nome) or {}
|
||||
cw, ch = dati.get("image_width"), dati.get("image_height")
|
||||
alt, larg = frame.shape[:2]
|
||||
if isinstance(cw, int) and isinstance(ch, int) and (cw, ch) != (larg, alt):
|
||||
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
|
||||
f"il frame è {larg}×{alt}")
|
||||
corretto, raddrizzato = frame, "no"
|
||||
else:
|
||||
corretto = calibrazione.raddrizza(frame, mappa)
|
||||
if corretto is None: # risoluzione diversa da quella calibrata
|
||||
ch, cw = mappa.shape[:2]
|
||||
alt, larg = frame.shape[:2]
|
||||
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
|
||||
f"il frame è {larg}×{alt}")
|
||||
raddrizzato = "si"
|
||||
alt, larg = corretto.shape[:2]
|
||||
if corretto.dtype != np.uint8: # il JPEG vuole 8 bit
|
||||
corretto = _grigio8(corretto)
|
||||
scala = lato / max(alt, larg)
|
||||
if scala < 1.0: # mai ingrandita
|
||||
corretto = cv2.resize(corretto, (max(1, round(larg * scala)), max(1, round(alt * scala))),
|
||||
interpolation=cv2.INTER_AREA)
|
||||
ok, buf = cv2.imencode(".jpg", corretto, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
if not ok:
|
||||
raise HTTPException(500, "codifica JPEG fallita")
|
||||
return Response(buf.tobytes(), media_type="image/jpeg", headers={
|
||||
"X-Raddrizzato": raddrizzato, "X-Larghezza": str(larg), "X-Altezza": str(alt),
|
||||
"X-Calibrazione": urllib.parse.quote(attiva, safe="")})
|
||||
|
||||
@app.get("/camera/parametri")
|
||||
def parametri_leggi(source: str = ""):
|
||||
nome = _sorgente(source)
|
||||
return _con_sorgente(nome, lambda s: _parametri(nome, s))
|
||||
|
||||
@app.put("/camera/parametri")
|
||||
def parametri_scrivi(corpo: dict = Body(default=None)):
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
nome = _sorgente(corpo.get("source"))
|
||||
valori = corpo.get("valori")
|
||||
if not isinstance(valori, dict) or not valori:
|
||||
raise HTTPException(400, "manca «valori»")
|
||||
for n, v in valori.items():
|
||||
if n not in _PARAMETRI:
|
||||
raise HTTPException(400, f"parametro non riconosciuto: «{n}»")
|
||||
if isinstance(v, bool) or not isinstance(v, (int, float, str)):
|
||||
raise HTTPException(400, f"valore non valido per «{n}»")
|
||||
|
||||
def scrivi(s):
|
||||
if not _regolabile(s):
|
||||
raise HTTPException(409, "questa sorgente non ha parametri regolabili")
|
||||
for n in _ORDINE_SCRITTURA: # prima gli *_auto
|
||||
if n in valori:
|
||||
try:
|
||||
s.scrivi(n, valori[n], assesta=3)
|
||||
except CameraStateError as e:
|
||||
raise HTTPException(422, _traduci(str(e)))
|
||||
return _parametri(nome, s) # valori riletti dalla camera
|
||||
|
||||
return _con_sorgente(nome, scrivi)
|
||||
|
||||
@app.post("/camera/parametri/salva")
|
||||
def parametri_salva(corpo: dict = Body(default=None)):
|
||||
nome = _sorgente(corpo.get("source") if isinstance(corpo, dict) else None)
|
||||
|
||||
def salva(s):
|
||||
if not _regolabile(s) or not hasattr(s, "salva_settaggi"):
|
||||
raise HTTPException(409, "questa sorgente non ha parametri regolabili")
|
||||
percorso = _file_settaggi(nome)
|
||||
percorso.parent.mkdir(parents=True, exist_ok=True)
|
||||
s.salva_settaggi(percorso)
|
||||
return {"source": nome, "salvati": True, "file": percorso.name}
|
||||
|
||||
return _con_sorgente(nome, salva)
|
||||
|
||||
@app.get("/calibrazione", response_class=HTMLResponse)
|
||||
@app.get("/acquisizione", response_class=HTMLResponse) # #87: stessa pagina
|
||||
def calibrazione_pagina():
|
||||
return HTMLResponse(PAGINA)
|
||||
|
||||
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")
|
||||
p.add_argument("--config-dir", default=None,
|
||||
help="cartella delle calibrazioni salvate (default: ProgramData su Windows)") # #83
|
||||
a = p.parse_args()
|
||||
uvicorn.run(create_app(a.folder, a.config_dir), host=a.host, port=a.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,400 @@
|
||||
"""Seed the recipes used to accept points 2, 3, 5, 8, 9 and 11 on a real screen.
|
||||
|
||||
The tests prove the rules hold; this puts them in front of an operator. Two
|
||||
recipes, because they need opposite settings:
|
||||
|
||||
COLLAUDO-A typing allowed, lot compulsory, two measurement tasks with
|
||||
documental tasks before, between and after them, interval of two
|
||||
minutes. This is the one to drive: the measurement loop, the
|
||||
bidirectional countdown, the out-of-tolerance gate, the cycle that
|
||||
closes only on the last measurement task.
|
||||
|
||||
COLLAUDO-B caliper only, lot and serial compulsory. This one is to look at:
|
||||
the keypad is not drawn, and Avvia does not start until both
|
||||
traceability fields are filled in.
|
||||
|
||||
Run it inside the server container, where src.backend and the database settings
|
||||
are already in place:
|
||||
|
||||
docker compose cp scripts/seed_collaudo.py server:/tmp/seed_collaudo.py
|
||||
docker compose exec server uv run python /tmp/seed_collaudo.py --station ST-DEFAULT
|
||||
|
||||
Re-running finds the recipes already there and stops. --replace deletes them
|
||||
first, along with anything measured against them - which is what you want between
|
||||
one acceptance session and the next, and never what you want by accident: it
|
||||
prints how many Measurement/ProductionRun/ProductionEvent rows that is and
|
||||
refuses to touch them without --si.
|
||||
|
||||
Refuses to run against a real deployment (APP_ENV outside dev/test, same rule
|
||||
as src.backend.config) unless --conferma-produzione is also given: this script
|
||||
deletes data, and a shop floor is not where to find that out by mistake.
|
||||
|
||||
If --supervisor-password is not given and the capoturno account does not exist
|
||||
yet, a random one is generated and printed once - never committed, never a
|
||||
default anyone could look up.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import secrets
|
||||
import sys
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
from src.backend.config import settings # noqa: E402
|
||||
from src.backend.database import async_session_factory # noqa: E402
|
||||
from src.backend.models.orm.measurement import Measurement # noqa: E402
|
||||
from src.backend.models.orm.production import ( # noqa: E402
|
||||
ProductionEvent, ProductionRun,
|
||||
)
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: E402
|
||||
from src.backend.models.orm.station import ( # noqa: E402
|
||||
Station, StationRecipeAssignment,
|
||||
)
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask # noqa: E402
|
||||
from src.backend.models.orm.user import User # noqa: E402
|
||||
from src.backend.services import auth_service # noqa: E402
|
||||
|
||||
CODES = ("COLLAUDO-A", "COLLAUDO-B")
|
||||
|
||||
# Limits chosen so every outcome is one deliberate keystroke away:
|
||||
# 10.00 -> conforme 10.30 -> attenzione (dentro tolleranza)
|
||||
# 12.00 -> fuori tolleranza, serve il capoturno
|
||||
QUOTES_A1 = [
|
||||
dict(marker_number=1, description="Diametro esterno",
|
||||
nominal=10.0, ltl=9.5, lwl=9.8, uwl=10.2, utl=10.5, unit="mm"),
|
||||
dict(marker_number=2, description="Spessore parete",
|
||||
nominal=25.0, ltl=24.5, lwl=24.8, uwl=25.2, utl=25.5, unit="mm"),
|
||||
]
|
||||
QUOTES_A2 = [
|
||||
dict(marker_number=3, description="Lunghezza totale",
|
||||
nominal=100.0, ltl=99.0, lwl=99.5, uwl=100.5, utl=101.0, unit="mm"),
|
||||
]
|
||||
QUOTES_B = [
|
||||
dict(marker_number=1, description="Diametro con calibro",
|
||||
nominal=10.0, ltl=9.5, lwl=9.8, uwl=10.2, utl=10.5, unit="mm"),
|
||||
]
|
||||
|
||||
# A description with a blank line and a bold phrase: point 11 is visible or it is
|
||||
# not, and this is where you look.
|
||||
NOTA_INIZIALE = (
|
||||
"Prendere il pezzo dalla cassetta **a sinistra** della postazione.\n"
|
||||
"Pulire la superficie prima di misurare.\n\n"
|
||||
"**Attenzione**: non misurare pezzi ancora caldi."
|
||||
)
|
||||
NOTA_INTERMEDIA = (
|
||||
"Girare il pezzo di **90 gradi** prima della seconda misura.\n"
|
||||
"Se il pezzo non appoggia in piano, ripetere il posizionamento."
|
||||
)
|
||||
NOTA_FINALE = (
|
||||
"Riporre il pezzo nella cassetta **a destra**.\n"
|
||||
"Segnalare al capoturno qualsiasi quota fuori tolleranza."
|
||||
)
|
||||
|
||||
|
||||
async def _find_recipes(session, codes):
|
||||
result = await session.execute(select(Recipe).where(Recipe.code.in_(codes)))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _count_replace_impact(session, recipes) -> dict:
|
||||
"""How many Measurement/ProductionRun/ProductionEvent --replace would delete.
|
||||
|
||||
Read-only mirror of what _delete_recipes actually removes, so the count
|
||||
printed before the confirmation matches the count deleted after it.
|
||||
"""
|
||||
version_ids = []
|
||||
run_ids = []
|
||||
for recipe in recipes:
|
||||
version_ids += (await session.execute(
|
||||
select(RecipeVersion.id).where(RecipeVersion.recipe_id == recipe.id)
|
||||
)).scalars().all()
|
||||
run_ids += (await session.execute(
|
||||
select(ProductionRun.id).where(ProductionRun.recipe_id == recipe.id)
|
||||
)).scalars().all()
|
||||
|
||||
measurements = 0
|
||||
if version_ids:
|
||||
measurements = (await session.execute(
|
||||
select(func.count()).select_from(Measurement)
|
||||
.where(Measurement.version_id.in_(version_ids))
|
||||
)).scalar_one()
|
||||
|
||||
events = 0
|
||||
if run_ids:
|
||||
events = (await session.execute(
|
||||
select(func.count()).select_from(ProductionEvent)
|
||||
.where(ProductionEvent.run_id.in_(run_ids))
|
||||
)).scalar_one()
|
||||
|
||||
return {
|
||||
"measurements": measurements,
|
||||
"production_runs": len(run_ids),
|
||||
"production_events": events,
|
||||
}
|
||||
|
||||
|
||||
async def _delete_recipes(session, recipes):
|
||||
"""Remove the collaudo recipes and everything measured against them."""
|
||||
for recipe in recipes:
|
||||
versions = (await session.execute(
|
||||
select(RecipeVersion.id).where(RecipeVersion.recipe_id == recipe.id)
|
||||
)).scalars().all()
|
||||
runs = (await session.execute(
|
||||
select(ProductionRun.id).where(ProductionRun.recipe_id == recipe.id)
|
||||
)).scalars().all()
|
||||
|
||||
if versions:
|
||||
await session.execute(
|
||||
delete(Measurement).where(Measurement.version_id.in_(versions))
|
||||
)
|
||||
if runs:
|
||||
await session.execute(
|
||||
delete(ProductionEvent).where(ProductionEvent.run_id.in_(runs))
|
||||
)
|
||||
await session.execute(
|
||||
delete(ProductionRun).where(ProductionRun.id.in_(runs))
|
||||
)
|
||||
await session.execute(
|
||||
delete(StationRecipeAssignment).where(
|
||||
StationRecipeAssignment.recipe_id == recipe.id
|
||||
)
|
||||
)
|
||||
# Versions, tasks and subtasks go with the recipe through the ORM cascade.
|
||||
await session.delete(recipe)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def _add_task(session, version_id, order_index, title, task_type,
|
||||
directive=None, description=None, quotes=()):
|
||||
task = RecipeTask(
|
||||
version_id=version_id,
|
||||
order_index=order_index,
|
||||
title=title,
|
||||
task_type=task_type,
|
||||
directive=directive,
|
||||
description=description,
|
||||
)
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
for quote in quotes:
|
||||
session.add(RecipeSubtask(task_id=task.id, **quote))
|
||||
await session.flush()
|
||||
return task
|
||||
|
||||
|
||||
async def _create_recipe(session, user_id, *, code, name, description,
|
||||
interval, requires_lot, requires_serial,
|
||||
allow_manual_input):
|
||||
recipe = Recipe(
|
||||
code=code,
|
||||
name=name,
|
||||
description=description,
|
||||
created_by=user_id,
|
||||
active=True,
|
||||
measurement_interval_minutes=interval,
|
||||
requires_lot=requires_lot,
|
||||
requires_serial=requires_serial,
|
||||
allow_manual_input=allow_manual_input,
|
||||
)
|
||||
session.add(recipe)
|
||||
await session.flush()
|
||||
|
||||
version = RecipeVersion(
|
||||
recipe_id=recipe.id,
|
||||
version_number=1,
|
||||
is_current=True,
|
||||
created_by=user_id,
|
||||
change_notes="Ricetta di collaudo",
|
||||
)
|
||||
session.add(version)
|
||||
await session.flush()
|
||||
return recipe, version
|
||||
|
||||
|
||||
async def _assign(session, station, recipe, user_id):
|
||||
session.add(StationRecipeAssignment(
|
||||
station_id=station.id, recipe_id=recipe.id, assigned_by=user_id,
|
||||
))
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def _ensure_supervisor(session, username, password):
|
||||
"""A capoturno with the role, not an admin standing in for one.
|
||||
|
||||
An admin is accepted by the authorisation check, but the acceptance session
|
||||
should exercise the role the shop floor will actually use.
|
||||
|
||||
`password` may be None: it is only needed when the account does not exist
|
||||
yet, in which case a random one is generated here rather than falling
|
||||
back to a committed default. Returns (user, created, password_used) so
|
||||
the caller can print the generated one - the only place it appears.
|
||||
"""
|
||||
existing = (await session.execute(
|
||||
select(User).where(User.username == username)
|
||||
)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing, False, password
|
||||
|
||||
password_used = password or secrets.token_urlsafe(12)
|
||||
user = await auth_service.create_user(
|
||||
session,
|
||||
username=username,
|
||||
password=password_used,
|
||||
display_name="Capo Turno (collaudo)",
|
||||
roles=["Supervisor", "MeasurementTec"],
|
||||
)
|
||||
await session.flush()
|
||||
return user, True, password_used
|
||||
|
||||
|
||||
async def seed(
|
||||
station_code: str, replace: bool, supervisor_password: str | None, si: bool,
|
||||
) -> int:
|
||||
async with async_session_factory() as session:
|
||||
owner = (await session.execute(
|
||||
select(User).where(User.is_admin == True) # noqa: E712
|
||||
)).scalars().first()
|
||||
if owner is None:
|
||||
print("Nessun utente amministratore: impossibile intestare le ricette.")
|
||||
return 2
|
||||
|
||||
station = (await session.execute(
|
||||
select(Station).where(Station.code == station_code)
|
||||
)).scalar_one_or_none()
|
||||
if station is None:
|
||||
print(f"Stazione '{station_code}' inesistente.")
|
||||
return 2
|
||||
|
||||
existing = await _find_recipes(session, CODES)
|
||||
if existing and not replace:
|
||||
found = ", ".join(r.code for r in existing)
|
||||
print(f"Ricette di collaudo gia' presenti ({found}).")
|
||||
print("Usa --replace per rifarle da zero (cancella anche le misure).")
|
||||
return 1
|
||||
if existing:
|
||||
impact = await _count_replace_impact(session, existing)
|
||||
print(f"--replace cancellera' ({', '.join(r.code for r in existing)}):")
|
||||
print(f" {impact['measurements']} misure")
|
||||
print(f" {impact['production_runs']} produzioni")
|
||||
print(f" {impact['production_events']} eventi di produzione")
|
||||
if not si:
|
||||
print("Nessuna cancellazione eseguita. Aggiungi --si per confermare.")
|
||||
return 1
|
||||
await _delete_recipes(session, existing)
|
||||
print(f"Rimosse: {', '.join(r.code for r in existing)}")
|
||||
|
||||
# ---- COLLAUDO-A: the one to drive ----
|
||||
recipe_a, version_a = await _create_recipe(
|
||||
session, owner.id,
|
||||
code="COLLAUDO-A",
|
||||
name="Collaudo — ciclo di misura",
|
||||
description=(
|
||||
"Due task di misura con task documentali intorno. "
|
||||
"Lotto obbligatorio, inserimento manuale consentito, "
|
||||
"intervallo di 2 minuti."
|
||||
),
|
||||
interval=2,
|
||||
requires_lot=True,
|
||||
requires_serial=False,
|
||||
allow_manual_input=True,
|
||||
)
|
||||
await _add_task(
|
||||
session, version_a.id, 0, "Preparazione del pezzo", "note",
|
||||
directive="Leggere prima di iniziare",
|
||||
description=NOTA_INIZIALE,
|
||||
)
|
||||
await _add_task(
|
||||
session, version_a.id, 1, "Misura 1 — diametro e spessore", "measure",
|
||||
directive="Misurare le due quote in sequenza",
|
||||
description="Quota **1** e quota **2**.\nUsare il calibro o il tastierino.",
|
||||
quotes=QUOTES_A1,
|
||||
)
|
||||
await _add_task(
|
||||
session, version_a.id, 2, "Riposizionamento", "note",
|
||||
directive="Fra la prima e la seconda misura",
|
||||
description=NOTA_INTERMEDIA,
|
||||
)
|
||||
await _add_task(
|
||||
session, version_a.id, 3, "Misura 2 — lunghezza", "measure",
|
||||
directive="Ultimo task di misura: chiude il ciclo",
|
||||
description="Alla conferma di questa quota **riparte l'intervallo**.",
|
||||
quotes=QUOTES_A2,
|
||||
)
|
||||
await _add_task(
|
||||
session, version_a.id, 4, "Chiusura", "note",
|
||||
directive="Dopo le misure",
|
||||
description=NOTA_FINALE,
|
||||
)
|
||||
await _assign(session, station, recipe_a, owner.id)
|
||||
|
||||
# ---- COLLAUDO-B: the one to look at ----
|
||||
recipe_b, version_b = await _create_recipe(
|
||||
session, owner.id,
|
||||
code="COLLAUDO-B",
|
||||
name="Collaudo — solo calibro",
|
||||
description=(
|
||||
"Lotto e seriale obbligatori, inserimento manuale vietato: "
|
||||
"il tastierino non compare e Avvia non parte finche' mancano i dati."
|
||||
),
|
||||
interval=5,
|
||||
requires_lot=True,
|
||||
requires_serial=True,
|
||||
allow_manual_input=False,
|
||||
)
|
||||
await _add_task(
|
||||
session, version_b.id, 0, "Misura con calibro", "measure",
|
||||
directive="Il valore deve arrivare dal calibro",
|
||||
description="Nessun tastierino: la ricetta **non ammette valori digitati**.",
|
||||
quotes=QUOTES_B,
|
||||
)
|
||||
await _assign(session, station, recipe_b, owner.id)
|
||||
|
||||
supervisor, created, password_used = await _ensure_supervisor(
|
||||
session, "capoturno", supervisor_password,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
print(f"Ricette create e assegnate alla stazione {station_code}:")
|
||||
print(" COLLAUDO-A ciclo di misura, lotto obbligatorio, intervallo 2 min")
|
||||
print(" COLLAUDO-B solo calibro, lotto e seriale obbligatori")
|
||||
if created:
|
||||
print(f"Capoturno creato: {supervisor.username} / {password_used}")
|
||||
print(" cambiare la password prima di qualunque uso reale.")
|
||||
else:
|
||||
print(f"Capoturno gia' presente: {supervisor.username} (password invariata)")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--station", default="ST-DEFAULT",
|
||||
help="codice della stazione a cui assegnare le ricette")
|
||||
parser.add_argument("--replace", action="store_true",
|
||||
help="cancella le ricette di collaudo esistenti e le misure fatte su di esse")
|
||||
parser.add_argument("--si", action="store_true",
|
||||
help="conferma la cancellazione di --replace (senza, si limita a mostrarla)")
|
||||
parser.add_argument("--supervisor-password", default=None,
|
||||
help="password del capoturno, se va creato; "
|
||||
"se omessa ne viene generata una casuale e stampata una volta")
|
||||
parser.add_argument("--conferma-produzione", action="store_true",
|
||||
help="richiesto per lanciare lo script con APP_ENV di produzione")
|
||||
args = parser.parse_args()
|
||||
|
||||
if settings.app_env not in ("dev", "test") and not args.conferma_produzione:
|
||||
print(
|
||||
f"APP_ENV={settings.app_env!r} indica una produzione: "
|
||||
"questo script scrive ricette e utenti di collaudo. "
|
||||
"Rilancia con --conferma-produzione se e' davvero quello che vuoi."
|
||||
)
|
||||
return 2
|
||||
|
||||
return asyncio.run(
|
||||
seed(args.station, args.replace, args.supervisor_password, args.si)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,458 @@
|
||||
"""Seed per il PC della fiera: utenti, stazione, tre ricette e quattro settimane di misure.
|
||||
|
||||
Cosa fa
|
||||
Popola un database TieMeasureFlow (SQLite del pacchetto Windows) cosi' che al primo
|
||||
avvio NON compaia la procedura di setup e la demo abbia gia' qualcosa da mostrare:
|
||||
|
||||
- utenti adriano (admin), maker, capoturno, operatore (password nella costante
|
||||
UTENTI qui sotto, decise da Adriano per la fiera);
|
||||
- la stazione ST-DEFAULT (quella del pacchetto, vedi packaging/windows/avvio.py)
|
||||
con camera_url http://127.0.0.1:8765 e camera_source camera:601020 (Itala);
|
||||
- FIERA-FLANGIA «Flangia Ø80» (5 quote) e FIERA-ALBERO «Albero rettificato»
|
||||
(4 quote), con circa 4 settimane di storico fino a ieri (lun-ven 8-17, una
|
||||
produzione chiusa per giorno, lotti e seriali coerenti, misure con Cpk diversi
|
||||
per quota, una deriva lenta nell'ultima settimana, una quota con 1-3% fuori
|
||||
tolleranza);
|
||||
- FIERA-DXF «Profilo DXF (visione)»: un task dxf_compare senza ricetta di visione
|
||||
(si crea in fiera dal pannello «Crea ricetta da DXF»).
|
||||
|
||||
«Setup fatto» per il backend vuol dire: esiste almeno un utente admin
|
||||
(api/routers/setup.py::_admin_exists), quindi basta creare adriano.
|
||||
|
||||
Come si usa (da daitarn, TMF fermo sul PC, su una COPIA del file SQLite)
|
||||
uv run python scripts/seed_fiera.py --db C:/percorso/tmf.db [--replace] [--dry-run]
|
||||
[--fine AAAA-MM-GG]
|
||||
|
||||
Il file deve avere gia' lo schema (alembic upgrade head: lo fa l'exe al primo
|
||||
avvio). --replace toglie solo i dati FIERA-* (ricette, misure, produzioni) e li
|
||||
rifa'; senza, un secondo giro non duplica le ricette. Gli utenti e la stazione
|
||||
sono idempotenti: se esistono si aggiornano password, ruoli e camera.
|
||||
--dry-run fa tutto e annulla (stampa i numeri). Il seme casuale e' fisso: stesso
|
||||
comando e stessa data di fine, stessi dati.
|
||||
|
||||
Da cosa dipende
|
||||
src.backend (modelli ORM, auth_service, measurement_service), SQLAlchemy +
|
||||
aiosqlite, tzdata su Windows (gia' in pyproject). Non usa MySQL.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import random
|
||||
import sys
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import delete, event, select
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession, async_sessionmaker, create_async_engine,
|
||||
)
|
||||
|
||||
# La radice del repo nel path, cosi' "src.backend" si importa lanciando lo script da ovunque
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from src.backend.database import async_session_factory # noqa: E402
|
||||
from src.backend.models.orm.measurement import Measurement # noqa: E402
|
||||
from src.backend.models.orm.production import ( # noqa: E402
|
||||
ProductionEvent, ProductionRun,
|
||||
)
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: E402
|
||||
from src.backend.models.orm.setting import RecipeVersionAudit # noqa: E402
|
||||
from src.backend.models.orm.station import ( # noqa: E402
|
||||
Station, StationRecipeAssignment,
|
||||
)
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask # noqa: E402
|
||||
from src.backend.models.orm.task_result import TaskResult # noqa: E402
|
||||
from src.backend.models.orm.user import User # noqa: E402
|
||||
from src.backend.services import auth_service # noqa: E402
|
||||
from src.backend.services.measurement_service import calculate_pass_fail # noqa: E402
|
||||
|
||||
CODES = ("FIERA-FLANGIA", "FIERA-ALBERO", "FIERA-DXF")
|
||||
STATION_CODE = "ST-DEFAULT" # STATION_CODE del pacchetto Windows (avvio.py PREDEFINITI)
|
||||
CAMERA_URL = "http://127.0.0.1:8765"
|
||||
CAMERA_SOURCE = "camera:601020" # Itala sul PC fiera
|
||||
SEED = 20261026 # seme fisso: dati ripetibili
|
||||
FUSO = ZoneInfo("Europe/Rome") # le misure sono salvate in UTC naive, l'orario di turno e' locale
|
||||
GIORNI_STORICO = 28 # circa 4 settimane
|
||||
|
||||
# (username, password, nome, ruoli, admin). Password "123" per tutti, chiesta da Adriano per la fiera (#85); il login non controlla la lunghezza.
|
||||
UTENTI = [
|
||||
("adriano", "123", "Adriano Dal Pastro",
|
||||
["Maker", "MeasurementTec", "Metrologist", "Supervisor"], True),
|
||||
("maker", "123", "Marco Ferrari (Maker)", ["Maker"], False),
|
||||
("capoturno", "123", "Luca Bianchi (Capoturno)",
|
||||
["Supervisor", "MeasurementTec"], False),
|
||||
("operatore", "123", "Giulia Rossi (Operatore)", ["MeasurementTec"], False),
|
||||
]
|
||||
|
||||
|
||||
def _quota(marker, descrizione, nominale, tol, unita="mm", dec=3):
|
||||
"""Quota simmetrica: tolleranza = nominale +/- tol, warning al 70% della tolleranza."""
|
||||
return dict(
|
||||
marker_number=marker, description=descrizione, nominal=nominale,
|
||||
ltl=round(nominale - tol, dec), lwl=round(nominale - 0.7 * tol, dec),
|
||||
uwl=round(nominale + 0.7 * tol, dec), utl=round(nominale + tol, dec),
|
||||
unit=unita,
|
||||
)
|
||||
|
||||
|
||||
# Profilo statistico di una quota: scostamento medio (in frazioni di tol), sigma (in
|
||||
# frazioni di tol) e deriva (frazione di tol aggiunta linearmente nell'ultima settimana).
|
||||
# Cpk ~ (1 - |scostamento|) / (3 * sigma): sigma 0.2 -> 1,67; 0.33 -> 1,0.
|
||||
FLANGIA = [
|
||||
# (task, quota, scostamento, sigma, deriva)
|
||||
(0, _quota(1, "Diametro esterno Ø80", 80.0, 0.05), 0.0, 0.20, 0.0), # Cpk ~1,67 capace
|
||||
(0, _quota(2, "Diametro foro centrale Ø25", 25.0, 0.02), 0.0, 0.33, 0.0), # Cpk ~1,0 al limite
|
||||
(1, _quota(3, "Spessore flangia", 12.0, 0.10), 0.0, 0.22, 0.55), # deriva ultima settimana
|
||||
(1, _quota(4, "Interasse fori Ø60", 60.0, 0.10), 0.10, 0.25, 0.0), # Cpk ~1,2
|
||||
(1, _quota(5, "Profondita' lamatura", 8.0, 0.15), 0.50, 0.244, 0.0), # ~2% fuori tolleranza
|
||||
]
|
||||
ALBERO = [
|
||||
(0, _quota(1, "Diametro cuscinetto Ø20 h6", 20.0, 0.010), 0.0, 0.20, 0.0), # Cpk ~1,67
|
||||
(0, _quota(2, "Diametro sede Ø25 j5", 25.0, 0.008), 0.0, 0.25, 0.0), # Cpk ~1,33
|
||||
(1, _quota(3, "Lunghezza totale", 150.0, 0.10), 0.0, 0.33, 0.0), # Cpk ~1,0
|
||||
(1, _quota(4, "Rugosita' Ra", 0.40, 0.20, unita="µm"), 0.20, 0.25, 0.0), # Cpk ~1,07
|
||||
]
|
||||
|
||||
|
||||
def _ora_utc(giorno: date, minuti_da_mezzanotte: int) -> datetime:
|
||||
"""Ora locale di officina -> datetime UTC naive, come le salva il backend."""
|
||||
locale = datetime.combine(giorno, time(0, 0), tzinfo=FUSO) + timedelta(minutes=minuti_da_mezzanotte)
|
||||
return locale.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _giorni_lavorativi(fine: date) -> list[date]:
|
||||
"""Lun-ven degli ultimi GIORNI_STORICO giorni, fino a `fine` compreso."""
|
||||
giorni = [fine - timedelta(days=i) for i in range(GIORNI_STORICO)]
|
||||
return sorted(g for g in giorni if g.weekday() < 5)
|
||||
|
||||
|
||||
def _slot_giornalieri(rng: random.Random, n: int) -> list[int]:
|
||||
"""n orari (minuti dalla mezzanotte) in 8-12 e 13-17, ogni ~30 min con un po' di scarto."""
|
||||
mezz_ore = [8 * 60 + 30 * i for i in range(8)] + [13 * 60 + 30 * i for i in range(8)]
|
||||
scelti = sorted(rng.sample(mezz_ore, n))
|
||||
return [m + rng.randint(0, 9) for m in scelti]
|
||||
|
||||
|
||||
async def _upsert_utenti(session: AsyncSession) -> dict[str, User]:
|
||||
"""Crea gli utenti o, se esistono, ne aggiorna password, nome, ruoli e flag admin."""
|
||||
utenti = {}
|
||||
for username, password, nome, ruoli, admin in UTENTI:
|
||||
esistente = (await session.execute(
|
||||
select(User).where(User.username == username)
|
||||
)).scalar_one_or_none()
|
||||
if esistente is None:
|
||||
utenti[username] = await auth_service.create_user(
|
||||
session, username=username, password=password, display_name=nome,
|
||||
roles=ruoli, is_admin=admin,
|
||||
)
|
||||
else:
|
||||
esistente.password_hash = auth_service.hash_password(password)
|
||||
esistente.display_name = nome
|
||||
esistente.roles = ruoli
|
||||
esistente.is_admin = admin
|
||||
esistente.active = True
|
||||
utenti[username] = esistente
|
||||
await session.flush()
|
||||
return utenti
|
||||
|
||||
|
||||
async def _upsert_stazione(session: AsyncSession, owner: User) -> Station:
|
||||
"""La stazione del pacchetto, con la camera Itala; se c'e' gia' ne aggiorna la camera."""
|
||||
stazione = (await session.execute(
|
||||
select(Station).where(Station.code == STATION_CODE)
|
||||
)).scalar_one_or_none()
|
||||
if stazione is None:
|
||||
stazione = Station(
|
||||
code=STATION_CODE, name="Postazione fiera", location="Stand",
|
||||
created_by=owner.id,
|
||||
)
|
||||
session.add(stazione)
|
||||
stazione.camera_url = CAMERA_URL
|
||||
stazione.camera_source = CAMERA_SOURCE
|
||||
await session.flush()
|
||||
return stazione
|
||||
|
||||
|
||||
async def _conta_e_cancella(session: AsyncSession, ricette: list[Recipe]) -> dict:
|
||||
"""Toglie le ricette FIERA-* e tutto cio' che e' misurato contro di esse. Ritorna i conteggi."""
|
||||
conti = {"misure": 0, "produzioni": 0, "eventi": 0}
|
||||
for ricetta in ricette:
|
||||
versioni = (await session.execute(
|
||||
select(RecipeVersion.id).where(RecipeVersion.recipe_id == ricetta.id)
|
||||
)).scalars().all()
|
||||
runs = (await session.execute(
|
||||
select(ProductionRun.id).where(ProductionRun.recipe_id == ricetta.id)
|
||||
)).scalars().all()
|
||||
if versioni:
|
||||
conti["misure"] += (await session.execute(
|
||||
delete(Measurement).where(Measurement.version_id.in_(versioni))
|
||||
)).rowcount
|
||||
await session.execute( # esiti dei task di queste versioni (FK sulle versioni)
|
||||
delete(TaskResult).where(TaskResult.version_id.in_(versioni))
|
||||
)
|
||||
if runs:
|
||||
conti["eventi"] += (await session.execute(
|
||||
delete(ProductionEvent).where(ProductionEvent.run_id.in_(runs))
|
||||
)).rowcount
|
||||
conti["produzioni"] += (await session.execute(
|
||||
delete(ProductionRun).where(ProductionRun.id.in_(runs))
|
||||
)).rowcount
|
||||
await session.execute(
|
||||
delete(StationRecipeAssignment).where(StationRecipeAssignment.recipe_id == ricetta.id)
|
||||
)
|
||||
await session.execute(
|
||||
delete(RecipeVersionAudit).where(RecipeVersionAudit.recipe_id == ricetta.id)
|
||||
)
|
||||
# Versioni, task e quote se ne vanno con la cascade ORM della ricetta.
|
||||
await session.delete(ricetta)
|
||||
await session.flush()
|
||||
return conti
|
||||
|
||||
|
||||
async def _nuova_ricetta(session, owner, stazione, *, code, name, description,
|
||||
interval, requires_serial, note_versione):
|
||||
"""Ricetta + versione 1 corrente + assegnazione alla stazione."""
|
||||
ricetta = Recipe(
|
||||
code=code, name=name, description=description, created_by=owner.id, active=True,
|
||||
measurement_interval_minutes=interval, requires_lot=True,
|
||||
requires_serial=requires_serial, allow_manual_input=True,
|
||||
)
|
||||
session.add(ricetta)
|
||||
await session.flush()
|
||||
versione = RecipeVersion(
|
||||
recipe_id=ricetta.id, version_number=1, is_current=True,
|
||||
created_by=owner.id, change_notes=note_versione,
|
||||
)
|
||||
session.add(versione)
|
||||
session.add(StationRecipeAssignment(
|
||||
station_id=stazione.id, recipe_id=ricetta.id, assigned_by=owner.id,
|
||||
))
|
||||
await session.flush()
|
||||
return ricetta, versione
|
||||
|
||||
|
||||
async def _task(session, versione, ordine, titolo, tipo, direttiva=None,
|
||||
descrizione=None, quote=()):
|
||||
task = RecipeTask(
|
||||
version_id=versione.id, order_index=ordine, title=titolo, task_type=tipo,
|
||||
directive=direttiva, description=descrizione,
|
||||
)
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
sottotask = []
|
||||
for q in quote:
|
||||
st = RecipeSubtask(task_id=task.id, **q)
|
||||
session.add(st)
|
||||
sottotask.append(st)
|
||||
await session.flush()
|
||||
return task, sottotask
|
||||
|
||||
|
||||
async def _ricetta_meccanica(session, owner, stazione, *, code, name, description,
|
||||
interval, requires_serial, profilo, titoli_task):
|
||||
"""Ricetta con una nota iniziale e un task di misura per ogni gruppo di quote del profilo."""
|
||||
ricetta, versione = await _nuova_ricetta(
|
||||
session, owner, stazione, code=code, name=name, description=description,
|
||||
interval=interval, requires_serial=requires_serial,
|
||||
note_versione="Ricetta dimostrativa per la fiera",
|
||||
)
|
||||
await _task(session, versione, 0, "Preparazione del pezzo", "note",
|
||||
direttiva="Prima di misurare",
|
||||
descrizione="Pulire il pezzo e controllare che sia a **temperatura ambiente**.")
|
||||
sottotask_per_quota = {}
|
||||
for indice, titolo in enumerate(titoli_task):
|
||||
quote = [q for (t, q, *_r) in profilo if t == indice]
|
||||
_t, sottotask = await _task(
|
||||
session, versione, indice + 1, titolo, "measure",
|
||||
direttiva="Misurare le quote in sequenza", quote=quote,
|
||||
)
|
||||
for st in sottotask:
|
||||
sottotask_per_quota[st.marker_number] = st
|
||||
return ricetta, versione, sottotask_per_quota
|
||||
|
||||
|
||||
def _valore(rng, quota, scost, sigma, deriva, frazione_tempo):
|
||||
"""Un valore: normale attorno a nominale + scostamento (+ deriva nell'ultima settimana)."""
|
||||
tol = quota["utl"] - quota["nominal"]
|
||||
media = quota["nominal"] + tol * (scost + deriva * max(0.0, (frazione_tempo - 0.75) / 0.25))
|
||||
return round(rng.gauss(media, tol * sigma), 6)
|
||||
|
||||
|
||||
async def _storico(session, rng, fine, owner_ricetta, stazione, utenti, ricetta, versione,
|
||||
sottotask, profilo, pezzi_giorno, prefisso_lotto, prefisso_seriale):
|
||||
"""Una produzione chiusa per giorno lavorativo, con i suoi pezzi, eventi e misure."""
|
||||
giorni = _giorni_lavorativi(fine)
|
||||
operatore, capoturno = utenti["operatore"], utenti["capoturno"]
|
||||
task_ids = {} # marker -> id del task di misura
|
||||
for marker, st in sottotask.items():
|
||||
task_ids[marker] = st.task_id
|
||||
ordine_task = sorted(set(task_ids.values()))
|
||||
ultimo_task = ordine_task[-1]
|
||||
contatore = 0
|
||||
totale = 0
|
||||
for indice_giorno, giorno in enumerate(giorni):
|
||||
frazione = indice_giorno / max(1, len(giorni) - 1) # 0 = primo giorno, 1 = ieri
|
||||
n = rng.randint(*pezzi_giorno)
|
||||
slot = _slot_giornalieri(rng, n)
|
||||
lotto = f"{prefisso_lotto}{giorno:%y%m%d}"
|
||||
inizio, fine_run = _ora_utc(giorno, slot[0] - 5), _ora_utc(giorno, slot[-1] + 10)
|
||||
run = ProductionRun(
|
||||
station_id=stazione.id, recipe_id=ricetta.id, version_id=versione.id,
|
||||
operator_id=operatore.id, lot_number=lotto, status="closed",
|
||||
measurement_interval_minutes=ricetta.measurement_interval_minutes,
|
||||
cycle_count=n, started_at=inizio, closed_at=fine_run, closed_by=capoturno.id,
|
||||
active_station_id=None,
|
||||
)
|
||||
session.add(run)
|
||||
await session.flush()
|
||||
session.add(ProductionEvent(run_id=run.id, event_type="start", user_id=operatore.id,
|
||||
created_at=inizio))
|
||||
for numero, minuti in enumerate(slot, start=1):
|
||||
contatore += 1
|
||||
seriale = f"{prefisso_seriale}{contatore:05d}"
|
||||
istante = _ora_utc(giorno, minuti)
|
||||
for marker in sorted(sottotask):
|
||||
st = sottotask[marker]
|
||||
_t, quota, scost, sigma, deriva = next(p for p in profilo if p[1]["marker_number"] == marker)
|
||||
valore = _valore(rng, quota, scost, sigma, deriva, frazione)
|
||||
esito, scarto = calculate_pass_fail(valore, st)
|
||||
m = Measurement(
|
||||
subtask_id=st.id, version_id=versione.id, measured_by=operatore.id,
|
||||
value=valore, pass_fail=esito, deviation=scarto, lot_number=lotto,
|
||||
serial_number=seriale,
|
||||
input_method="usb_caliper" if rng.random() < 0.75 else "manual",
|
||||
input_duration_ms=rng.randint(2500, 9000),
|
||||
production_run_id=run.id,
|
||||
# un fuori tolleranza passa solo con l'ok del capoturno (come in produzione)
|
||||
supervisor_id=capoturno.id if esito == "fail" else None,
|
||||
authorised_at=istante + timedelta(minutes=1) if esito == "fail" else None,
|
||||
measured_at=istante + timedelta(seconds=20 * marker),
|
||||
)
|
||||
session.add(m)
|
||||
totale += 1
|
||||
# un task_measured per ogni task di misura non finale, poi il ciclo completato
|
||||
for tid in ordine_task:
|
||||
tipo = "cycle_completed" if tid == ultimo_task else "task_measured"
|
||||
session.add(ProductionEvent(
|
||||
run_id=run.id, event_type=tipo, user_id=operatore.id, task_id=tid,
|
||||
created_at=istante + timedelta(minutes=1),
|
||||
))
|
||||
session.add(ProductionEvent(run_id=run.id, event_type="close", user_id=capoturno.id,
|
||||
supervisor_id=capoturno.id, created_at=fine_run))
|
||||
await session.flush()
|
||||
return contatore, totale
|
||||
|
||||
|
||||
async def _ricetta_dxf(session, owner, stazione):
|
||||
"""Demo visione: un task dxf_compare, senza grafo (si crea in fiera dal pannello DXF)."""
|
||||
ricetta, versione = await _nuova_ricetta(
|
||||
session, owner, stazione, code="FIERA-DXF", name="Profilo DXF (visione)",
|
||||
description="Confronto di un profilo con il disegno DXF tramite la camera. "
|
||||
"La ricetta di visione si crea in fiera dal pannello «Crea ricetta da DXF».",
|
||||
interval=None, requires_serial=False, note_versione="Ricetta dimostrativa per la fiera",
|
||||
)
|
||||
await _task(session, versione, 0, "Preparazione del pezzo", "note",
|
||||
direttiva="Prima di misurare",
|
||||
descrizione="Appoggiare il pezzo sul piano della camera, **in piano**.")
|
||||
await _task(
|
||||
session, versione, 1, "Confronto profilo con DXF", "dxf_compare",
|
||||
direttiva="Acquisire e confrontare",
|
||||
descrizione="Lo scostamento massimo del profilo rispetto al DXF deve restare entro la tolleranza.",
|
||||
quote=[_quota(1, "Scostamento massimo profilo", 0.0, 0.15)],
|
||||
)
|
||||
return ricetta
|
||||
|
||||
|
||||
async def seed(replace: bool, dry_run: bool, fine: date) -> int:
|
||||
"""Esegue il seed in una sola transazione; con dry_run annulla tutto alla fine."""
|
||||
rng = random.Random(SEED)
|
||||
async with async_session_factory() as session:
|
||||
utenti = await _upsert_utenti(session)
|
||||
owner = utenti["adriano"]
|
||||
stazione = await _upsert_stazione(session, owner)
|
||||
|
||||
esistenti = list((await session.execute(
|
||||
select(Recipe).where(Recipe.code.in_(CODES))
|
||||
)).scalars().all())
|
||||
if esistenti and not replace:
|
||||
print(f"Ricette FIERA gia' presenti ({', '.join(r.code for r in esistenti)}): "
|
||||
"non duplico. Usa --replace per rifarle.")
|
||||
else:
|
||||
if esistenti:
|
||||
conti = await _conta_e_cancella(session, esistenti)
|
||||
print(f"--replace: rimosse {', '.join(r.code for r in esistenti)} "
|
||||
f"({conti['misure']} misure, {conti['produzioni']} produzioni, "
|
||||
f"{conti['eventi']} eventi)")
|
||||
|
||||
r1, v1, sub1 = await _ricetta_meccanica(
|
||||
session, owner, stazione, code="FIERA-FLANGIA", name="Flangia Ø80",
|
||||
description="Flangia tornita in alluminio: diametri, spessore, interasse fori "
|
||||
"e profondita' della lamatura. Lotto e seriale obbligatori.",
|
||||
interval=30, requires_serial=True, profilo=FLANGIA,
|
||||
titoli_task=["Misure diametrali", "Spessore e posizione fori"],
|
||||
)
|
||||
n1, m1 = await _storico(session, rng, fine, owner, stazione, utenti, r1, v1, sub1,
|
||||
FLANGIA, (10, 14), "LF", "FL")
|
||||
r2, v2, sub2 = await _ricetta_meccanica(
|
||||
session, owner, stazione, code="FIERA-ALBERO", name="Albero rettificato",
|
||||
description="Albero in acciaio rettificato: sedi dei cuscinetti, lunghezza e "
|
||||
"rugosita'. Tolleranze strette.",
|
||||
interval=30, requires_serial=False, profilo=ALBERO,
|
||||
titoli_task=["Diametri rettificati", "Lunghezza e rugosita'"],
|
||||
)
|
||||
n2, m2 = await _storico(session, rng, fine, owner, stazione, utenti, r2, v2, sub2,
|
||||
ALBERO, (8, 11), "LA", "AL")
|
||||
await _ricetta_dxf(session, owner, stazione)
|
||||
print(f"FIERA-FLANGIA: {n1} pezzi, {m1} misure; FIERA-ALBERO: {n2} pezzi, {m2} misure")
|
||||
|
||||
if dry_run:
|
||||
await session.rollback()
|
||||
print("--dry-run: nessuna modifica salvata.")
|
||||
else:
|
||||
await session.commit()
|
||||
print(f"Seed fiera completato (stazione {stazione.code}, utenti: "
|
||||
f"{', '.join(u[0] for u in UTENTI)}).")
|
||||
return 0
|
||||
|
||||
|
||||
def _factory_per_file(db: Path):
|
||||
"""Sessioni async su un file SQLite qualsiasi, con le stesse PRAGMA dell'app (FK attive)."""
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{db.resolve().as_posix()}", echo=False)
|
||||
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def _pragma(dbapi_connection, _record) -> None:
|
||||
cur = dbapi_connection.cursor()
|
||||
cur.execute("PRAGMA foreign_keys=ON")
|
||||
cur.execute("PRAGMA busy_timeout=5000")
|
||||
cur.close()
|
||||
|
||||
return engine, async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def _main_async(args) -> int:
|
||||
global async_session_factory
|
||||
engine, async_session_factory = _factory_per_file(args.db)
|
||||
try:
|
||||
fine = date.fromisoformat(args.fine) if args.fine else date.today() - timedelta(days=1)
|
||||
return await seed(args.replace, args.dry_run, fine)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--db", required=True, type=Path, help="file SQLite con lo schema gia' creato")
|
||||
parser.add_argument("--replace", action="store_true",
|
||||
help="rimuove i dati FIERA-* (ricette, misure, produzioni) e li rifa'")
|
||||
parser.add_argument("--dry-run", action="store_true", help="esegue tutto e annulla")
|
||||
parser.add_argument("--fine", default=None, help="ultimo giorno dello storico AAAA-MM-GG (default: ieri)")
|
||||
args = parser.parse_args()
|
||||
if not args.db.is_file():
|
||||
print(f"File non trovato: {args.db}")
|
||||
return 2
|
||||
return asyncio.run(_main_async(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Test di #94: «Blocca immagine» (scripts/camera_service.py) e riquadro vivo nella tab Calibrazione (PAGINA).
|
||||
|
||||
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||
Sorgente finta che conta i grab: niente camera vera, niente ProgramData.
|
||||
"""
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from test_calibrazione import _png, cartella, client, una_scacchiera # fixture e helper della calibrazione
|
||||
from test_immagini_affiancate import _anteprima, _carica, _modulo
|
||||
from test_pagina_acquisizione import PAGINA
|
||||
|
||||
__all__ = ["cartella", "client"] # le fixture importate restano usate
|
||||
|
||||
FINTA = "finta"
|
||||
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def finta(client, monkeypatch):
|
||||
"""Sorgente «finta»: il primo grab da' la scacchiera, i successivi un frame bianco (non calibrabile)."""
|
||||
letture = []
|
||||
scacchiera = una_scacchiera()
|
||||
bianco = np.full(scacchiera.shape, 255, np.uint8)
|
||||
|
||||
class Finta:
|
||||
def grab(self, timeout=1000):
|
||||
letture.append(1)
|
||||
return scacchiera.copy() if len(letture) == 1 else bianco.copy()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setitem(_modulo(client), "open_source", lambda *a, **k: Finta())
|
||||
client.letture = letture
|
||||
return client
|
||||
|
||||
|
||||
def _blocca(c, nome=FINTA):
|
||||
return c.post("/camera/blocca", json={"source": nome})
|
||||
|
||||
|
||||
def _calcola(c, **extra):
|
||||
return c.post("/camera/calibrate", data={"source": FINTA, **CAMPI, **extra})
|
||||
|
||||
|
||||
# ---- servizio
|
||||
|
||||
def test_blocca_senza_frame_404(finta):
|
||||
r = _blocca(finta)
|
||||
assert r.status_code == 404 and r.json()["detail"] == "nessun frame: avviare l'anteprima o scattare"
|
||||
|
||||
|
||||
def test_blocca_senza_source_400(finta):
|
||||
assert finta.post("/camera/blocca", json={}).status_code == 400
|
||||
assert finta.post("/camera/blocca").status_code == 400
|
||||
|
||||
|
||||
def test_anteprima_poi_blocca_200_con_misure(finta):
|
||||
assert _anteprima(finta, FINTA).status_code == 200
|
||||
r = _blocca(finta)
|
||||
alt, larg = una_scacchiera().shape[:2]
|
||||
assert r.status_code == 200 and r.json() == {"source": FINTA, "larghezza": larg, "altezza": alt}
|
||||
|
||||
|
||||
def test_calibrate_bloccata_usa_la_copia_senza_grab(finta):
|
||||
assert _anteprima(finta, FINTA).status_code == 200 # frame 1: la scacchiera
|
||||
assert _blocca(finta).status_code == 200
|
||||
assert _anteprima(finta, FINTA).status_code == 200 # frame 2: bianco, il grezzo cambia ma la copia no
|
||||
prima = len(finta.letture)
|
||||
r = _calcola(finta, bloccata="true")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["bloccata"] is True and r.json()["origine"] == FINTA
|
||||
assert len(finta.letture) == prima # nessun grab per il calcolo
|
||||
# senza il blocco il calcolo legge un frame nuovo (bianco): non calibrabile
|
||||
assert _calcola(finta).status_code == 422
|
||||
assert len(finta.letture) == prima + 1
|
||||
|
||||
|
||||
def test_calibrate_normale_dice_bloccata_false(client):
|
||||
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["bloccata"] is False
|
||||
|
||||
|
||||
def test_calibrate_bloccata_senza_copia_409(finta):
|
||||
assert _anteprima(finta, FINTA).status_code == 200 # il grezzo c'e', ma non e' bloccato
|
||||
r = _calcola(finta, bloccata="true")
|
||||
assert r.status_code == 409 and r.json()["detail"] == "nessuna immagine bloccata su questa sorgente"
|
||||
|
||||
|
||||
def test_sblocca_204_idempotente_e_toglie_la_copia(finta):
|
||||
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204 # niente da togliere
|
||||
assert _anteprima(finta, FINTA).status_code == 200
|
||||
assert _blocca(finta).status_code == 200
|
||||
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
|
||||
assert _calcola(finta, bloccata="true").status_code == 409
|
||||
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
|
||||
|
||||
|
||||
def test_rimuovere_l_immagine_toglie_la_copia_bloccata(client):
|
||||
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
|
||||
assert _anteprima(client, "foto.png").status_code == 200
|
||||
assert _blocca(client, "foto.png").status_code == 200
|
||||
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
|
||||
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
|
||||
r = client.post("/camera/calibrate", data={"source": "foto.png", "bloccata": "true", **CAMPI})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
# ---- pagina (contenuto statico)
|
||||
|
||||
def test_pagina_riquadro_e_casella_nella_tab_calibrazione():
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
assert {"immagineCalTab", "vuotoCalTab", "scattaCal", "bloccaImg", "msgBlocco"} <= ids
|
||||
assert "Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione" in PAGINA
|
||||
assert "Blocca immagine" in PAGINA
|
||||
tab = PAGINA.split('id="pannelloCal"')[1].split('id="sezioneSalvate"')[0]
|
||||
assert "immagineCalTab" in tab and "bloccaImg" in tab # nella sezione Calibrazione, non altrove
|
||||
|
||||
|
||||
def test_pagina_scegli_tab_non_ferma_piu_il_ciclo():
|
||||
assert "A.riprendi" not in PAGINA
|
||||
scegli = PAGINA.split("function scegliTab")[1].split("$(\"tabAcq\").onclick")[0]
|
||||
assert "fermaAnteprima" not in scegli and "avviaAnteprima" not in scegli
|
||||
|
||||
|
||||
def test_pagina_calcola_manda_bloccata_e_non_ferma_il_ciclo():
|
||||
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
|
||||
assert 'f.append("bloccata", "true")' in calcola
|
||||
assert "if (bloccata)" in calcola and "else fermaAnteprima();" in calcola
|
||||
assert "immagine bloccata" in calcola
|
||||
assert "l'anteprima resta sospesa" not in PAGINA and "si può bloccare l'immagine" in PAGINA
|
||||
|
||||
|
||||
def test_pagina_blocco_rotte_e_cambio_sorgente():
|
||||
assert '"/camera/blocca"' in PAGINA and '"/camera/blocca?source="' in PAGINA
|
||||
assert "method: \"DELETE\"" in PAGINA
|
||||
cambio = PAGINA.split("function cambioSorgente")[1].split("// ---- Anteprima")[0]
|
||||
assert "rilasciaBlocco()" in cambio
|
||||
assert cambio.index("rilasciaBlocco()") < cambio.index("svuotaRiquadri()")
|
||||
|
||||
|
||||
def test_pagina_blocco_attende_l_anteprima_in_volo():
|
||||
# #94: il frame bloccato e' quello mostrato: flag A.bloccando, attesa di A.inVolo (20 ms, max 3 s), poi POST, flag tolto in finally
|
||||
blocco = PAGINA.split('$("bloccaImg").onchange')[1].split('$("avvia").onclick')[0]
|
||||
assert "A.bloccando++" in blocco and "A.bloccando--" in blocco and "finally" in blocco
|
||||
assert "k < 150" in blocco and "pausa(20)" in blocco
|
||||
assert blocco.index("A.inVolo") < blocco.index('fetch("/camera/blocca"')
|
||||
ciclo = PAGINA.split("async function ciclo")[1].split("function avviaAnteprima")[0]
|
||||
assert "while (A.bloccando)" in ciclo and ciclo.index("A.bloccando") < ciclo.index("unaAnteprima(1280)")
|
||||
scatta = PAGINA.split("async function scatta")[1].split("// #94: «Blocca immagine»")[0]
|
||||
assert "while (A.bloccando)" in scatta and scatta.index("A.bloccando") < scatta.index("unaAnteprima(4096)")
|
||||
@@ -0,0 +1,422 @@
|
||||
"""Test della calibrazione dell'acquisitore (#83): logica pura e rotte di camera_service.
|
||||
|
||||
Uso: dalla radice, `uv run --python 3.13 --extra vision-worker --extra dev
|
||||
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q`
|
||||
(o il venv di VisionSuite come per test_camera_service.py).
|
||||
"""
|
||||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
MODULO = SCRIPTS / "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
|
||||
|
||||
|
||||
def una_scacchiera(square_px: int = 40, nx: int = 10, ny: int = 7, margin: int = 60) -> np.ndarray:
|
||||
"""nx x ny quadrati => (nx-1) x (ny-1) angoli interni, su fondo bianco."""
|
||||
img = np.full((ny * square_px + 2 * margin, nx * square_px + 2 * margin), 255, np.uint8)
|
||||
for j in range(ny):
|
||||
for i in range(nx):
|
||||
if (i + j) % 2 == 0:
|
||||
y, x = margin + j * square_px, margin + i * square_px
|
||||
img[y:y + square_px, x:x + square_px] = 0
|
||||
return img
|
||||
|
||||
|
||||
def una_scacchiera_densa(w: int = 640, h: int = 480, square_px: int = 40, k: float = 1e-8) -> np.ndarray:
|
||||
"""#84: scacchiera che riempie tutto il campo, con distorsione a barilotto (k sui px dal centro)."""
|
||||
m = 3 * square_px # la scacchiera continua oltre il campo: nessun bordo finto nell'immagine
|
||||
yy, xx = np.mgrid[0:h + 2 * m, 0:w + 2 * m]
|
||||
piana = (((xx // square_px) + (yy // square_px)) % 2 * 255).astype(np.uint8)
|
||||
cx, cy = w / 2.0, h / 2.0
|
||||
dx, dy = (xx[m:m + h, m:m + w] - m - cx).astype(np.float32), (yy[m:m + h, m:m + w] - m - cy).astype(np.float32)
|
||||
f = 1 + k * (dx * dx + dy * dy)
|
||||
img = cv2.remap(piana, cx + m + dx * f, cy + m + dy * f, cv2.INTER_LINEAR)
|
||||
return cv2.GaussianBlur(img, (3, 3), 0.8) # bordi non perfettamente netti, come in camera
|
||||
|
||||
|
||||
def residuo_reticolo(griglia: np.ndarray) -> float:
|
||||
"""Residuo massimo (px) della griglia (nr,nc,2) da un reticolo affine regolare."""
|
||||
nr, nc, _ = griglia.shape
|
||||
ii, jj = np.mgrid[0:nr, 0:nc]
|
||||
A = np.stack([np.ones(nr * nc), ii.ravel(), jj.ravel()], axis=1)
|
||||
P = griglia.reshape(-1, 2)
|
||||
coef, *_ = np.linalg.lstsq(A, P, rcond=None)
|
||||
return float(np.hypot(*(A @ coef - P).T).max())
|
||||
|
||||
|
||||
def _png(img: np.ndarray) -> bytes:
|
||||
ok, buf = cv2.imencode(".png", img)
|
||||
assert ok
|
||||
return buf.tobytes()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cartella(tmp_path):
|
||||
# la sorgente camera:folder fa il loop su una scacchiera: il grab la trova
|
||||
cartella = tmp_path / "immagini"
|
||||
cartella.mkdir()
|
||||
cv2.imwrite(str(cartella / "scacchiera.png"), una_scacchiera())
|
||||
return cartella
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(cartella, tmp_path):
|
||||
mod = _carica()
|
||||
with TestClient(mod.create_app(cartella, config_dir=tmp_path / "config")) as c:
|
||||
c.config = tmp_path / "config"
|
||||
yield c
|
||||
|
||||
|
||||
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
|
||||
CHIAVI_C1 = {"px_per_mm", "mm_per_px", "rms_px", "n_corners", "cols", "rows",
|
||||
"square_mm", "image_width", "image_height", "scale_spread_pct", "calibrated_at"}
|
||||
# #84: campi in piu' per metodo planare (C2 con cols/rows) e per la densa
|
||||
CHIAVI_PLANARE = CHIAVI_C1 | {"metodo", "distorsione", "origine"} # #91: + origine ("camera:X" | "file:Y")
|
||||
CHIAVI_DENSA = CHIAVI_PLANARE | {"residuo_rms_px", "residuo_max_px", "copertura_pct"}
|
||||
SORGENTE = "camera:folder"
|
||||
# #91: GET /camera/calibration = voce di libreria + "nome" + "source" (la sorgente che la usa)
|
||||
CHIAVI_ATTIVA = {"source", "nome"}
|
||||
|
||||
|
||||
def salva_con_nome(client, calcolo: dict, nome: str, **extra):
|
||||
"""#91: POST /camera/calibrazioni sul calcolo_id di una risposta di /camera/calibrate."""
|
||||
return client.post("/camera/calibrazioni", json={"calcolo_id": calcolo["calcolo_id"], "nome": nome, **extra})
|
||||
|
||||
|
||||
def carica_su(client, sorgente: str, nome: str):
|
||||
"""#91: PUT /camera/calibrazione-attiva."""
|
||||
return client.put("/camera/calibrazione-attiva", json={"source": sorgente, "nome": nome})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cartella_densa(tmp_path):
|
||||
# #84: la sorgente camera:folder fa il loop su una scacchiera densa distorta
|
||||
cartella = tmp_path / "immagini_dense"
|
||||
cartella.mkdir()
|
||||
cv2.imwrite(str(cartella / "densa.png"), una_scacchiera_densa(k=1e-7))
|
||||
return cartella
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_denso(cartella_densa, tmp_path):
|
||||
mod = _carica()
|
||||
with TestClient(mod.create_app(cartella_densa, config_dir=tmp_path / "config_densa")) as c:
|
||||
c.config = tmp_path / "config_densa"
|
||||
yield c
|
||||
|
||||
|
||||
NON_IMMAGINE = b"questo non e una immagine"
|
||||
|
||||
|
||||
# ---- logica pura
|
||||
|
||||
def test_scacchiera_sintetica_da_la_scala():
|
||||
_carica() # mette scripts/ in sys.path
|
||||
import calibrazione
|
||||
r = calibrazione.calibra_scacchiera(una_scacchiera(), 9, 6, 2.0)
|
||||
assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||
assert r["mm_per_px"] == pytest.approx(0.05, rel=0.005)
|
||||
assert r["rms_px"] < 0.5
|
||||
assert r["n_corners"] == 54
|
||||
assert (r["image_width"], r["image_height"]) == (520, 400)
|
||||
assert r["scale_spread_pct"] < 1.0
|
||||
assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def test_immagine_bianca_solleva():
|
||||
_carica()
|
||||
import calibrazione
|
||||
with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"):
|
||||
calibrazione.calibra_scacchiera(np.full((300, 300), 255, np.uint8), 9, 6, 2.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cols,rows,quadrato", [(1, 6, 2.0), (9, 6, 0.0), (9, 6, -1.0)])
|
||||
def test_parametri_invalidi_sollevano(cols, rows, quadrato):
|
||||
_carica()
|
||||
import calibrazione
|
||||
with pytest.raises(calibrazione.CalibrazioneError):
|
||||
calibrazione.calibra_scacchiera(una_scacchiera(), cols, rows, quadrato)
|
||||
|
||||
|
||||
# ---- rotte
|
||||
|
||||
def test_c1_non_calibrata_404(client):
|
||||
r = client.get("/camera/calibration", params={"source": "camera:folder"})
|
||||
assert r.status_code == 404
|
||||
assert r.json()["detail"] == "sorgente non calibrata"
|
||||
|
||||
|
||||
def test_c1_senza_source_400(client):
|
||||
assert client.get("/camera/calibration").status_code == 400
|
||||
|
||||
|
||||
def test_c2_immagine_caricata_senza_salvare(client):
|
||||
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
|
||||
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||
assert r.status_code == 200, r.text
|
||||
corpo = r.json()
|
||||
assert CHIAVI_PLANARE | {"overlay_png", "calcolo_id", "bloccata"} == set(corpo) # #94: + bloccata; #91: niente "saved", niente "source"
|
||||
assert corpo["metodo"] == "planare" and corpo["distorsione"] is False # #84: con cols/rows nulla cambia
|
||||
assert corpo["origine"] == "file:s.png"
|
||||
assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||
# il calcolo non salva mai
|
||||
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
|
||||
assert not client.config.exists() or not list(client.config.iterdir())
|
||||
|
||||
|
||||
def test_c2_save_true_viene_ignorato(client):
|
||||
# #91: il vecchio campo save non salva piu' nulla
|
||||
r = client.post("/camera/calibrate", data={"source": "camera:folder", "save": "true", **CAMPI})
|
||||
assert r.status_code == 200, r.text
|
||||
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
|
||||
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
|
||||
assert not client.config.exists() or not list(client.config.iterdir())
|
||||
|
||||
|
||||
def test_c2_c1_c3_ciclo_con_grab_dalla_sorgente(client):
|
||||
# nessun file: grab da camera:folder (la scacchiera in cartella)
|
||||
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["origine"] == "camera:folder"
|
||||
assert salva_con_nome(client, r.json(), "Banco").status_code == 201 # #91: si salva con nome, dopo
|
||||
|
||||
# salvata ma non ancora caricata sulla sorgente
|
||||
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
|
||||
assert carica_su(client, "camera:folder", "Banco").status_code == 200
|
||||
|
||||
r1 = client.get("/camera/calibration", params={"source": "camera:folder"})
|
||||
assert r1.status_code == 200
|
||||
assert set(r1.json()) == CHIAVI_PLANARE | CHIAVI_ATTIVA # nessun overlay nel file salvato
|
||||
assert r1.json()["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||
assert r1.json()["source"] == "camera:folder" and r1.json()["nome"] == "Banco"
|
||||
file_salvati = list((client.config / "calibrazioni").glob("*.json"))
|
||||
assert len(file_salvati) == 1
|
||||
assert json.loads(file_salvati[0].read_text(encoding="utf-8"))["cols"] == 9
|
||||
|
||||
# DELETE /camera/calibration scollega: la libreria resta
|
||||
assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204
|
||||
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
|
||||
assert len(list((client.config / "calibrazioni").glob("*.json"))) == 1
|
||||
# scollegare di nuovo e' idempotente
|
||||
assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204
|
||||
|
||||
|
||||
def test_c2_scacchiera_non_trovata_422(client):
|
||||
bianca = np.full((300, 300), 255, np.uint8)
|
||||
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
|
||||
files={"image": ("b.png", _png(bianca), "image/png")})
|
||||
assert r.status_code == 422
|
||||
assert "non trovata" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_c2_parametri_invalidi_422(client):
|
||||
r = client.post("/camera/calibrate",
|
||||
data={"source": "camera:folder", "cols": "1", "rows": "6", "square_mm": "2"},
|
||||
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_c2_immagine_illeggibile_422(client):
|
||||
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
|
||||
files={"image": ("x.png", NON_IMMAGINE, "image/png")})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_c2_errori_camera_come_grab(client):
|
||||
r = client.post("/camera/calibrate", data={"source": "camera:non-esiste", **CAMPI})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_due_nomi_simili_hanno_file_diversi(client):
|
||||
# #91: i nomi si ripuliscono come prima (hash sul nome originale): due nomi diversi non si pestano
|
||||
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
|
||||
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||
assert r.status_code == 200
|
||||
for nome in ("banco a b", "banco a_b"):
|
||||
assert salva_con_nome(client, r.json(), nome).status_code == 201
|
||||
assert len(list((client.config / "calibrazioni").glob("*.json"))) == 2
|
||||
|
||||
|
||||
def test_c4_pagina_html(client):
|
||||
r = client.get("/calibrazione")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("text/html")
|
||||
assert "Acquisisci e calibra" in r.text
|
||||
assert "Lato dello scacco" in r.text and 'id="cols"' not in r.text # #84: un solo campo
|
||||
assert "http://" not in r.text and "https://" not in r.text # offline, niente CDN
|
||||
|
||||
|
||||
# ---- #84: calibrazione densa (logica pura)
|
||||
|
||||
def test_densa_trova_la_griglia_da_sola_e_la_scala():
|
||||
_carica()
|
||||
import calibrazione
|
||||
r, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(k=1e-8), 2.0)
|
||||
assert (r["cols"], r["rows"]) == (15, 11) # 640x480 con quadrati da 40 px
|
||||
assert r["n_corners"] == 15 * 11
|
||||
assert r["mm_per_px"] == pytest.approx(2.0 / 40, rel=0.005)
|
||||
assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||
assert r["metodo"] == "densa" and r["distorsione"] is True
|
||||
assert r["residuo_max_px"] < 0.3 and r["residuo_rms_px"] <= r["residuo_max_px"]
|
||||
assert 60 < r["copertura_pct"] <= 100
|
||||
assert (r["image_width"], r["image_height"]) == (640, 480)
|
||||
assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
assert mappa.shape == (480, 640) and mappa.pixel_size == pytest.approx(0.05, rel=0.005)
|
||||
|
||||
|
||||
def test_densa_raddrizza_su_griglia_regolare():
|
||||
_carica()
|
||||
import calibrazione
|
||||
img = una_scacchiera_densa(k=1e-7) # distorsione marcata: ai bordi ~5 %
|
||||
r, mappa = calibrazione.calibra_scacchiera_densa(img, 2.0)
|
||||
assert (r["cols"], r["rows"]) == (15, 11)
|
||||
# prima la griglia e' curva, dopo e' un reticolo regolare
|
||||
assert residuo_reticolo(calibrazione._trova_griglia(img)) > 1.0
|
||||
pronta = calibrazione._prepara_mappa(mappa)
|
||||
dritta = calibrazione.raddrizza(img, pronta)
|
||||
assert dritta.shape == img.shape
|
||||
assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cosa", ["bianca", "scacchiera_piccola"])
|
||||
def test_densa_senza_griglia_solleva(cosa):
|
||||
_carica()
|
||||
import calibrazione
|
||||
img = (np.full((300, 300), 255, np.uint8) if cosa == "bianca"
|
||||
else una_scacchiera_densa(80, 60, 20)) # 3x2 angoli: meno di 3x3
|
||||
with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"):
|
||||
calibrazione.calibra_scacchiera_densa(img, 2.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("quadrato", [0.0, -1.0, float("nan")])
|
||||
def test_densa_quadrato_invalido_solleva(quadrato):
|
||||
_carica()
|
||||
import calibrazione
|
||||
with pytest.raises(calibrazione.CalibrazioneError):
|
||||
calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), quadrato)
|
||||
|
||||
|
||||
def test_archivio_mappa_cache_e_invalidazione(tmp_path):
|
||||
_carica()
|
||||
import calibrazione
|
||||
_, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0)
|
||||
arch = calibrazione.Archivio(tmp_path)
|
||||
assert arch.leggi_mappa("s") is None
|
||||
arch.salva_nome("cal", {"a": 1}, mappa) # #91: salvataggio con nome + attivazione sulla sorgente
|
||||
arch.attiva("s", "cal")
|
||||
prima = arch.leggi_mappa("s")
|
||||
assert prima is not None and arch.leggi_mappa("s") is prima # cache in memoria
|
||||
assert not list(tmp_path.rglob("*.tmp*")) # niente temporanei rimasti
|
||||
arch.salva_nome("cal", {"a": 2}, mappa, sovrascrivi=True) # file riscritto -> cache invalidata
|
||||
assert arch.leggi_mappa("s") is not prima
|
||||
arch.salva_nome("cal", {"a": 3}, sovrascrivi=True) # salvataggio senza mappa (planare): la vecchia sparisce
|
||||
assert arch.leggi_mappa("s") is None and not list(tmp_path.rglob("*.npz"))
|
||||
|
||||
|
||||
# ---- #84: rotte con la calibrazione densa
|
||||
|
||||
def _grigio(r) -> np.ndarray:
|
||||
return cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_GRAYSCALE)
|
||||
|
||||
|
||||
def test_c2_senza_cols_rows_e_densa(client_denso):
|
||||
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
|
||||
assert r.status_code == 200, r.text
|
||||
corpo = r.json()
|
||||
assert CHIAVI_DENSA | {"overlay_png", "calcolo_id", "bloccata"} == set(corpo)
|
||||
assert corpo["metodo"] == "densa" and corpo["distorsione"] is True
|
||||
assert (corpo["cols"], corpo["rows"]) == (15, 11)
|
||||
assert corpo["px_per_mm"] == pytest.approx(1.0 / corpo["mm_per_px"])
|
||||
assert not client_denso.config.exists() or not list(client_denso.config.iterdir())
|
||||
|
||||
|
||||
def test_c2_solo_cols_o_solo_rows_422(client_denso):
|
||||
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "cols": "9"})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_grab_raddrizzato_se_calibrata_e_grezzo_a_richiesta(client_denso, cartella_densa):
|
||||
originale = cv2.imread(str(cartella_densa / "densa.png"), cv2.IMREAD_GRAYSCALE)
|
||||
prima = client_denso.post("/camera/grab", json={"source": SORGENTE})
|
||||
assert prima.headers["x-raddrizzato"] == "no" # non calibrata: frame com'e'
|
||||
assert np.array_equal(_grigio(prima), originale)
|
||||
|
||||
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert salva_con_nome(client_denso, r.json(), "Densa").status_code == 201 # #91: salvata con nome
|
||||
libreria = client_denso.config / "calibrazioni"
|
||||
json_salvati, mappe = list(libreria.glob("*.json")), list(libreria.glob("*.npz"))
|
||||
assert len(json_salvati) == 1 and len(mappe) == 1
|
||||
assert mappe[0].stem == json_salvati[0].stem # stesso nome del JSON
|
||||
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no" # salvata != attiva
|
||||
assert carica_su(client_denso, SORGENTE, "Densa").status_code == 200
|
||||
saved = client_denso.get("/camera/calibration", params={"source": SORGENTE}).json()
|
||||
assert set(saved) == CHIAVI_DENSA | CHIAVI_ATTIVA and saved["metodo"] == "densa"
|
||||
|
||||
dopo = client_denso.post("/camera/grab", json={"source": SORGENTE})
|
||||
assert dopo.status_code == 200 and dopo.headers["x-raddrizzato"] == "si"
|
||||
import calibrazione
|
||||
dritta = _grigio(dopo)
|
||||
assert dritta.shape == originale.shape
|
||||
assert not np.array_equal(dritta, originale)
|
||||
assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3 # griglia regolare
|
||||
|
||||
grezzo = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": True})
|
||||
assert grezzo.headers["x-raddrizzato"] == "no"
|
||||
assert np.array_equal(_grigio(grezzo), originale)
|
||||
|
||||
# la calibrazione parte sempre dal frame grezzo: ricalibrare dalla sorgente da' lo stesso mm/px
|
||||
di_nuovo = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
|
||||
assert di_nuovo.json()["mm_per_px"] == pytest.approx(r.json()["mm_per_px"])
|
||||
assert di_nuovo.json()["residuo_max_px"] < 0.3
|
||||
|
||||
|
||||
def test_grab_grezzo_non_booleano_400(client_denso):
|
||||
r = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": "si"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_scollega_lascia_la_libreria_ed_elimina_toglie_la_mappa(client_denso):
|
||||
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
|
||||
salva_con_nome(client_denso, r.json(), "Densa")
|
||||
carica_su(client_denso, SORGENTE, "Densa")
|
||||
libreria = client_denso.config / "calibrazioni"
|
||||
assert list(libreria.glob("*.npz"))
|
||||
# scollegare: il frame torna com'e', ma la voce (e la mappa) restano in libreria
|
||||
assert client_denso.delete("/camera/calibration", params={"source": SORGENTE}).status_code == 204
|
||||
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
|
||||
assert list(libreria.glob("*.npz")) and list(libreria.glob("*.json"))
|
||||
# eliminare la voce toglie JSON e mappa
|
||||
carica_su(client_denso, SORGENTE, "Densa")
|
||||
assert client_denso.delete("/camera/calibrazioni", params={"nome": "Densa"}).status_code == 204
|
||||
assert not list(libreria.glob("*.npz")) and not list(libreria.glob("*.json"))
|
||||
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
|
||||
|
||||
|
||||
def test_ricalibrare_planare_toglie_la_mappa_densa(client_denso):
|
||||
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
|
||||
salva_con_nome(client_denso, densa.json(), "Cal")
|
||||
carica_su(client_denso, SORGENTE, "Cal")
|
||||
libreria = client_denso.config / "calibrazioni"
|
||||
assert list(libreria.glob("*.npz"))
|
||||
planare = client_denso.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI},
|
||||
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||
assert planare.status_code == 200 and planare.json()["metodo"] == "planare"
|
||||
assert salva_con_nome(client_denso, planare.json(), "Cal").status_code == 409 # nome gia' preso
|
||||
assert salva_con_nome(client_denso, planare.json(), "Cal", sovrascrivi=True).status_code == 201
|
||||
assert not list(libreria.glob("*.npz")) # niente mappa vecchia che raddrizza a vuoto
|
||||
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Test di #95: calibrazione manuale (mm/px impostato a mano) in scripts/camera_service.py e nella PAGINA.
|
||||
|
||||
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||
Sorgenti = immagini caricate in tmp_path: niente camera vera, niente ProgramData.
|
||||
"""
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from test_calibrazione import _png, cartella, client # fixture della calibrazione
|
||||
from test_immagini_affiancate import _anteprima, _calibrata, _carica
|
||||
from test_pagina_acquisizione import PAGINA
|
||||
|
||||
__all__ = ["cartella", "client"] # le fixture importate restano usate
|
||||
|
||||
SRC = "banco.png" # 64x48
|
||||
ALTRA = "grande.png" # 80x60, risoluzione diversa
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sorgenti(client):
|
||||
assert _carica(client, SRC, np.full((48, 64), 120, np.uint8)).status_code == 201
|
||||
assert _carica(client, ALTRA, np.full((60, 80), 120, np.uint8)).status_code == 201
|
||||
return client
|
||||
|
||||
|
||||
def _manuale(c, nome="Manuale", mm=0.05, source=SRC, **extra):
|
||||
return c.post("/camera/calibrazioni/manuale",
|
||||
json={"source": source, "nome": nome, "mm_per_px": mm, **extra})
|
||||
|
||||
|
||||
# ---- servizio
|
||||
|
||||
def test_manuale_201_con_scala_e_risoluzione_del_frame(sorgenti):
|
||||
r = _manuale(sorgenti, mm=0.05)
|
||||
assert r.status_code == 201, r.text
|
||||
m = r.json()
|
||||
assert m["nome"] == "Manuale" and m["metodo"] == "manuale"
|
||||
assert m["px_per_mm"] == pytest.approx(20.0) and m["mm_per_px"] == pytest.approx(0.05)
|
||||
assert (m["image_width"], m["image_height"]) == (64, 48)
|
||||
assert m["origine"] == SRC and m["square_mm"] is None and m["attiva_su"] == [] # non la attiva
|
||||
voci = sorgenti.get("/camera/calibrazioni").json()["calibrazioni"]
|
||||
assert [v["nome"] for v in voci] == ["Manuale"]
|
||||
|
||||
|
||||
def test_manuale_usa_il_frame_tenuto_se_c_e(sorgenti):
|
||||
assert _anteprima(sorgenti, SRC).status_code == 200 # tiene il grezzo 64x48
|
||||
r = _manuale(sorgenti)
|
||||
assert r.status_code == 201 and (r.json()["image_width"], r.json()["image_height"]) == (64, 48)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("valore", [0, -1, 0.000001, 100.5, "abc", None, True])
|
||||
def test_manuale_mm_non_valido_400(sorgenti, valore):
|
||||
r = _manuale(sorgenti, mm=valore)
|
||||
assert r.status_code == 400
|
||||
assert "mm/px deve essere un numero positivo" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_manuale_senza_source_o_nome_400_e_sorgente_ignota_404(sorgenti):
|
||||
assert sorgenti.post("/camera/calibrazioni/manuale", json={"nome": "x", "mm_per_px": 0.1}).status_code == 400
|
||||
assert _manuale(sorgenti, nome=" ").status_code == 400
|
||||
assert _manuale(sorgenti, source="nonesiste.png").status_code == 404
|
||||
|
||||
|
||||
def test_manuale_409_nome_doppio_e_sovrascrivi(sorgenti):
|
||||
assert _manuale(sorgenti, mm=0.05).status_code == 201
|
||||
r = _manuale(sorgenti, mm=0.1)
|
||||
assert r.status_code == 409 and "esiste già una calibrazione" in r.json()["detail"]
|
||||
r = _manuale(sorgenti, mm=0.1, sovrascrivi=True)
|
||||
assert r.status_code == 201 and r.json()["px_per_mm"] == pytest.approx(10.0)
|
||||
assert _manuale(sorgenti, mm=0.1, sovrascrivi="si").status_code == 400
|
||||
|
||||
|
||||
def test_attiva_manuale_ok_su_stessa_risoluzione_e_legge_px_per_mm(sorgenti):
|
||||
assert _manuale(sorgenti, mm=0.05).status_code == 201
|
||||
r = sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"})
|
||||
assert r.status_code == 200, r.text
|
||||
g = sorgenti.get("/camera/calibration", params={"source": SRC})
|
||||
assert g.status_code == 200 and g.json()["px_per_mm"] == pytest.approx(20.0)
|
||||
assert g.json()["nome"] == "Manuale" and g.json()["metodo"] == "manuale"
|
||||
|
||||
|
||||
def test_attiva_manuale_409_su_risoluzione_diversa(sorgenti):
|
||||
assert _manuale(sorgenti).status_code == 201
|
||||
r = sorgenti.put("/camera/calibrazione-attiva", json={"source": ALTRA, "nome": "Manuale"})
|
||||
assert r.status_code == 409 and "64×48" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_anteprima_calibrata_con_manuale_200_grezza(sorgenti):
|
||||
assert _manuale(sorgenti).status_code == 201
|
||||
assert sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"}).status_code == 200
|
||||
assert _anteprima(sorgenti, SRC).status_code == 200
|
||||
r = _calibrata(sorgenti, SRC)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.headers["X-Raddrizzato"] == "no" and r.headers["X-Calibrazione"] == "Manuale"
|
||||
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("64", "48")
|
||||
assert r.headers["content-type"] == "image/jpeg"
|
||||
|
||||
|
||||
def test_anteprima_calibrata_manuale_409_se_il_frame_ha_altra_risoluzione(sorgenti):
|
||||
assert _manuale(sorgenti).status_code == 201
|
||||
assert sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"}).status_code == 200
|
||||
assert _anteprima(sorgenti, ALTRA).status_code == 200
|
||||
# stessa sorgente attiva, ma il frame tenuto cambia (immagine sostituita con altra misura)
|
||||
assert _carica(sorgenti, SRC, np.full((60, 80), 9, np.uint8)).status_code == 201
|
||||
assert _anteprima(sorgenti, SRC).status_code == 200
|
||||
r = _calibrata(sorgenti, SRC)
|
||||
assert r.status_code == 409 and "64×48" in r.json()["detail"]
|
||||
|
||||
|
||||
# ---- pagina
|
||||
|
||||
def test_pagina_scheda_manuale_id_e_testi():
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
assert {"mmPx", "pxMmCalc", "nomeManuale", "salvaManuale", "msgManuale"} <= ids
|
||||
tab = PAGINA.split('id="pannelloCal"')[1]
|
||||
assert tab.index('id="sezioneCalibrazione"') < tab.index('id="sezioneManuale"') < tab.index('id="sezioneSalvate"')
|
||||
assert "Calibrazione manuale" in PAGINA and "mm per pixel" in PAGINA
|
||||
assert "salvata: per usarla «Carica» o CONFERMA" in PAGINA
|
||||
assert "Sovrascrivi «" in PAGINA.split("async function salvaManuale")[1] # stessa conferma del Salva scacchiera
|
||||
assert "/camera/calibrazioni/manuale" in PAGINA and "alert(" not in PAGINA and "confirm(" not in PAGINA
|
||||
|
||||
|
||||
def test_pagina_manuale_senza_sorgente_pulsante_spento_con_nota():
|
||||
stato = PAGINA.split("function statoManuale")[1].split("function aggiornaManuale")[0]
|
||||
assert 'salvaManuale").disabled = !s' in stato and "Scegliere una sorgente" in stato
|
||||
assert "statoManuale()" in PAGINA.split("async function aggiornaStato")[1].split("async function caricaCalibrazioni")[0]
|
||||
|
||||
|
||||
def test_pagina_px_mm_calcolato_a_ogni_tasto():
|
||||
assert '$("mmPx").oninput = aggiornaManuale' in PAGINA
|
||||
assert '"= " + fmt(1 / v, 4) + " px/mm"' in PAGINA
|
||||
|
||||
|
||||
def test_pagina_metodo_manuale_e_campi_null():
|
||||
assert 'c.metodo === "densa" || c.metodo === "manuale" ? c.metodo : "planare"' in PAGINA
|
||||
righe = PAGINA.split("function righe")[1].split("function tabella")[0]
|
||||
assert 'd.metodo === "manuale"' in righe and "Lato dello scacco" in righe # la manuale ha il suo elenco, le altre il solito
|
||||
assert 'v === null || v === undefined' in PAGINA.split("function fmt")[1].split("function fmtCasella")[0]
|
||||
stato = PAGINA.split("async function aggiornaStato")[1].split("async function caricaCalibrazioni")[0]
|
||||
assert "Number(d.square_mm) > 0" in stato
|
||||
|
||||
|
||||
def test_pagina_calibrata_senza_raddrizzamento_dice_solo_scala():
|
||||
assert "solo scala, nessuna correzione ottica" in PAGINA
|
||||
assert 'r.headers.get("X-Raddrizzato") === "no"' in PAGINA
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Test dell'archivio unico di calibrazioni con nome (#91): scripts/calibrazione.py, classe Archivio.
|
||||
|
||||
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||
Solo tmp_path: niente ProgramData, niente camera.
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from test_calibrazione import _carica, una_scacchiera_densa
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def mappa_densa():
|
||||
# #91: una mappa vera, calcolata una volta sola per tutti i test del modulo
|
||||
_carica()
|
||||
import calibrazione
|
||||
return calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0)[1]
|
||||
|
||||
|
||||
def _dati(**extra):
|
||||
base = {"px_per_mm": 20.0, "mm_per_px": 0.05, "image_width": 640, "image_height": 480,
|
||||
"metodo": "planare", "square_mm": 2.0, "calibrated_at": "2026-10-24T10:00:00+00:00",
|
||||
"origine": "file:s.png"}
|
||||
return {**base, **extra}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cal():
|
||||
_carica()
|
||||
import calibrazione
|
||||
return calibrazione
|
||||
|
||||
|
||||
# ---- nomi
|
||||
|
||||
@pytest.mark.parametrize("nome", ["", " ", "x" * 61, "a\x00b", "a\nb", "a\tb", None, 5])
|
||||
def test_nome_non_valido(cal, tmp_path, nome):
|
||||
with pytest.raises(cal.NomeNonValido):
|
||||
cal.Archivio(tmp_path).salva_nome(nome, _dati())
|
||||
|
||||
|
||||
def test_nome_valido_limiti_e_strip(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("x" * 60, _dati())
|
||||
meta = arch.salva_nome(" Banco 1 ", _dati())
|
||||
assert meta["nome"] == "Banco 1" # strip
|
||||
assert arch.leggi_nome("Banco 1")["nome"] == "Banco 1"
|
||||
|
||||
|
||||
def test_nome_unico_senza_maiuscole(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("Banco", _dati(px_per_mm=1.0))
|
||||
with pytest.raises(cal.NomeGiaUsato, match="Banco"):
|
||||
arch.salva_nome("BANCO", _dati(px_per_mm=2.0))
|
||||
assert arch.leggi_nome("banco")["px_per_mm"] == 1.0 # non toccata
|
||||
assert len(arch.elenco()) == 1
|
||||
|
||||
|
||||
def test_sovrascrivi_sostituisce_e_prende_il_nuovo_nome(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("Banco", _dati(px_per_mm=1.0))
|
||||
arch.salva_nome("BANCO", _dati(px_per_mm=2.0), sovrascrivi=True)
|
||||
voci = arch.elenco()
|
||||
assert len(voci) == 1 and voci[0]["nome"] == "BANCO" and voci[0]["px_per_mm"] == 2.0
|
||||
assert len(list((tmp_path / "calibrazioni").glob("*.json"))) == 1
|
||||
|
||||
|
||||
def test_salva_non_scrive_source_ne_overlay(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("A", _dati(source="camera:x", overlay_png="abc", saved=True))
|
||||
scritto = json.loads(next((tmp_path / "calibrazioni").glob("*.json")).read_text(encoding="utf-8"))
|
||||
assert "source" not in scritto and "overlay_png" not in scritto and "saved" not in scritto
|
||||
assert scritto["nome"] == "A" and scritto["origine"] == "file:s.png"
|
||||
|
||||
|
||||
def test_leggi_nome_inesistente_o_non_valido(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
assert arch.leggi_nome("nessuna") is None and arch.leggi_nome("") is None
|
||||
|
||||
|
||||
# ---- elenco e attive
|
||||
|
||||
def test_elenco_dalla_piu_recente_e_attiva_su(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("vecchia", _dati(calibrated_at="2026-01-01T00:00:00+00:00"))
|
||||
arch.salva_nome("nuova", _dati(calibrated_at="2026-10-01T00:00:00+00:00"))
|
||||
arch.attiva("camera:b", "nuova")
|
||||
arch.attiva("camera:a", "nuova")
|
||||
voci = arch.elenco()
|
||||
assert [v["nome"] for v in voci] == ["nuova", "vecchia"]
|
||||
assert voci[0]["attiva_su"] == ["camera:a", "camera:b"] and voci[1]["attiva_su"] == []
|
||||
assert set(voci[0]) == {"nome", "origine", "image_width", "image_height", "px_per_mm",
|
||||
"mm_per_px", "metodo", "square_mm", "calibrated_at", "attiva_su"}
|
||||
|
||||
|
||||
def test_attiva_leggi_scollega(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("Banco", _dati())
|
||||
assert arch.attiva_di("camera:a") is None and arch.leggi("camera:a") is None
|
||||
assert arch.attiva("camera:a", "banco") == "Banco" # nome come in libreria
|
||||
assert arch.attiva_di("camera:a") == "Banco"
|
||||
letta = arch.leggi("camera:a")
|
||||
assert letta["nome"] == "Banco" and letta["source"] == "camera:a" and letta["px_per_mm"] == 20.0
|
||||
assert json.loads((tmp_path / "attive.json").read_text(encoding="utf-8")) == {"camera:a": "Banco"}
|
||||
arch.scollega("camera:a")
|
||||
assert arch.attiva_di("camera:a") is None and arch.leggi("camera:a") is None
|
||||
assert arch.leggi_nome("Banco") is not None # la libreria resta
|
||||
arch.scollega("camera:a") # idempotente
|
||||
|
||||
|
||||
def test_attiva_nome_sconosciuto(cal, tmp_path):
|
||||
with pytest.raises(cal.NomeSconosciuto):
|
||||
cal.Archivio(tmp_path).attiva("camera:a", "nessuna")
|
||||
|
||||
|
||||
def test_due_sorgenti_stessa_calibrazione(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("Comune", _dati())
|
||||
arch.attiva("camera:a", "Comune")
|
||||
arch.attiva("camera:b", "Comune")
|
||||
assert arch.leggi("camera:a")["source"] == "camera:a" and arch.leggi("camera:b")["source"] == "camera:b"
|
||||
|
||||
|
||||
def test_elimina_nome_toglie_da_attive_e_mappa(cal, tmp_path, mappa_densa):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa)
|
||||
arch.salva_nome("Altra", _dati())
|
||||
arch.attiva("camera:a", "Densa")
|
||||
arch.attiva("camera:b", "Densa")
|
||||
arch.attiva("camera:c", "Altra")
|
||||
assert arch.leggi_mappa("camera:a") is not None
|
||||
assert arch.elimina_nome("DENSA") is True
|
||||
assert arch.leggi_nome("Densa") is None and not list((tmp_path / "calibrazioni").glob("*.npz"))
|
||||
assert arch.attiva_di("camera:a") is None and arch.attiva_di("camera:b") is None
|
||||
assert arch.leggi_mappa("camera:a") is None
|
||||
assert arch.attiva_di("camera:c") == "Altra"
|
||||
assert json.loads((tmp_path / "attive.json").read_text(encoding="utf-8")) == {"camera:c": "Altra"}
|
||||
assert arch.elimina_nome("Densa") is False # gia' tolta
|
||||
|
||||
|
||||
# ---- mappa
|
||||
|
||||
def test_leggi_mappa_risolve_l_attiva_con_cache(cal, tmp_path, mappa_densa):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
assert arch.leggi_mappa("camera:a") is None
|
||||
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa)
|
||||
assert arch.leggi_mappa("camera:a") is None # non attiva su camera:a
|
||||
arch.attiva("camera:a", "Densa")
|
||||
prima = arch.leggi_mappa("camera:a")
|
||||
assert prima is not None and arch.leggi_mappa("camera:a") is prima # cache in memoria
|
||||
assert arch.leggi_mappa("camera:b") is None
|
||||
assert not list(tmp_path.rglob("*.tmp*")) # niente temporanei rimasti
|
||||
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa, sovrascrivi=True) # file riscritto
|
||||
assert arch.leggi_mappa("camera:a") is not prima # cache invalidata
|
||||
arch.salva_nome("Densa", _dati(), sovrascrivi=True) # sovrascritta con una planare: la mappa sparisce
|
||||
assert arch.leggi_mappa("camera:a") is None and not list(tmp_path.rglob("*.npz"))
|
||||
|
||||
|
||||
# ---- migrazione dal formato vecchio
|
||||
|
||||
def _vecchia(cartella, cal, source, mappa=None, **extra):
|
||||
"""Scrive una calibrazione nel formato vecchio (un JSON per sorgente, + .npz) come faceva Archivio.salva."""
|
||||
dati = {"source": source, **_dati(**extra)}
|
||||
dati.pop("origine")
|
||||
nudo = cal.Archivio.__new__(cal.Archivio) # senza costruttore: niente migrazione mentre si prepara
|
||||
nudo.cartella = cartella
|
||||
percorso = nudo._file(source)
|
||||
cartella.mkdir(parents=True, exist_ok=True)
|
||||
percorso.write_text(json.dumps(dati), encoding="utf-8")
|
||||
if mappa is not None:
|
||||
mappa.save(str(percorso.with_suffix(".npz")))
|
||||
return percorso
|
||||
|
||||
|
||||
def test_migrazione_formato_vecchio(cal, tmp_path, mappa_densa):
|
||||
v1 = _vecchia(tmp_path, cal, "camera:601020", mappa_densa, metodo="densa", px_per_mm=7.0)
|
||||
v2 = _vecchia(tmp_path, cal, "camera:folder", px_per_mm=9.0)
|
||||
(tmp_path / "settaggi").mkdir()
|
||||
(tmp_path / "settaggi" / v1.name).write_text("{}", encoding="utf-8") # non si tocca
|
||||
arch = cal.Archivio(tmp_path)
|
||||
assert not v1.exists() and not v1.with_suffix(".npz").exists() and not v2.exists()
|
||||
assert {v["nome"] for v in arch.elenco()} == {"camera:601020", "camera:folder"}
|
||||
assert arch.attiva_di("camera:601020") == "camera:601020" and arch.attiva_di("camera:folder") == "camera:folder"
|
||||
letta = arch.leggi("camera:601020")
|
||||
assert letta["px_per_mm"] == 7.0 and letta["source"] == "camera:601020"
|
||||
assert arch.leggi_mappa("camera:601020") is not None # il .npz ha seguito il JSON
|
||||
assert len(list((tmp_path / "calibrazioni").glob("*.npz"))) == 1
|
||||
assert (tmp_path / "settaggi" / v1.name).read_text(encoding="utf-8") == "{}"
|
||||
|
||||
|
||||
def test_migrazione_idempotente(cal, tmp_path):
|
||||
_vecchia(tmp_path, cal, "camera:a")
|
||||
cal.Archivio(tmp_path)
|
||||
prima = sorted(p.name for p in tmp_path.rglob("*") if p.is_file())
|
||||
arch = cal.Archivio(tmp_path) # secondo avvio
|
||||
arch.migra()
|
||||
assert sorted(p.name for p in tmp_path.rglob("*") if p.is_file()) == prima
|
||||
assert [v["nome"] for v in arch.elenco()] == ["camera:a"]
|
||||
|
||||
|
||||
def test_migrazione_nome_gia_preso(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("camera:a", _dati(px_per_mm=1.0, calibrated_at="2020-01-01T00:00:00+00:00"))
|
||||
_vecchia(tmp_path, cal, "camera:a", px_per_mm=5.0)
|
||||
arch.migra()
|
||||
nomi = {v["nome"]: v["px_per_mm"] for v in arch.elenco()}
|
||||
assert nomi == {"camera:a": 1.0, "camera:a (2)": 5.0}
|
||||
assert arch.attiva_di("camera:a") == "camera:a (2)" # la sorgente usa la sua, non quella gia' presente
|
||||
# un altro file con lo stesso nome -> (3)
|
||||
_vecchia(tmp_path, cal, "camera:A", px_per_mm=6.0) # nome uguale senza maiuscole
|
||||
arch.migra()
|
||||
assert "camera:A (3)" in {v["nome"] for v in arch.elenco()}
|
||||
|
||||
|
||||
def test_migrazione_ignora_json_senza_source_e_attive(cal, tmp_path):
|
||||
tmp_path.mkdir(exist_ok=True)
|
||||
(tmp_path / "altro.json").write_text(json.dumps({"x": 1}), encoding="utf-8")
|
||||
(tmp_path / "rotto.json").write_text("{non json", encoding="utf-8")
|
||||
arch = cal.Archivio(tmp_path)
|
||||
arch.salva_nome("A", _dati())
|
||||
arch.attiva("source", "A") # una sorgente chiamata «source» non deve far scambiare attive.json per un vecchio file
|
||||
arch.migra()
|
||||
assert (tmp_path / "altro.json").exists() and (tmp_path / "rotto.json").exists()
|
||||
assert arch.attiva_di("source") == "A" and [v["nome"] for v in arch.elenco()] == ["A"]
|
||||
|
||||
|
||||
def test_cartella_assente_non_da_errore(cal, tmp_path):
|
||||
arch = cal.Archivio(tmp_path / "non" / "esiste")
|
||||
assert arch.elenco() == [] and arch.leggi("s") is None and arch.leggi_mappa("s") is None
|
||||
arch.scollega("s")
|
||||
assert arch.elimina_nome("x") is False
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Test delle rotte dell'archivio unico di calibrazioni con nome (#91): scripts/camera_service.py.
|
||||
|
||||
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||
Sorgenti: camera:folder su cartelle temporanee (520x400 scacchiera piana, 640x480 densa).
|
||||
Solo tmp_path: niente ProgramData, niente camera vera.
|
||||
"""
|
||||
import json
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_calibrazione import ( # fixture e helper della calibrazione (#83/#84)
|
||||
CAMPI, CHIAVI_ATTIVA, CHIAVI_DENSA, CHIAVI_PLANARE, SORGENTE, _carica, _png,
|
||||
carica_su, cartella, cartella_densa, client, client_denso, salva_con_nome,
|
||||
una_scacchiera, una_scacchiera_densa,
|
||||
)
|
||||
|
||||
__all__ = ["cartella", "cartella_densa", "client", "client_denso"] # le fixture importate restano usate
|
||||
|
||||
|
||||
def _calcola_file(c, **extra):
|
||||
"""Calcolo planare da un'immagine (520x400): ritorna la risposta JSON."""
|
||||
r = c.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI, **extra},
|
||||
files={"image": ("scacco.png", _png(una_scacchiera()), "image/png")})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
# ---- POST /camera/calibrate
|
||||
|
||||
def test_calibrate_senza_source_con_immagine_ok(client):
|
||||
r = client.post("/camera/calibrate", data=CAMPI,
|
||||
files={"image": ("C:\\foto\\scacco.png", _png(una_scacchiera()), "image/png")})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["origine"] == "file:scacco.png" # solo il nome del file, non il percorso
|
||||
|
||||
|
||||
def test_calibrate_senza_source_e_senza_immagine_400(client):
|
||||
r = client.post("/camera/calibrate", data=CAMPI)
|
||||
assert r.status_code == 400 and "source" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_calibrate_da_sorgente_ha_origine_camera_e_id(client):
|
||||
r = client.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI})
|
||||
assert r.status_code == 200, r.text
|
||||
corpo = r.json()
|
||||
assert corpo["origine"] == SORGENTE and corpo["calibrated_at"]
|
||||
assert len(corpo["calcolo_id"]) == 32 and int(corpo["calcolo_id"], 16) >= 0 # esadecimale
|
||||
assert corpo["calcolo_id"] != client.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI}).json()["calcolo_id"]
|
||||
|
||||
|
||||
# ---- POST /camera/calibrazioni
|
||||
|
||||
def test_salva_201_con_metadati(client):
|
||||
calcolo = _calcola_file(client)
|
||||
r = salva_con_nome(client, calcolo, " Banco 1 ")
|
||||
assert r.status_code == 201, r.text
|
||||
meta = r.json()
|
||||
assert meta["nome"] == "Banco 1" and meta["origine"] == "file:scacco.png"
|
||||
assert (meta["image_width"], meta["image_height"]) == (520, 400)
|
||||
assert meta["metodo"] == "planare" and meta["attiva_su"] == []
|
||||
assert meta["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||
# lo stesso calcolo si puo' salvare anche con un altro nome
|
||||
assert salva_con_nome(client, calcolo, "Banco 2").status_code == 201
|
||||
|
||||
|
||||
def test_salva_calcolo_sconosciuto_404(client):
|
||||
r = client.post("/camera/calibrazioni", json={"calcolo_id": "0" * 32, "nome": "X"})
|
||||
assert r.status_code == 404 and r.json()["detail"] == "calcolo non più disponibile: ricalcolare"
|
||||
|
||||
|
||||
def test_salva_calcolo_scaduto_dopo_otto_calcoli_404(client):
|
||||
primo = _calcola_file(client)
|
||||
ultimo = primo
|
||||
for _ in range(8): # il nono calcolo spinge fuori il primo
|
||||
ultimo = _calcola_file(client)
|
||||
assert salva_con_nome(client, primo, "Vecchio").status_code == 404
|
||||
assert salva_con_nome(client, ultimo, "Nuovo").status_code == 201
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nome", ["", " ", "x" * 61, "a\x01b", None, 7])
|
||||
def test_salva_nome_non_valido_400(client, nome):
|
||||
r = client.post("/camera/calibrazioni", json={"calcolo_id": _calcola_file(client)["calcolo_id"], "nome": nome})
|
||||
assert r.status_code == 400, r.text
|
||||
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
|
||||
|
||||
|
||||
def test_salva_corpo_incompleto_400(client):
|
||||
assert client.post("/camera/calibrazioni", json={"nome": "X"}).status_code == 400
|
||||
calcolo = _calcola_file(client)
|
||||
r = client.post("/camera/calibrazioni", json={"calcolo_id": calcolo["calcolo_id"], "nome": "X", "sovrascrivi": "si"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_salva_nome_gia_usato_409_e_sovrascrivi(client):
|
||||
a = _calcola_file(client)
|
||||
assert salva_con_nome(client, a, "Banco").status_code == 201
|
||||
r = salva_con_nome(client, a, "BANCO") # senza distinguere maiuscole
|
||||
assert r.status_code == 409 and r.json()["detail"] == "esiste già una calibrazione «Banco»"
|
||||
assert len(client.get("/camera/calibrazioni").json()["calibrazioni"]) == 1
|
||||
r = salva_con_nome(client, a, "BANCO", sovrascrivi=True)
|
||||
assert r.status_code == 201 and r.json()["nome"] == "BANCO"
|
||||
voci = client.get("/camera/calibrazioni").json()["calibrazioni"]
|
||||
assert [v["nome"] for v in voci] == ["BANCO"]
|
||||
|
||||
|
||||
# ---- GET /camera/calibrazioni
|
||||
|
||||
def test_elenco_vuoto_e_ordine(client):
|
||||
assert client.get("/camera/calibrazioni").json() == {"calibrazioni": [], "attiva": None}
|
||||
prima = _calcola_file(client)
|
||||
salva_con_nome(client, prima, "prima")
|
||||
seconda = _calcola_file(client)
|
||||
seconda_ts = seconda["calibrated_at"]
|
||||
salva_con_nome(client, seconda, "seconda")
|
||||
voci = client.get("/camera/calibrazioni").json()["calibrazioni"]
|
||||
assert {v["nome"] for v in voci} == {"prima", "seconda"}
|
||||
assert [v["calibrated_at"] for v in voci] == sorted((v["calibrated_at"] for v in voci), reverse=True)
|
||||
assert set(voci[0]) == {"nome", "origine", "image_width", "image_height", "px_per_mm", "mm_per_px",
|
||||
"metodo", "square_mm", "calibrated_at", "attiva_su"}
|
||||
assert seconda_ts >= prima["calibrated_at"]
|
||||
|
||||
|
||||
def test_elenco_con_source_dice_l_attiva(client):
|
||||
salva_con_nome(client, _calcola_file(client), "Banco")
|
||||
salva_con_nome(client, _calcola_file(client), "Altra")
|
||||
assert carica_su(client, SORGENTE, "banco").status_code == 200
|
||||
r = client.get("/camera/calibrazioni", params={"source": SORGENTE}).json()
|
||||
assert r["attiva"] == "Banco"
|
||||
attiva_su = {v["nome"]: v["attiva_su"] for v in r["calibrazioni"]}
|
||||
assert attiva_su == {"Banco": [SORGENTE], "Altra": []}
|
||||
assert client.get("/camera/calibrazioni", params={"source": "camera:altra"}).json()["attiva"] is None
|
||||
assert client.get("/camera/calibrazioni", params={"source": " "}).status_code == 400
|
||||
|
||||
|
||||
# ---- DELETE /camera/calibrazioni
|
||||
|
||||
def test_elimina_204_404_e_toglie_dalle_sorgenti(client):
|
||||
salva_con_nome(client, _calcola_file(client), "Banco")
|
||||
carica_su(client, SORGENTE, "Banco")
|
||||
assert client.delete("/camera/calibrazioni", params={"nome": "BANCO"}).status_code == 204
|
||||
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
|
||||
assert client.get("/camera/calibration", params={"source": SORGENTE}).status_code == 404 # non piu' attiva
|
||||
assert json.loads((client.config / "attive.json").read_text(encoding="utf-8")) == {}
|
||||
assert client.delete("/camera/calibrazioni", params={"nome": "Banco"}).status_code == 404
|
||||
assert client.delete("/camera/calibrazioni").status_code == 404 # senza nome
|
||||
|
||||
|
||||
# ---- PUT /camera/calibrazione-attiva
|
||||
|
||||
def test_carica_risoluzione_uguale_200_come_get(client):
|
||||
salva_con_nome(client, _calcola_file(client), "Banco") # 520x400 = la scacchiera in cartella
|
||||
r = carica_su(client, SORGENTE, "banco")
|
||||
assert r.status_code == 200, r.text
|
||||
assert set(r.json()) == CHIAVI_PLANARE | CHIAVI_ATTIVA
|
||||
assert r.json()["nome"] == "Banco" and r.json()["source"] == SORGENTE
|
||||
assert client.get("/camera/calibration", params={"source": SORGENTE}).json() == r.json()
|
||||
|
||||
|
||||
def test_carica_risoluzione_diversa_409_e_anteprima_continua(client_denso):
|
||||
# calibrazione da file 520x400, sorgente 640x480
|
||||
salva_con_nome(client_denso, _calcola_file(client_denso), "Piccola")
|
||||
r = carica_su(client_denso, SORGENTE, "Piccola")
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == ("la calibrazione «Piccola» è per immagini 520×400, "
|
||||
"la sorgente dà 640×480: non si può caricare")
|
||||
assert client_denso.get("/camera/calibration", params={"source": SORGENTE}).status_code == 404 # nulla attivato
|
||||
# l'acquisizione continua: grab e anteprima rispondono, frame non raddrizzato
|
||||
g = client_denso.post("/camera/grab", json={"source": SORGENTE})
|
||||
assert g.status_code == 200 and g.headers["x-raddrizzato"] == "no"
|
||||
a = client_denso.post("/camera/anteprima", json={"source": SORGENTE})
|
||||
assert a.status_code == 200 and a.headers["x-raddrizzato"] == "no"
|
||||
assert (a.headers["x-larghezza"], a.headers["x-altezza"]) == ("640", "480")
|
||||
|
||||
|
||||
def test_carica_risoluzione_diversa_non_scalza_l_attiva_precedente(client_denso):
|
||||
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}).json()
|
||||
salva_con_nome(client_denso, densa, "Giusta")
|
||||
salva_con_nome(client_denso, _calcola_file(client_denso), "Sbagliata")
|
||||
assert carica_su(client_denso, SORGENTE, "Giusta").status_code == 200
|
||||
assert carica_su(client_denso, SORGENTE, "Sbagliata").status_code == 409
|
||||
assert client_denso.get("/camera/calibration", params={"source": SORGENTE}).json()["nome"] == "Giusta"
|
||||
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "si"
|
||||
|
||||
|
||||
def test_carica_densa_su_sorgente_stessa_risoluzione_raddrizza(client_denso):
|
||||
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}).json()
|
||||
assert salva_con_nome(client_denso, densa, "Densa").json()["metodo"] == "densa"
|
||||
r = carica_su(client_denso, SORGENTE, "Densa")
|
||||
assert r.status_code == 200 and set(r.json()) == CHIAVI_DENSA | CHIAVI_ATTIVA
|
||||
assert client_denso.post("/camera/anteprima", json={"source": SORGENTE}).headers["x-raddrizzato"] == "si"
|
||||
|
||||
|
||||
def test_carica_nome_sconosciuto_404(client):
|
||||
r = carica_su(client, SORGENTE, "nessuna")
|
||||
assert r.status_code == 404
|
||||
assert client.put("/camera/calibrazione-attiva", json={"source": SORGENTE}).status_code == 404 # senza nome
|
||||
assert client.put("/camera/calibrazione-attiva", json={"nome": "x"}).status_code == 400 # senza source
|
||||
|
||||
|
||||
def test_carica_su_sorgente_inesistente_404(client):
|
||||
salva_con_nome(client, _calcola_file(client), "Banco")
|
||||
assert carica_su(client, "camera:non-esiste", "Banco").status_code == 404
|
||||
|
||||
|
||||
def test_carica_senza_risoluzione_registrata_409(cartella, tmp_path):
|
||||
_carica()
|
||||
import calibrazione
|
||||
config = tmp_path / "config_senza"
|
||||
calibrazione.Archivio(config).salva_nome("Senza", {"px_per_mm": 10.0, "calibrated_at": "2026-01-01T00:00:00+00:00"})
|
||||
with TestClient(_carica().create_app(cartella, config_dir=config)) as c:
|
||||
r = carica_su(c, SORGENTE, "Senza")
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == "la calibrazione «Senza» non ha la risoluzione registrata: non si può caricare"
|
||||
|
||||
|
||||
# ---- contratto verso il backend e migrazione all'avvio
|
||||
|
||||
def test_get_calibration_contratto_per_il_backend(client):
|
||||
# src/backend/services/vision_service.py legge questi campi
|
||||
salva_con_nome(client, _calcola_file(client), "Banco")
|
||||
carica_su(client, SORGENTE, "Banco")
|
||||
dati = client.get("/camera/calibration", params={"source": SORGENTE}).json()
|
||||
for campo in ("px_per_mm", "mm_per_px", "image_width", "image_height", "calibrated_at", "metodo"):
|
||||
assert campo in dati
|
||||
assert client.get("/camera/calibration", params={"source": "camera:altra"}).json() == {
|
||||
"detail": "sorgente non calibrata"}
|
||||
|
||||
|
||||
def test_avvio_migra_il_formato_vecchio(cartella, tmp_path):
|
||||
_carica()
|
||||
import calibrazione
|
||||
config = tmp_path / "config_vecchia"
|
||||
config.mkdir()
|
||||
nudo = calibrazione.Archivio.__new__(calibrazione.Archivio) # per avere il nome file del formato vecchio
|
||||
nudo.cartella = config
|
||||
vecchio = nudo._file(SORGENTE)
|
||||
vecchio.write_text(json.dumps({"source": SORGENTE, "px_per_mm": 20.0, "mm_per_px": 0.05,
|
||||
"image_width": 520, "image_height": 400, "metodo": "planare",
|
||||
"calibrated_at": "2026-10-01T10:00:00+00:00"}), encoding="utf-8")
|
||||
with TestClient(_carica().create_app(cartella, config_dir=config)) as c:
|
||||
dati = c.get("/camera/calibration", params={"source": SORGENTE}).json()
|
||||
assert dati["nome"] == SORGENTE and dati["source"] == SORGENTE and dati["px_per_mm"] == 20.0
|
||||
voci = c.get("/camera/calibrazioni", params={"source": SORGENTE}).json()
|
||||
assert [v["nome"] for v in voci["calibrazioni"]] == [SORGENTE] and voci["attiva"] == SORGENTE
|
||||
assert voci["calibrazioni"][0]["attiva_su"] == [SORGENTE]
|
||||
assert not vecchio.exists()
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Test delle rotte di acquisizione di scripts/camera_service.py (#87).
|
||||
|
||||
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||
Copre: POST /camera/anteprima, GET/PUT /camera/parametri, POST /camera/parametri/salva,
|
||||
caricamento dei settaggi alla riapertura, alias GET /acquisizione.
|
||||
Sorgente regolabile finta (iniettata su open_source) e FolderCamera su PNG temporanei.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from visionsuite.camera.camera_interface import CameraStateError
|
||||
|
||||
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
|
||||
SORGENTE = "camera:finta"
|
||||
|
||||
|
||||
def _carica():
|
||||
spec = importlib.util.spec_from_file_location("camera_service_acq", MODULO)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
class Finta:
|
||||
"""Camera regolabile finta: stesso vocabolario di GenicamSource."""
|
||||
|
||||
DOMINI = {
|
||||
"esposizione": {"tipo": "numero", "min": 10.0, "max": 100000.0, "passo": 1.0},
|
||||
"esposizione_auto": {"tipo": "scelta", "scelte": ["Off", "Once", "Continuous"]},
|
||||
"guadagno": {"tipo": "numero", "min": 0.0, "max": 24.0},
|
||||
"guadagno_auto": {"tipo": "scelta", "scelte": ["Off", "Once", "Continuous"]},
|
||||
}
|
||||
|
||||
def __init__(self, supportati=None, rifiuta=()):
|
||||
self.valori = {"esposizione": 5000.0, "esposizione_auto": "Continuous",
|
||||
"guadagno": 1.0, "guadagno_auto": "Off"}
|
||||
self.supportati = set(supportati if supportati is not None else self.DOMINI)
|
||||
self.rifiuta = set(rifiuta)
|
||||
self.scritture = []
|
||||
self.caricato = None
|
||||
self.chiusa = False
|
||||
# metà bianca (255) e metà a 100: saturati 50%, media 177,5
|
||||
frame = np.full((1000, 2000, 3), 100, np.uint8)
|
||||
frame[:, :1000] = 255
|
||||
self.frame = frame
|
||||
|
||||
def supporta(self, nome):
|
||||
return nome in self.supportati
|
||||
|
||||
def dominio(self, nome):
|
||||
return {"capacita": nome, "valore": self.valori[nome], **self.DOMINI[nome]}
|
||||
|
||||
def leggi(self, nome):
|
||||
return self.valori[nome]
|
||||
|
||||
def scrivi(self, nome, valore, assesta=0):
|
||||
if nome in self.rifiuta:
|
||||
raise CameraStateError(f"non posso impostare {nome}: nodo in sola lettura")
|
||||
self.scritture.append((nome, valore, assesta))
|
||||
self.valori[nome] = valore
|
||||
|
||||
def salva_settaggi(self, percorso):
|
||||
Path(percorso).write_text(json.dumps(self.valori), encoding="utf-8")
|
||||
return dict(self.valori)
|
||||
|
||||
def carica_settaggi(self, percorso, forza=False):
|
||||
self.caricato = Path(percorso)
|
||||
self.valori.update(json.loads(self.caricato.read_text(encoding="utf-8")))
|
||||
return []
|
||||
|
||||
def grab(self, timeout=0):
|
||||
return self.frame.copy()
|
||||
|
||||
def close(self):
|
||||
self.chiusa = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cartella(tmp_path):
|
||||
img = tmp_path / "foto"
|
||||
img.mkdir()
|
||||
for nome in ("a.png", "b.png"):
|
||||
cv2.imwrite(str(img / nome), np.full((48, 64, 3), 128, np.uint8))
|
||||
return img
|
||||
|
||||
|
||||
def _client(cartella, config, monkeypatch, finta):
|
||||
mod = _carica()
|
||||
monkeypatch.setattr(mod, "open_source", lambda *a, **k: finta)
|
||||
c = TestClient(mod.create_app(cartella, config))
|
||||
c.mod = mod
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def finta():
|
||||
return Finta()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(cartella, tmp_path, monkeypatch, finta):
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, finta) as c:
|
||||
c.finta = finta
|
||||
c.config = tmp_path / "cfg"
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_cartella(cartella, tmp_path):
|
||||
mod = _carica()
|
||||
with TestClient(mod.create_app(cartella, tmp_path / "cfg")) as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ---- anteprima
|
||||
|
||||
def test_anteprima_jpeg_ridotta_e_header(client):
|
||||
r = client.post("/camera/anteprima", json={"source": SORGENTE, "lato_max": 500})
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "image/jpeg"
|
||||
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
|
||||
assert img.shape[:2] == (250, 500) # lato lungo 2000 -> 500
|
||||
assert r.headers["x-raddrizzato"] == "no"
|
||||
assert (r.headers["x-larghezza"], r.headers["x-altezza"]) == ("2000", "1000") # frame pieno
|
||||
assert r.headers["x-saturati-pct"] == "50.00"
|
||||
assert r.headers["x-media"] == "177.5"
|
||||
assert float(r.headers["x-nitidezza"]) > 0 # bordo netto fra le due metà
|
||||
|
||||
|
||||
def test_anteprima_mai_ingrandita(client_cartella):
|
||||
r = client_cartella.post("/camera/anteprima", json={"source": "camera:folder"})
|
||||
assert r.status_code == 200
|
||||
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
|
||||
assert img.shape[:2] == (48, 64) # lato_max 1280 > 64: resta com'è
|
||||
assert r.headers["x-nitidezza"] == "0.0" # grigio uniforme
|
||||
assert r.headers["x-saturati-pct"] == "0.00"
|
||||
assert r.headers["x-media"] == "128.0"
|
||||
|
||||
|
||||
def test_anteprima_parametri_errati_400(client):
|
||||
for corpo in ({}, {"source": SORGENTE, "lato_max": 100}, {"source": SORGENTE, "lato_max": 5000},
|
||||
{"source": SORGENTE, "timeout_ms": 0}, {"source": SORGENTE, "grezzo": "si"}):
|
||||
assert client.post("/camera/anteprima", json=corpo).status_code == 400, corpo
|
||||
|
||||
|
||||
def test_anteprima_sorgente_inesistente_404(client_cartella):
|
||||
assert client_cartella.post("/camera/anteprima",
|
||||
json={"source": "camera:non-esiste"}).status_code == 404
|
||||
|
||||
|
||||
def test_anteprima_errore_camera_503_e_timeout_504(client):
|
||||
from visionsuite.camera.camera_interface import CameraGrabError
|
||||
|
||||
def rotta(timeout=0):
|
||||
raise CameraGrabError("Camera is not open")
|
||||
|
||||
client.finta.grab = rotta
|
||||
r = client.post("/camera/anteprima", json={"source": SORGENTE})
|
||||
assert r.status_code == 503 and r.json()["detail"] == "La camera non è aperta"
|
||||
|
||||
def lenta(timeout=0):
|
||||
raise TimeoutError("scaduto")
|
||||
|
||||
client.finta.grab = lenta
|
||||
assert client.post("/camera/anteprima", json={"source": SORGENTE}).status_code == 504
|
||||
|
||||
|
||||
# ---- parametri
|
||||
|
||||
def test_parametri_regolabile(client):
|
||||
r = client.get("/camera/parametri", params={"source": SORGENTE})
|
||||
assert r.status_code == 200
|
||||
j = r.json()
|
||||
assert j["source"] == SORGENTE and j["regolabile"] is True
|
||||
assert list(j["parametri"]) == ["esposizione", "esposizione_auto", "guadagno", "guadagno_auto"]
|
||||
assert j["parametri"]["esposizione"]["valore"] == 5000.0
|
||||
assert j["parametri"]["esposizione_auto"]["scelte"] == ["Off", "Once", "Continuous"]
|
||||
|
||||
|
||||
def test_parametri_solo_quelli_supportati(cartella, tmp_path, monkeypatch):
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta(supportati=["esposizione"])) as c:
|
||||
j = c.get("/camera/parametri", params={"source": SORGENTE}).json()
|
||||
assert j["regolabile"] is True and list(j["parametri"]) == ["esposizione"]
|
||||
|
||||
|
||||
def test_parametri_sorgente_non_regolabile(client_cartella):
|
||||
j = client_cartella.get("/camera/parametri", params={"source": "camera:folder"}).json()
|
||||
assert j == {"source": "camera:folder", "regolabile": False, "parametri": {}}
|
||||
|
||||
|
||||
def test_parametri_senza_source_400(client):
|
||||
assert client.get("/camera/parametri").status_code == 400
|
||||
|
||||
|
||||
def test_put_scrive_prima_gli_auto(client):
|
||||
r = client.put("/camera/parametri", json={"source": SORGENTE, "valori": {
|
||||
"esposizione": 8000, "guadagno": 3.5, "esposizione_auto": "Off", "guadagno_auto": "Off"}})
|
||||
assert r.status_code == 200
|
||||
assert client.finta.scritture == [
|
||||
("esposizione_auto", "Off", 3), ("guadagno_auto", "Off", 3),
|
||||
("esposizione", 8000, 3), ("guadagno", 3.5, 3)]
|
||||
j = r.json() # valori riletti dalla camera
|
||||
assert j["parametri"]["esposizione"]["valore"] == 8000
|
||||
assert j["parametri"]["esposizione_auto"]["valore"] == "Off"
|
||||
|
||||
|
||||
def test_put_nome_ignoto_400(client):
|
||||
r = client.put("/camera/parametri", json={"source": SORGENTE, "valori": {"frame_rate": 10}})
|
||||
assert r.status_code == 400
|
||||
assert client.finta.scritture == []
|
||||
for corpo in ({"source": SORGENTE}, {"source": SORGENTE, "valori": {}},
|
||||
{"source": SORGENTE, "valori": {"guadagno": True}}):
|
||||
assert client.put("/camera/parametri", json=corpo).status_code == 400, corpo
|
||||
|
||||
|
||||
def test_put_non_regolabile_409(client_cartella):
|
||||
r = client_cartella.put("/camera/parametri",
|
||||
json={"source": "camera:folder", "valori": {"esposizione": 1000}})
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == "questa sorgente non ha parametri regolabili"
|
||||
|
||||
|
||||
def test_put_rifiuto_della_camera_422(cartella, tmp_path, monkeypatch):
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta(rifiuta=["guadagno"])) as c:
|
||||
r = c.put("/camera/parametri", json={"source": SORGENTE, "valori": {"guadagno": 5}})
|
||||
assert r.status_code == 422
|
||||
assert "guadagno" in r.json()["detail"]
|
||||
|
||||
|
||||
# ---- salvataggio e riapertura
|
||||
|
||||
def test_salva_crea_il_file(client):
|
||||
r = client.post("/camera/parametri/salva", json={"source": SORGENTE})
|
||||
assert r.status_code == 200
|
||||
j = r.json()
|
||||
assert j["source"] == SORGENTE and j["salvati"] is True
|
||||
atteso = client.config / "settaggi" / j["file"]
|
||||
assert atteso.is_file()
|
||||
assert j["file"].startswith("camera_finta-") and j["file"].endswith(".json")
|
||||
assert json.loads(atteso.read_text(encoding="utf-8"))["esposizione"] == 5000.0
|
||||
|
||||
|
||||
def test_salva_non_regolabile_409(client_cartella):
|
||||
r = client_cartella.post("/camera/parametri/salva", json={"source": "camera:folder"})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_riapertura_carica_i_settaggi(cartella, tmp_path, monkeypatch):
|
||||
config = tmp_path / "cfg"
|
||||
prima = Finta()
|
||||
with _client(cartella, config, monkeypatch, prima) as c:
|
||||
c.put("/camera/parametri", json={"source": SORGENTE, "valori": {"esposizione": 7777}})
|
||||
file = c.post("/camera/parametri/salva", json={"source": SORGENTE}).json()["file"]
|
||||
seconda = Finta() # servizio riavviato: camera nuova, valori di fabbrica
|
||||
with _client(cartella, config, monkeypatch, seconda) as c:
|
||||
assert seconda.caricato is None # niente apertura finché nessuno la chiede
|
||||
c.get("/camera/parametri", params={"source": SORGENTE})
|
||||
assert seconda.caricato == config / "settaggi" / file
|
||||
assert seconda.valori["esposizione"] == 7777
|
||||
|
||||
|
||||
def test_settaggi_rotti_non_bloccano_l_apertura(cartella, tmp_path, monkeypatch):
|
||||
class Rotta(Finta):
|
||||
def carica_settaggi(self, percorso, forza=False):
|
||||
raise ValueError("file illeggibile")
|
||||
|
||||
config = tmp_path / "cfg"
|
||||
with _client(cartella, config, monkeypatch, Finta()) as c:
|
||||
file = c.post("/camera/parametri/salva", json={"source": SORGENTE}).json()["file"]
|
||||
assert (config / "settaggi" / file).is_file()
|
||||
with _client(cartella, config, monkeypatch, Rotta()) as c:
|
||||
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
|
||||
|
||||
|
||||
def test_apertura_chiede_il_frame_piu_recente(cartella, tmp_path, monkeypatch):
|
||||
# #89: solo_ultimo_frame() una volta sola all'apertura, non a ogni richiesta
|
||||
class ConUltimoFrame(Finta):
|
||||
chiamate = 0
|
||||
|
||||
def solo_ultimo_frame(self, attivo=True):
|
||||
self.chiamate += 1
|
||||
|
||||
finta = ConUltimoFrame()
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, finta) as c:
|
||||
for _ in range(3):
|
||||
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
|
||||
assert finta.chiamate == 1
|
||||
|
||||
|
||||
def test_frame_piu_recente_non_impostabile_non_blocca_l_apertura(cartella, tmp_path, monkeypatch, caplog):
|
||||
# #89: producer senza il nodo -> avviso nel log, apertura riuscita
|
||||
class SenzaNodo(Finta):
|
||||
def solo_ultimo_frame(self, attivo=True):
|
||||
raise CameraStateError("questo producer non espone StreamBufferHandlingMode")
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, SenzaNodo()) as c:
|
||||
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
|
||||
assert "frame piu' recente non impostabile" in caplog.text
|
||||
|
||||
|
||||
def test_sorgente_senza_solo_ultimo_frame_si_apre(cartella, tmp_path, monkeypatch):
|
||||
# #89: FolderCamera e simili non hanno il metodo: nessun errore
|
||||
assert not hasattr(Finta(), "solo_ultimo_frame")
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta()) as c:
|
||||
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
|
||||
|
||||
|
||||
# ---- pagina
|
||||
|
||||
def test_acquisizione_alias_della_pagina(client):
|
||||
a = client.get("/acquisizione")
|
||||
c = client.get("/calibrazione")
|
||||
assert a.status_code == 200 and a.text == c.text
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Test di #92: immagini caricate come sorgenti e anteprima calibrata (scripts/camera_service.py).
|
||||
|
||||
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||
Solo tmp_path: niente ProgramData, niente camera vera. Le immagini le scrive il test.
|
||||
"""
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from test_calibrazione import ( # fixture e helper della calibrazione (#83/#84)
|
||||
CAMPI, _png, carica_su, cartella, client, salva_con_nome, una_scacchiera,
|
||||
una_scacchiera_densa,
|
||||
)
|
||||
|
||||
__all__ = ["cartella", "client"] # le fixture importate restano usate
|
||||
|
||||
DENSA = "densa.png"
|
||||
PIANA = "piana.png"
|
||||
|
||||
|
||||
def _modulo(c) -> dict:
|
||||
"""Il dict globale di camera_service (per sostituire open_source con una spia)."""
|
||||
return next(r.endpoint for r in c.app.routes if r.path == "/camera/grab").__globals__
|
||||
|
||||
|
||||
def _carica(c, nome: str, img: np.ndarray, **extra):
|
||||
"""POST /camera/immagini con un'immagine codificata PNG (o il contenuto dato)."""
|
||||
return c.post("/camera/immagini", files={"image": (nome, _png(img), "image/png")}, **extra)
|
||||
|
||||
|
||||
def _densa(**kw) -> np.ndarray:
|
||||
return una_scacchiera_densa(k=1e-7, **kw)
|
||||
|
||||
|
||||
def _calibra_e_attiva(c, nome_immagine: str, nome_calibrazione: str, **campi):
|
||||
"""Calcola dalla sorgente, salva con nome e la rende attiva su quella sorgente."""
|
||||
r = c.post("/camera/calibrate", data={"source": nome_immagine, "square_mm": "2", **campi})
|
||||
assert r.status_code == 200, r.text
|
||||
assert salva_con_nome(c, r.json(), nome_calibrazione).status_code == 201
|
||||
assert carica_su(c, nome_immagine, nome_calibrazione).status_code == 200
|
||||
|
||||
|
||||
def _anteprima(c, nome: str, **extra):
|
||||
return c.post("/camera/anteprima", json={"source": nome, "grezzo": True, **extra})
|
||||
|
||||
|
||||
def _calibrata(c, nome: str, **extra):
|
||||
return c.post("/camera/anteprima/calibrata", json={"source": nome, **extra})
|
||||
|
||||
|
||||
# ---- POST /camera/immagini
|
||||
|
||||
def test_carica_201_salva_in_cartella_immagini(client):
|
||||
r = _carica(client, "calibration_135_1.bmp", una_scacchiera())
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json() == {"source": "calibration_135_1.bmp",
|
||||
"label": "immagine — calibration_135_1.bmp", "kind": "image"}
|
||||
assert (client.config / "immagini" / "calibration_135_1.bmp").is_file()
|
||||
|
||||
|
||||
def test_carica_ripulisce_il_nome_e_toglie_il_percorso(client):
|
||||
r = _carica(client, "C:\\foto\\ban co è1.PNG", una_scacchiera())
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["source"] == "ban_co_1.PNG" # niente percorso, caratteri sicuri, estensione intatta
|
||||
assert (client.config / "immagini" / "ban_co_1.PNG").is_file()
|
||||
|
||||
|
||||
def test_carica_stesso_nome_sostituisce(client):
|
||||
_carica(client, "a.png", np.full((48, 64), 10, np.uint8))
|
||||
assert _carica(client, "a.png", np.full((30, 40), 200, np.uint8)).status_code == 201
|
||||
assert [f.name for f in (client.config / "immagini").iterdir()] == ["a.png"]
|
||||
img = cv2.imread(str(client.config / "immagini" / "a.png"), cv2.IMREAD_UNCHANGED)
|
||||
assert img.shape[:2] == (30, 40)
|
||||
|
||||
|
||||
def test_carica_senza_file_400(client):
|
||||
assert client.post("/camera/immagini").status_code == 400
|
||||
|
||||
|
||||
def test_carica_estensione_non_ammessa_400(client):
|
||||
r = client.post("/camera/immagini", files={"image": ("note.txt", b"ciao", "text/plain")})
|
||||
assert r.status_code == 400
|
||||
assert not (client.config / "immagini").exists() or not list((client.config / "immagini").iterdir())
|
||||
|
||||
|
||||
def test_carica_file_illeggibile_422_e_niente_su_disco(client):
|
||||
r = client.post("/camera/immagini", files={"image": ("rotta.png", b"non e una immagine", "image/png")})
|
||||
assert r.status_code == 422
|
||||
assert not (client.config / "immagini").exists() or not list((client.config / "immagini").iterdir())
|
||||
|
||||
|
||||
# ---- GET /camera/sources
|
||||
|
||||
def test_sources_con_immagini_e_senza_camera_file(client):
|
||||
_carica(client, "b.png", una_scacchiera())
|
||||
_carica(client, "a.png", una_scacchiera())
|
||||
voci = client.get("/camera/sources").json()["sources"]
|
||||
nomi = [v["name"] for v in voci]
|
||||
assert "camera:file" not in nomi
|
||||
assert "camera:folder" in nomi
|
||||
assert nomi[-2:] == ["a.png", "b.png"] # le immagini stanno in coda
|
||||
assert voci[-1] == {"name": "b.png", "kind": "image", "label": "immagine — b.png"}
|
||||
assert nomi.index("camera:folder") < nomi.index("a.png")
|
||||
|
||||
|
||||
def test_sources_camere_in_cache_e_aggiorna(client):
|
||||
# #92: la ricerca camere (~10 s) si fa una volta e resta 60 s; aggiorna=true la rilancia; le immagini sono sempre fresche
|
||||
g = _modulo(client)
|
||||
originale = g["list_sources"]
|
||||
chiamate = []
|
||||
|
||||
def finto(cartella, cameras=True, **kw):
|
||||
if cameras:
|
||||
chiamate.append(1)
|
||||
return originale(cartella, cameras=cameras, **kw)
|
||||
|
||||
g["list_sources"] = finto
|
||||
try:
|
||||
client.get("/camera/sources") # se la ricerca d'avvio e' ancora in corso aspetta il suo lock
|
||||
base = len(chiamate)
|
||||
_carica(client, "nuova.png", una_scacchiera())
|
||||
voci = client.get("/camera/sources").json()["sources"]
|
||||
client.get("/camera/sources")
|
||||
assert len(chiamate) == base # seconda e terza chiamata: nessuna nuova ricerca
|
||||
assert voci[-1]["name"] == "nuova.png" # ma l'immagine appena caricata c'e'
|
||||
client.get("/camera/sources", params={"aggiorna": "true"})
|
||||
assert len(chiamate) == base + 1 # aggiorna=true la rilancia
|
||||
client.get("/camera/sources")
|
||||
assert len(chiamate) == base + 1 # e il risultato nuovo torna in cache
|
||||
finally:
|
||||
g["list_sources"] = originale
|
||||
|
||||
|
||||
def test_sources_senza_immagini_non_crea_la_cartella(client):
|
||||
voci = client.get("/camera/sources").json()["sources"]
|
||||
assert all(v["kind"] != "image" for v in voci)
|
||||
|
||||
|
||||
# ---- grab / anteprima / calibrate su un'immagine caricata
|
||||
|
||||
def test_grab_anteprima_e_grab_ripetuti_su_immagine(client):
|
||||
_carica(client, "foto.png", una_scacchiera())
|
||||
for _ in range(3): # l'anteprima continua non deve esaurire un'immagine sola
|
||||
r = client.post("/camera/grab", json={"source": "foto.png"})
|
||||
assert r.status_code == 200 and r.headers["content-type"] == "image/png"
|
||||
for _ in range(3):
|
||||
r = _anteprima(client, "foto.png")
|
||||
assert r.status_code == 200 and r.headers["content-type"] == "image/jpeg"
|
||||
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("520", "400")
|
||||
|
||||
|
||||
def test_grab_immagine_inesistente_404(client):
|
||||
assert client.post("/camera/grab", json={"source": "non_c_e.png"}).status_code == 404
|
||||
|
||||
|
||||
def test_calibrate_su_immagine_origine_e_il_nome(client):
|
||||
_carica(client, "calibration_135_1.bmp", una_scacchiera())
|
||||
r = client.post("/camera/calibrate", data={"source": "calibration_135_1.bmp", **CAMPI})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["origine"] == "calibration_135_1.bmp"
|
||||
|
||||
|
||||
# ---- DELETE /camera/immagini
|
||||
|
||||
def test_elimina_204_poi_404_e_sparisce_dalle_sorgenti(client):
|
||||
_carica(client, "foto.png", una_scacchiera())
|
||||
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
|
||||
assert not (client.config / "immagini" / "foto.png").exists()
|
||||
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 404
|
||||
assert "foto.png" not in [v["name"] for v in client.get("/camera/sources").json()["sources"]]
|
||||
assert client.post("/camera/grab", json={"source": "foto.png"}).status_code == 404
|
||||
|
||||
|
||||
def test_elimina_nome_con_percorso_404(client):
|
||||
_carica(client, "foto.png", una_scacchiera())
|
||||
assert client.delete("/camera/immagini", params={"nome": "../immagini/foto.png"}).status_code == 404
|
||||
assert client.delete("/camera/immagini").status_code == 404
|
||||
assert (client.config / "immagini" / "foto.png").is_file()
|
||||
|
||||
|
||||
def test_elimina_chiude_la_sorgente_aperta(client, monkeypatch):
|
||||
chiuse = []
|
||||
modulo = _modulo(client)
|
||||
reale = modulo["open_source"]
|
||||
|
||||
def spia(*a, **k):
|
||||
s = reale(*a, **k)
|
||||
chiusura = s.close
|
||||
s.close = lambda: (chiuse.append(a[0]), chiusura())[1]
|
||||
return s
|
||||
|
||||
monkeypatch.setitem(modulo, "open_source", spia)
|
||||
_carica(client, "foto.png", una_scacchiera())
|
||||
assert client.post("/camera/grab", json={"source": "foto.png"}).status_code == 200
|
||||
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
|
||||
assert chiuse == ["foto.png"]
|
||||
|
||||
|
||||
# ---- POST /camera/anteprima/calibrata
|
||||
|
||||
def test_calibrata_senza_frame_404(client):
|
||||
_carica(client, DENSA, _densa())
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 404 and r.json()["detail"] == "nessun frame: avviare l'anteprima o scattare"
|
||||
|
||||
|
||||
def test_calibrata_senza_attiva_409(client):
|
||||
_carica(client, DENSA, _densa())
|
||||
assert _anteprima(client, DENSA).status_code == 200
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 409 and r.json()["detail"] == "nessuna calibrazione attiva su questa sorgente"
|
||||
|
||||
|
||||
def test_calibrata_validazione_ingresso(client):
|
||||
assert client.post("/camera/anteprima/calibrata", json={}).status_code == 400
|
||||
assert _calibrata(client, DENSA, lato_max=100).status_code == 400
|
||||
assert _calibrata(client, DENSA, lato_max=True).status_code == 400
|
||||
|
||||
|
||||
def test_calibrata_200_densa_senza_nuova_lettura(client, monkeypatch):
|
||||
modulo = _modulo(client)
|
||||
reale = modulo["open_source"]
|
||||
letture = []
|
||||
|
||||
def conta(*a, **k):
|
||||
s = reale(*a, **k)
|
||||
originale = s.grab
|
||||
s.grab = lambda t=1000: (letture.append(1), originale(t))[1]
|
||||
return s
|
||||
|
||||
monkeypatch.setitem(modulo, "open_source", conta)
|
||||
_carica(client, DENSA, _densa())
|
||||
_calibra_e_attiva(client, DENSA, "Banco àè 1")
|
||||
assert _anteprima(client, DENSA).status_code == 200
|
||||
prima = len(letture)
|
||||
for _ in range(3):
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.headers["content-type"] == "image/jpeg"
|
||||
assert r.headers["X-Raddrizzato"] == "si"
|
||||
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("640", "480")
|
||||
assert unquote(r.headers["X-Calibrazione"]) == "Banco àè 1"
|
||||
assert r.headers["X-Calibrazione"] == quote("Banco àè 1", safe="") # percent-encoded UTF-8
|
||||
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||
assert img.shape[:2] == (480, 640)
|
||||
assert len(letture) == prima # la calibrata non legge mai un frame nuovo
|
||||
|
||||
|
||||
def test_calibrata_ridotta_a_lato_max(client):
|
||||
_carica(client, DENSA, _densa())
|
||||
_calibra_e_attiva(client, DENSA, "Banco")
|
||||
assert _anteprima(client, DENSA).status_code == 200
|
||||
r = _calibrata(client, DENSA, lato_max=320)
|
||||
assert r.status_code == 200
|
||||
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||
assert max(img.shape[:2]) == 320
|
||||
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("640", "480") # misure del frame pieno
|
||||
|
||||
|
||||
def test_anteprima_grezza_e_calibrata_usano_lo_stesso_frame_grezzo(client):
|
||||
# anche con anteprima NON grezza la copia tenuta e' quella prima di raddrizzare
|
||||
_carica(client, DENSA, _densa())
|
||||
_calibra_e_attiva(client, DENSA, "Banco")
|
||||
r = client.post("/camera/anteprima", json={"source": DENSA}) # calibrata: raddrizzata
|
||||
assert r.status_code == 200 and r.headers["X-Raddrizzato"] == "si"
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 200 and r.headers["X-Raddrizzato"] == "si" # raddrizzata una volta sola
|
||||
|
||||
|
||||
def test_calibrata_risoluzione_diversa_409(client):
|
||||
_carica(client, DENSA, _densa())
|
||||
_calibra_e_attiva(client, DENSA, "Banco")
|
||||
# stessa sorgente, immagine sostituita con un'altra risoluzione: la calibrazione resta attiva
|
||||
assert _carica(client, DENSA, _densa(w=320, h=240)).status_code == 201
|
||||
assert _calibrata(client, DENSA).status_code == 404 # sostituire l'immagine scorda il frame vecchio
|
||||
assert _anteprima(client, DENSA).status_code == 200
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == "la calibrazione «Banco» è per immagini 640×480, il frame è 320×240"
|
||||
|
||||
|
||||
def test_calibrata_planare_senza_mappa_200_grezza(client):
|
||||
# #95: senza mappa (planare) non e' piu' 409: esce il frame grezzo, X-Raddrizzato: no
|
||||
_carica(client, PIANA, una_scacchiera())
|
||||
_calibra_e_attiva(client, PIANA, "Piana", cols="9", rows="6")
|
||||
assert _anteprima(client, PIANA).status_code == 200
|
||||
r = _calibrata(client, PIANA)
|
||||
assert r.status_code == 200
|
||||
assert r.headers["X-Raddrizzato"] == "no" and unquote(r.headers["X-Calibrazione"]) == "Piana"
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Test della pagina Acquisizione camera (#87): contenuto statico di PAGINA, senza servizio."""
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
|
||||
_spec = importlib.util.spec_from_file_location("calibrazione_pagina", _FILE)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod)
|
||||
PAGINA = _mod.PAGINA
|
||||
|
||||
|
||||
def test_titolo_e_stile_scuro():
|
||||
assert "<title>Acquisizione camera</title>" in PAGINA
|
||||
assert "#0f172a" in PAGINA and "#1e293b" in PAGINA and "#3b82f6" in PAGINA
|
||||
|
||||
|
||||
def test_rotte_usate():
|
||||
for rotta in ("/camera/sources", "/camera/anteprima", "/camera/parametri",
|
||||
"/camera/parametri/salva", "/camera/calibrate", "/camera/calibration"):
|
||||
assert '"' + rotta in PAGINA, rotta
|
||||
|
||||
|
||||
def test_offline_e_senza_finestre_del_browser():
|
||||
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
|
||||
assert vietato not in PAGINA, vietato
|
||||
|
||||
|
||||
def test_id_principali():
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
attesi = {"sorgente", "avvia", "ferma", "scatta", "mirinoCasella", "immagine",
|
||||
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
|
||||
"parametri", "salvaParametri", "quadrato", "acquisisci", "caricaImmagine", "fileImmagine",
|
||||
"mostraCal", "salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga; #92: file e calibrata nell'Acquisizione
|
||||
assert attesi <= ids, attesi - ids
|
||||
# ogni id usato dal JS con $("...") esiste nel markup
|
||||
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
|
||||
assert usati <= ids, usati - ids
|
||||
|
||||
|
||||
def test_sezioni_e_testi_italiani():
|
||||
for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione",
|
||||
"Salva come predefiniti", "Carica immagine da file...", "Questa sorgente non ha parametri regolabili",
|
||||
"Attiva calibrazione"): # #92 #93
|
||||
assert testo in PAGINA, testo
|
||||
assert "it-IT" in PAGINA # numeri con la virgola
|
||||
assert "Choose File" not in PAGINA
|
||||
|
||||
|
||||
def test_localstorage_protetto():
|
||||
# ogni uso di localStorage sta dentro un try
|
||||
for m in re.finditer(r"localStorage[.]", PAGINA):
|
||||
assert "try {" in PAGINA[max(0, m.start() - 200):m.start()]
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Test della pagina a due tab (#91, #92): contenuto statico di PAGINA, senza servizio."""
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
|
||||
_spec = importlib.util.spec_from_file_location("calibrazione_pagina_tab", _FILE)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod)
|
||||
PAGINA = _mod.PAGINA
|
||||
|
||||
|
||||
def test_due_tab_con_ruolo_e_selezione():
|
||||
assert re.search(r'<button id="tabAcq"[^>]*role="tab"[^>]*aria-selected="true"', PAGINA)
|
||||
assert re.search(r'<button id="tabCal"[^>]*role="tab"[^>]*aria-selected="false"', PAGINA)
|
||||
assert ">Acquisizione</button>" in PAGINA and ">Calibrazione</button>" in PAGINA
|
||||
assert 'role="tablist"' in PAGINA and 'role="tabpanel"' in PAGINA
|
||||
assert 'aria-selected", cal ?' in PAGINA # la selezione cambia da JS
|
||||
assert '"tmf.acq.tab"' in PAGINA # tab scelta ricordata (via memoria(), protetta)
|
||||
|
||||
|
||||
def test_sorgente_in_cima_poi_riquadri():
|
||||
# #92: Sorgente, anteprima e parametri stanno nel pannello Acquisizione; Sorgente prima dei riquadri
|
||||
i_acq = PAGINA.index('id="pannelloAcq"')
|
||||
i_cal = PAGINA.index('id="pannelloCal"')
|
||||
i_sorg = PAGINA.index('id="sezioneSorgente"')
|
||||
i_ant = PAGINA.index('id="sezioneAnteprima"')
|
||||
i_riq = PAGINA.index('id="riquadri"')
|
||||
assert i_acq < i_sorg < i_ant < i_riq < PAGINA.index('id="sezioneParametri"') < i_cal
|
||||
assert PAGINA.index('id="sezioneCalibrazione"') > i_cal
|
||||
# barra: casella calibrata e mirino; la vecchia casella «Immagine grezza» non c'e' piu'
|
||||
assert 'id="mostraCal"' in PAGINA and "Attiva calibrazione" in PAGINA
|
||||
assert 'id="grezzo"' not in PAGINA and "Immagine grezza (senza correzione)" not in PAGINA
|
||||
# riquadri: «Immagine» sempre visibile, «Calibrata» nascosta finche' la casella e' spenta
|
||||
assert re.search(r'<div class="riquadro" id="riquadroCal" hidden>', PAGINA)
|
||||
assert "grezzo: true" in PAGINA
|
||||
|
||||
|
||||
def test_id_del_pannello_calibrazione():
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
attesi = {"calSorgente", "quadrato", "acquisisci", "nomeCal", "salva", "msgSalva", "calibrazioni",
|
||||
"msgElenco", "stato", "risultato", "overlay",
|
||||
"caricaImmagine", "fileImmagine", "rimuoviImmagine", "riquadroCal", "immagineCal", "mirinoCal"}
|
||||
assert attesi <= ids, attesi - ids
|
||||
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
|
||||
assert usati <= ids, usati - ids
|
||||
assert len(re.findall(r'id="([^"]+)"', PAGINA)) == len(ids) # nessun id doppio
|
||||
|
||||
|
||||
def test_calibrazione_senza_file_ne_radio():
|
||||
# #92: l'immagine da file e' una sorgente di acquisizione, non un'opzione della calibrazione
|
||||
for sparito in ("origFile", "origSorgente", "scegliFile", "nomeFile", 'type="radio"', "Immagine da file", "Sorgente selezionata"):
|
||||
assert sparito not in PAGINA, sparito
|
||||
# l'unico input file e' nella sezione Sorgente, in cima alla tab Acquisizione
|
||||
assert PAGINA.count('type="file"') == 1
|
||||
assert PAGINA.index('type="file"') < PAGINA.index('id="pannelloCal"')
|
||||
assert "immagine grezza dalla tab Acquisizione" in PAGINA
|
||||
# il calcolo manda solo la sorgente (e il lato), mai il file
|
||||
assert 'f.append("source", sorgente)' in PAGINA and 'f.append("image"' not in PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
|
||||
|
||||
|
||||
def test_testi_del_pannello():
|
||||
for testo in ("Camera o immagine", "Carica immagine da file...", "Rimuovi immagine", "Calcola",
|
||||
"Calibrazioni salvate", "risoluzione diversa", "Calibrata",
|
||||
"Carica", "Elimina", "Scollega", "Sovrascrivi «", "Clic ancora", "Nome",
|
||||
"calibrata: ", "non calibrata"):
|
||||
assert testo in PAGINA, testo
|
||||
|
||||
|
||||
def test_rotte_del_contratto():
|
||||
for rotta in ("/camera/calibrazioni", "/camera/calibrazione-attiva", "/camera/calibrate",
|
||||
"/camera/calibration?source=", "/camera/immagini", "/camera/immagini?nome=",
|
||||
"/camera/anteprima/calibrata"): # #92
|
||||
assert '"' + rotta in PAGINA, rotta
|
||||
for parola in ("calcolo_id", "sovrascrivi", "method: \"PUT\"", "method: \"DELETE\"", "X-Larghezza", "X-Altezza"):
|
||||
assert parola in PAGINA, parola
|
||||
# il calcolo non salva piu': il campo save sparisce
|
||||
assert 'append("save"' not in PAGINA
|
||||
|
||||
|
||||
def test_nessuna_finestra_del_browser_e_offline():
|
||||
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
|
||||
assert vietato not in PAGINA, vietato
|
||||
|
||||
|
||||
def test_acquisizione_di_87_90_conservata():
|
||||
# anteprima, indicatori, mirino, grezzo, parametri camera e ciclo a 100 ms restano
|
||||
for parola in ("Parametri camera", "Mirino centrale",
|
||||
"Math.max(100 - (Date.now() - t0), 0)", "/camera/anteprima", "/camera/parametri"):
|
||||
assert parola in PAGINA, parola
|
||||
|
||||
|
||||
def test_pagina_stretta():
|
||||
assert "@media (max-width: 500px)" in PAGINA
|
||||
# #91: elenco a schede, niente tabella larga ne' scorrimento orizzontale
|
||||
assert 'id="calibrazioni" class="elenco"' in PAGINA and "overflow-x" not in PAGINA
|
||||
assert "Lettura dei parametri..." in PAGINA
|
||||
|
||||
|
||||
def test_ciclo_non_sospeso_nella_tab_calibrazione():
|
||||
# #94: il ciclo non si ferma piu' entrando in Calibrazione (prima #92: A.riprendi); Calcola senza blocco aspetta le richieste in volo
|
||||
assert "A.riprendi" not in PAGINA
|
||||
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
|
||||
assert "fermaAnteprima();" in calcola and "A.inVolo) await pausa(50)" in calcola
|
||||
|
||||
|
||||
def test_calibrata_dopo_anteprima_e_dopo_carica():
|
||||
# #92: la calibrata segue ogni anteprima grezza riuscita e si aggiorna dopo Carica/Scollega; 404/409 al posto dell'immagine
|
||||
assert "await aggiornaCalibrata(lato);" in PAGINA
|
||||
assert PAGINA.count("aggiornaCalibrata();") >= 3
|
||||
assert "messaggioCalibrata(await dettaglio(r))" in PAGINA
|
||||
assert 'decodeURIComponent(r.headers.get("X-Calibrazione")' in PAGINA
|
||||
|
||||
|
||||
def test_elenco_sorgenti_senza_attese_e_calibrata_solo_se_serve():
|
||||
# #92: la voce dell'immagine caricata si aggiunge in pagina (niente rilettura dell'elenco); «Aggiorna» forza la ricerca camere
|
||||
assert "aggiungiSorgente(j);" in PAGINA and "caricaSorgenti(j.source)" not in PAGINA
|
||||
assert 'id="aggiornaSorgenti"' in PAGINA and ">Aggiorna</button>" in PAGINA
|
||||
assert '"?aggiorna=true"' in PAGINA
|
||||
# nessuna calibrazione attiva: il riquadro lo dice senza chiamare il servizio; dopo un 409 non si richiede piu'
|
||||
assert "if (A.calAttiva === false)" in PAGINA
|
||||
assert "nessuna calibrazione attiva su questa sorgente" in PAGINA
|
||||
assert "if (r.status === 409) A.bloccoCal = true;" in PAGINA and "if (A.bloccoCal) return;" in PAGINA
|
||||
assert PAGINA.count("A.bloccoCal = false;") >= 3 # cambio sorgente, Carica, Scollega
|
||||
|
||||
|
||||
def test_attiva_calibrazione_con_scelta_e_conferma():
|
||||
# #93: sotto la calibrata una combo e CONFERMA; la casella cambia nome
|
||||
for i in ('id="sceltaCal"', 'id="confermaCal"', 'id="msgSceltaCal"'):
|
||||
assert i in PAGINA, i
|
||||
assert "Attiva calibrazione" in PAGINA and ">CONFERMA</button>" in PAGINA
|
||||
assert "Mostra immagine calibrata" not in PAGINA
|
||||
assert PAGINA.index('id="riquadroCal"') < PAGINA.index('id="sceltaCal"') < PAGINA.index('id="confermaCal"') < PAGINA.index('id="msgSceltaCal"')
|
||||
# il gestore di CONFERMA usa PUT calibrazione-attiva (via caricaNome) o DELETE calibration (via scollega), messaggio sotto la calibrata
|
||||
gestore = PAGINA[PAGINA.index('$("confermaCal").onclick'):]
|
||||
gestore = gestore[:gestore.index("};")]
|
||||
assert 'caricaNome(nome, "msgSceltaCal")' in gestore and 'scollega("msgSceltaCal")' in gestore
|
||||
assert 'method: "PUT"' in PAGINA and "/camera/calibrazione-attiva" in PAGINA
|
||||
assert 'method: "DELETE"' in PAGINA and '"/camera/calibration?source="' in PAGINA
|
||||
# voce vuota «nessuna», suffisso di risoluzione, combo ridisegnata con l'elenco e spenta senza sorgente
|
||||
assert "— nessuna (non calibrata) —" in PAGINA and "— risoluzione diversa" in PAGINA
|
||||
assert "disegnaSceltaCal();" in PAGINA and "= !$(\"sorgente\").value" in PAGINA
|
||||
@@ -1,26 +0,0 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Installa dipendenze sistema per WeasyPrint
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libcairo2 \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
libffi-dev \
|
||||
shared-mime-info \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Crea directory uploads
|
||||
RUN mkdir -p uploads/images uploads/pdfs uploads/logos uploads/reports
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Entry point: Alembic upgrade + Uvicorn
|
||||
CMD ["sh", "-c", "alembic -c migrations/alembic.ini upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000 --workers 2"]
|
||||
@@ -1,58 +0,0 @@
|
||||
"""TieMeasureFlow Server Configuration."""
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
# Database
|
||||
db_host: str = "localhost"
|
||||
db_port: int = 3306
|
||||
db_name: str = "tiemeasureflow"
|
||||
db_user: str = "tmflow"
|
||||
db_password: str = "change_me_in_production"
|
||||
|
||||
# Server
|
||||
server_host: str = "0.0.0.0"
|
||||
server_port: int = 8000
|
||||
server_secret_key: str = "change-this-to-a-random-secret-key"
|
||||
server_cors_origins: str = "http://localhost:5000"
|
||||
|
||||
# File Storage
|
||||
upload_dir: str = "uploads"
|
||||
max_upload_size_mb: int = 50
|
||||
|
||||
# Rate Limiting (requests per minute)
|
||||
rate_limit_login: int = 5
|
||||
rate_limit_general: int = 100
|
||||
|
||||
# SSL (Production)
|
||||
ssl_certfile: str | None = None
|
||||
ssl_keyfile: str | None = None
|
||||
|
||||
# Setup page (empty = disabled)
|
||||
setup_password: str | None = None
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""Async MySQL connection string."""
|
||||
return (
|
||||
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
|
||||
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
||||
)
|
||||
|
||||
@property
|
||||
def cors_origins(self) -> list[str]:
|
||||
"""Parse CORS origins from comma-separated string."""
|
||||
return [origin.strip() for origin in self.server_cors_origins.split(",")]
|
||||
|
||||
@property
|
||||
def upload_path(self) -> Path:
|
||||
"""Absolute path to upload directory."""
|
||||
return Path(__file__).parent / self.upload_dir
|
||||
|
||||
model_config = {"env_file": "../.env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -1,104 +0,0 @@
|
||||
"""Rate limiting middleware for FastAPI.
|
||||
|
||||
Implements in-memory sliding window rate limiting per client IP.
|
||||
Configurable limits for login and general endpoints.
|
||||
"""
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from config import settings
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware that enforces per-IP rate limits using a sliding window.
|
||||
|
||||
- Login endpoint (/api/auth/login): limited to `rate_limit_login` req/min.
|
||||
- All other endpoints: limited to `rate_limit_general` req/min.
|
||||
Returns HTTP 429 with Retry-After header when limit exceeded.
|
||||
"""
|
||||
|
||||
LOGIN_PATH = "/api/auth/login"
|
||||
WINDOW_SECONDS = 60
|
||||
|
||||
def __init__(self, app) -> None:
|
||||
super().__init__(app)
|
||||
# {ip: [timestamp, ...]} per bucket
|
||||
self._login_requests: dict[str, list[float]] = defaultdict(list)
|
||||
self._general_requests: dict[str, list[float]] = defaultdict(list)
|
||||
self._request_count = 0 # Counter for triggering eviction
|
||||
|
||||
def _clean_window(self, timestamps: list[float], now: float) -> list[float]:
|
||||
"""Remove timestamps outside the current sliding window."""
|
||||
cutoff = now - self.WINDOW_SECONDS
|
||||
return [t for t in timestamps if t > cutoff]
|
||||
|
||||
def _evict_stale_ips(self, bucket: dict[str, list[float]], now: float) -> None:
|
||||
"""Remove IP entries with no timestamps in the current window (memory leak prevention)."""
|
||||
cutoff = now - self.WINDOW_SECONDS
|
||||
stale_ips = [ip for ip, timestamps in bucket.items() if not timestamps or max(timestamps) <= cutoff]
|
||||
for ip in stale_ips:
|
||||
del bucket[ip]
|
||||
|
||||
def _check_rate_limit(
|
||||
self,
|
||||
bucket: dict[str, list[float]],
|
||||
client_ip: str,
|
||||
limit: int,
|
||||
now: float,
|
||||
) -> tuple[bool, int]:
|
||||
"""Check if a request is within the rate limit.
|
||||
|
||||
Returns:
|
||||
Tuple of (allowed, retry_after_seconds).
|
||||
"""
|
||||
bucket[client_ip] = self._clean_window(bucket[client_ip], now)
|
||||
|
||||
if len(bucket[client_ip]) >= limit:
|
||||
# Calculate seconds until the oldest request falls out of window
|
||||
oldest = bucket[client_ip][0]
|
||||
retry_after = int(oldest + self.WINDOW_SECONDS - now) + 1
|
||||
return False, max(retry_after, 1)
|
||||
|
||||
bucket[client_ip].append(now)
|
||||
return True, 0
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
now = time.time()
|
||||
path = request.url.path
|
||||
|
||||
# Periodic eviction: every 100 requests, remove stale IP buckets
|
||||
self._request_count += 1
|
||||
if self._request_count % 100 == 0:
|
||||
self._evict_stale_ips(self._login_requests, now)
|
||||
self._evict_stale_ips(self._general_requests, now)
|
||||
|
||||
# Check login-specific rate limit
|
||||
if path == self.LOGIN_PATH and request.method == "POST":
|
||||
allowed, retry_after = self._check_rate_limit(
|
||||
self._login_requests, client_ip, settings.rate_limit_login, now
|
||||
)
|
||||
if not allowed:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "Too many login attempts. Please try again later."},
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
# Check general rate limit
|
||||
allowed, retry_after = self._check_rate_limit(
|
||||
self._general_requests, client_ip, settings.rate_limit_general, now
|
||||
)
|
||||
if not allowed:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "Too many requests. Please try again later."},
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
@@ -1,19 +0,0 @@
|
||||
"""SQLAlchemy models for TieMeasureFlow."""
|
||||
from models.user import User
|
||||
from models.recipe import Recipe, RecipeVersion
|
||||
from models.task import RecipeTask, RecipeSubtask
|
||||
from models.measurement import Measurement
|
||||
from models.access_log import AccessLog
|
||||
from models.setting import SystemSetting, RecipeVersionAudit
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Recipe",
|
||||
"RecipeVersion",
|
||||
"RecipeTask",
|
||||
"RecipeSubtask",
|
||||
"Measurement",
|
||||
"AccessLog",
|
||||
"SystemSetting",
|
||||
"RecipeVersionAudit",
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_functions = test_*
|
||||
@@ -1,37 +0,0 @@
|
||||
# FastAPI + ASGI
|
||||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
|
||||
# Database
|
||||
sqlalchemy[asyncio]>=2.0.0
|
||||
asyncmy>=0.2.0
|
||||
alembic>=1.13.0
|
||||
|
||||
# Validation
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
|
||||
# Security
|
||||
bcrypt>=4.0.0
|
||||
|
||||
# File handling
|
||||
pillow>=10.0.0
|
||||
python-multipart>=0.0.6
|
||||
|
||||
# Templating (setup page)
|
||||
jinja2>=3.1.0
|
||||
|
||||
# Reports
|
||||
plotly>=5.0.0
|
||||
kaleido>=0.2.0
|
||||
weasyprint>=62.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Testing
|
||||
pytest>=8.0.0
|
||||
pytest-asyncio>=0.23.0
|
||||
httpx>=0.27.0
|
||||
aiosqlite>=0.20.0
|
||||
coverage>=7.0.0
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Authentication router - login, logout, profile."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import get_current_user
|
||||
from models.user import User
|
||||
from schemas.user import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
UserProfileUpdate,
|
||||
UserResponse,
|
||||
)
|
||||
from services.auth_service import authenticate_user, login_user, logout_user
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Login with username and password, receive API key."""
|
||||
user = await authenticate_user(db, data.username, data.password)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid username or password",
|
||||
)
|
||||
api_key = await login_user(db, user)
|
||||
return LoginResponse(
|
||||
user=UserResponse.model_validate(user),
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Logout - invalidate API key."""
|
||||
await logout_user(db, user)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_profile(user: User = Depends(get_current_user)):
|
||||
"""Get current user profile."""
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.put("/me", response_model=UserResponse)
|
||||
async def update_profile(
|
||||
data: UserProfileUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update own profile (display_name, language, theme)."""
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
return UserResponse.model_validate(user)
|
||||
@@ -1,234 +0,0 @@
|
||||
"""File upload/download/delete router for images and PDFs."""
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from PIL import Image
|
||||
|
||||
from config import settings
|
||||
from middleware.api_key import get_current_user, require_maker
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter(prefix="/api/files", tags=["files"])
|
||||
|
||||
# Allowed MIME types
|
||||
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||
ALLOWED_PDF_TYPE = "application/pdf"
|
||||
ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | {ALLOWED_PDF_TYPE}
|
||||
|
||||
|
||||
def validate_file_type(content_type: str) -> bool:
|
||||
"""Validate if file type is allowed."""
|
||||
return content_type in ALLOWED_TYPES
|
||||
|
||||
|
||||
def validate_file_size(file_size: int) -> bool:
|
||||
"""Validate if file size is within limits."""
|
||||
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||
return file_size <= max_bytes
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""Sanitize an uploaded filename to prevent path traversal and injection.
|
||||
|
||||
- Strips directory path separators (/ and \\).
|
||||
- Only allows alphanumeric characters, dots, hyphens, and underscores.
|
||||
- Rejects filenames starting with '.' (hidden files).
|
||||
- Rejects empty filenames.
|
||||
|
||||
Returns:
|
||||
The sanitized filename.
|
||||
|
||||
Raises:
|
||||
HTTPException: If filename is empty or starts with '.'.
|
||||
"""
|
||||
# Strip any path separators to prevent directory traversal
|
||||
filename = filename.replace("/", "").replace("\\", "")
|
||||
|
||||
# Remove any characters not in the allowed set
|
||||
filename = re.sub(r"[^a-zA-Z0-9._-]", "", filename)
|
||||
|
||||
if not filename:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Filename is empty or contains only invalid characters.",
|
||||
)
|
||||
|
||||
if filename.startswith("."):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Filenames starting with '.' are not allowed.",
|
||||
)
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[int, int] = (200, 200)):
|
||||
"""Generate a thumbnail for an image."""
|
||||
try:
|
||||
with Image.open(image_path) as img:
|
||||
img.thumbnail(max_size, Image.Resampling.LANCZOS)
|
||||
img.save(thumbnail_path, quality=85)
|
||||
except Exception as e:
|
||||
# If thumbnail generation fails, we continue without it
|
||||
print(f"Thumbnail generation failed: {e}")
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
recipe_id: int | None = None,
|
||||
version_id: int | None = None,
|
||||
user: User = Depends(require_maker),
|
||||
):
|
||||
"""Upload an image or PDF file.
|
||||
|
||||
Files are stored in uploads/{recipe_id}/{version_id}/
|
||||
Thumbnails are generated for images.
|
||||
"""
|
||||
# Validate file type
|
||||
if not validate_file_type(file.content_type):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File type {file.content_type} not allowed. Must be image or PDF.",
|
||||
)
|
||||
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
# Validate file size
|
||||
if not validate_file_size(file_size):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File size {file_size} bytes exceeds maximum {settings.max_upload_size_mb}MB",
|
||||
)
|
||||
|
||||
# Determine storage path
|
||||
if recipe_id is not None and version_id is not None:
|
||||
storage_dir = settings.upload_path / str(recipe_id) / str(version_id)
|
||||
else:
|
||||
storage_dir = settings.upload_path / "general"
|
||||
|
||||
storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Sanitize the uploaded filename
|
||||
safe_filename = sanitize_filename(file.filename or "")
|
||||
|
||||
# Generate unique filename if file already exists
|
||||
file_path = storage_dir / safe_filename
|
||||
counter = 1
|
||||
while file_path.exists():
|
||||
name_parts = safe_filename.rsplit(".", 1)
|
||||
if len(name_parts) == 2:
|
||||
file_path = storage_dir / f"{name_parts[0]}_{counter}.{name_parts[1]}"
|
||||
else:
|
||||
file_path = storage_dir / f"{safe_filename}_{counter}"
|
||||
counter += 1
|
||||
|
||||
# Write file
|
||||
file_path.write_bytes(content)
|
||||
|
||||
# Generate thumbnail for images
|
||||
thumbnail_path = None
|
||||
if file.content_type in ALLOWED_IMAGE_TYPES:
|
||||
thumbnail_dir = storage_dir / "thumbnails"
|
||||
thumbnail_dir.mkdir(exist_ok=True)
|
||||
thumbnail_path = thumbnail_dir / file_path.name
|
||||
generate_thumbnail(file_path, thumbnail_path)
|
||||
|
||||
# Return relative path from uploads directory
|
||||
relative_path = file_path.relative_to(settings.upload_path)
|
||||
thumbnail_relative = (
|
||||
thumbnail_path.relative_to(settings.upload_path)
|
||||
if thumbnail_path
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"file_path": str(relative_path).replace("\\", "/"),
|
||||
"thumbnail_path": str(thumbnail_relative).replace("\\", "/") if thumbnail_relative else None,
|
||||
"file_type": file.content_type,
|
||||
"file_size": file_size,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{file_path:path}")
|
||||
async def get_file(
|
||||
file_path: str,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Serve a file from the uploads directory.
|
||||
|
||||
Requires authentication but no specific role.
|
||||
"""
|
||||
# Construct full path
|
||||
full_path = settings.upload_path / file_path
|
||||
|
||||
# Security: ensure path is within uploads directory
|
||||
try:
|
||||
full_path = full_path.resolve()
|
||||
if not str(full_path).startswith(str(settings.upload_path.resolve())):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied",
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found",
|
||||
)
|
||||
|
||||
# Check if file exists
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found",
|
||||
)
|
||||
|
||||
return FileResponse(full_path)
|
||||
|
||||
|
||||
@router.delete("/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_file(
|
||||
file_path: str,
|
||||
user: User = Depends(require_maker),
|
||||
):
|
||||
"""Delete a file from the uploads directory.
|
||||
|
||||
Also deletes associated thumbnail if it exists.
|
||||
"""
|
||||
# Construct full path
|
||||
full_path = settings.upload_path / file_path
|
||||
|
||||
# Security: ensure path is within uploads directory
|
||||
try:
|
||||
full_path = full_path.resolve()
|
||||
if not str(full_path).startswith(str(settings.upload_path.resolve())):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied",
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found",
|
||||
)
|
||||
|
||||
# Check if file exists
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found",
|
||||
)
|
||||
|
||||
# Delete thumbnail if it exists
|
||||
if full_path.parent.name != "thumbnails":
|
||||
thumbnail_path = full_path.parent / "thumbnails" / full_path.name
|
||||
if thumbnail_path.exists():
|
||||
thumbnail_path.unlink()
|
||||
|
||||
# Delete file
|
||||
full_path.unlink()
|
||||
@@ -1,153 +0,0 @@
|
||||
"""System settings router - read/update settings, upload company logo."""
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
from database import get_db
|
||||
from middleware.api_key import get_current_user, require_admin_user
|
||||
from models.setting import SystemSetting
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_settings(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get all system settings as a dictionary.
|
||||
|
||||
Returns key-value pairs of all settings.
|
||||
Available to all authenticated users.
|
||||
"""
|
||||
result = await db.execute(select(SystemSetting))
|
||||
settings_list = result.scalars().all()
|
||||
|
||||
settings_dict = {
|
||||
setting.setting_key: setting.setting_value
|
||||
for setting in settings_list
|
||||
}
|
||||
|
||||
return settings_dict
|
||||
|
||||
|
||||
@router.put("/")
|
||||
async def update_settings(
|
||||
settings_data: dict[str, str],
|
||||
user: User = Depends(require_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update multiple system settings.
|
||||
|
||||
Expects a JSON object with setting_key: setting_value pairs.
|
||||
Only admins can update settings.
|
||||
"""
|
||||
updated_keys = []
|
||||
|
||||
for key, value in settings_data.items():
|
||||
# Check if setting exists
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.setting_key == key)
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
# Create new setting (assume type 'string' for new settings)
|
||||
setting = SystemSetting(
|
||||
setting_key=key,
|
||||
setting_value=value,
|
||||
setting_type="string",
|
||||
updated_by=user.id,
|
||||
)
|
||||
db.add(setting)
|
||||
else:
|
||||
# Update existing setting
|
||||
setting.setting_value = value
|
||||
setting.updated_by = user.id
|
||||
|
||||
updated_keys.append(key)
|
||||
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"message": f"Updated {len(updated_keys)} settings",
|
||||
"updated_keys": updated_keys,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/logo")
|
||||
async def upload_company_logo(
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(require_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Upload company logo.
|
||||
|
||||
Saves logo to uploads/logos/ and updates company_logo_path setting.
|
||||
Only admins can upload the logo.
|
||||
"""
|
||||
# Validate file type (must be image)
|
||||
allowed_types = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"}
|
||||
if file.content_type not in allowed_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File type {file.content_type} not allowed. Must be an image.",
|
||||
)
|
||||
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
# Validate file size
|
||||
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||
if file_size > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File size {file_size} bytes exceeds maximum {settings.max_upload_size_mb}MB",
|
||||
)
|
||||
|
||||
# Create logos directory
|
||||
logos_dir = settings.upload_path / "logos"
|
||||
logos_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Use a fixed filename for the company logo
|
||||
file_extension = Path(file.filename).suffix
|
||||
logo_filename = f"company_logo{file_extension}"
|
||||
logo_path = logos_dir / logo_filename
|
||||
|
||||
# Write file
|
||||
logo_path.write_bytes(content)
|
||||
|
||||
# Update setting in database
|
||||
relative_path = logo_path.relative_to(settings.upload_path)
|
||||
logo_path_str = str(relative_path).replace("\\", "/")
|
||||
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.setting_key == "company_logo_path")
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = SystemSetting(
|
||||
setting_key="company_logo_path",
|
||||
setting_value=logo_path_str,
|
||||
setting_type="string",
|
||||
description="Path to company logo file",
|
||||
updated_by=user.id,
|
||||
)
|
||||
db.add(setting)
|
||||
else:
|
||||
setting.setting_value = logo_path_str
|
||||
setting.updated_by = user.id
|
||||
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"message": "Company logo uploaded successfully",
|
||||
"logo_path": logo_path_str,
|
||||
"file_size": file_size,
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Pydantic schemas for Measurement operations."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class MeasurementCreate(BaseModel):
|
||||
"""Schema for creating a single measurement."""
|
||||
subtask_id: int
|
||||
version_id: int
|
||||
value: float
|
||||
lot_number: Optional[str] = Field(None, max_length=100)
|
||||
serial_number: Optional[str] = Field(None, max_length=100)
|
||||
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
|
||||
|
||||
|
||||
class MeasurementBatchCreate(BaseModel):
|
||||
"""Schema for creating multiple measurements at once."""
|
||||
measurements: list[MeasurementCreate]
|
||||
|
||||
|
||||
class MeasurementResponse(BaseModel):
|
||||
"""Schema for measurement response."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
subtask_id: int
|
||||
version_id: int
|
||||
measured_by: int
|
||||
value: float
|
||||
pass_fail: str
|
||||
deviation: Optional[float] = None
|
||||
lot_number: Optional[str] = None
|
||||
serial_number: Optional[str] = None
|
||||
input_method: str
|
||||
measured_at: datetime
|
||||
synced_to_csv: bool
|
||||
|
||||
|
||||
class MeasurementListResponse(BaseModel):
|
||||
"""Schema for paginated measurement list."""
|
||||
items: list[MeasurementResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
class MeasurementQuery(BaseModel):
|
||||
"""Schema for measurement query filters."""
|
||||
recipe_id: Optional[int] = None
|
||||
version_id: Optional[int] = None
|
||||
subtask_id: Optional[int] = None
|
||||
measured_by: Optional[int] = None
|
||||
lot_number: Optional[str] = None
|
||||
serial_number: Optional[str] = None
|
||||
date_from: Optional[datetime] = None
|
||||
date_to: Optional[datetime] = None
|
||||
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
|
||||
page: int = Field(1, ge=1)
|
||||
per_page: int = Field(50, ge=1, le=500)
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Pydantic schemas for Station and StationRecipeAssignment."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class StationCreate(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=100)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
location: Optional[str] = Field(default=None, max_length=255)
|
||||
notes: Optional[str] = None
|
||||
active: bool = True
|
||||
|
||||
|
||||
class StationUpdate(BaseModel):
|
||||
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
location: Optional[str] = Field(default=None, max_length=255)
|
||||
notes: Optional[str] = None
|
||||
active: Optional[bool] = None
|
||||
|
||||
|
||||
class StationResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
location: Optional[str]
|
||||
notes: Optional[str]
|
||||
active: bool
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class StationRecipeAssignmentCreate(BaseModel):
|
||||
recipe_id: int = Field(..., gt=0)
|
||||
|
||||
|
||||
class StationRecipeAssignmentResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
station_id: int
|
||||
recipe_id: int
|
||||
assigned_by: int
|
||||
assigned_at: datetime
|
||||
|
||||
|
||||
class RecipeSummary(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
active: bool
|
||||
|
||||
|
||||
class StationWithRecipesResponse(StationResponse):
|
||||
recipes: list[RecipeSummary] = Field(default_factory=list)
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class SubtaskCreate(BaseModel):
|
||||
"""Schema for creating a subtask."""
|
||||
marker_number: int = Field(..., ge=1)
|
||||
description: str = Field(..., min_length=1, max_length=500)
|
||||
measurement_type: Optional[str] = Field(None, max_length=100)
|
||||
nominal: Optional[float] = None
|
||||
utl: Optional[float] = None
|
||||
uwl: Optional[float] = None
|
||||
lwl: Optional[float] = None
|
||||
ltl: Optional[float] = None
|
||||
unit: str = Field("mm", max_length=20)
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class SubtaskUpdate(BaseModel):
|
||||
"""Schema for updating a subtask."""
|
||||
marker_number: Optional[int] = Field(None, ge=1)
|
||||
description: Optional[str] = Field(None, min_length=1, max_length=500)
|
||||
measurement_type: Optional[str] = Field(None, max_length=100)
|
||||
nominal: Optional[float] = None
|
||||
utl: Optional[float] = None
|
||||
uwl: Optional[float] = None
|
||||
lwl: Optional[float] = None
|
||||
ltl: Optional[float] = None
|
||||
unit: Optional[str] = Field(None, max_length=20)
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class SubtaskResponse(BaseModel):
|
||||
"""Schema for subtask response."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
task_id: int
|
||||
marker_number: int
|
||||
description: str
|
||||
measurement_type: Optional[str] = None
|
||||
nominal: Optional[float] = None
|
||||
utl: Optional[float] = None
|
||||
uwl: Optional[float] = None
|
||||
lwl: Optional[float] = None
|
||||
ltl: Optional[float] = None
|
||||
unit: str
|
||||
image_path: Optional[str] = None
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
"""Schema for creating a task."""
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
directive: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
subtasks: list[SubtaskCreate] = []
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
"""Schema for updating a task."""
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
directive: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
"""Schema for task response."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
version_id: int
|
||||
recipe_id: Optional[int] = None
|
||||
order_index: int
|
||||
title: str
|
||||
directive: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
file_path: Optional[str] = None
|
||||
file_type: Optional[str] = None
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
subtasks: list[SubtaskResponse] = []
|
||||
|
||||
|
||||
class TaskReorderRequest(BaseModel):
|
||||
"""Schema for reordering tasks."""
|
||||
task_ids: list[int] = Field(..., min_length=1)
|
||||
@@ -1,97 +0,0 @@
|
||||
"""Authentication service - password hashing, API key management."""
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.user import User
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt."""
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash."""
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode("utf-8"), hashed_password.encode("utf-8")
|
||||
)
|
||||
|
||||
|
||||
def generate_api_key() -> str:
|
||||
"""Generate a secure 64-character API key."""
|
||||
return secrets.token_urlsafe(48) # 64 chars base64
|
||||
|
||||
|
||||
async def authenticate_user(
|
||||
db: AsyncSession, username: str, password: str
|
||||
) -> User | None:
|
||||
"""Authenticate user by username and password."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.username == username, User.active == True)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or not verify_password(password, user.password_hash):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
async def login_user(db: AsyncSession, user: User) -> str:
|
||||
"""Generate API key and update last_login for user."""
|
||||
api_key = generate_api_key()
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(api_key=api_key, last_login=datetime.utcnow())
|
||||
)
|
||||
await db.flush()
|
||||
return api_key
|
||||
|
||||
|
||||
async def logout_user(db: AsyncSession, user: User) -> None:
|
||||
"""Invalidate user's API key."""
|
||||
await db.execute(
|
||||
update(User).where(User.id == user.id).values(api_key=None)
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def create_user(
|
||||
db: AsyncSession,
|
||||
username: str,
|
||||
password: str,
|
||||
display_name: str,
|
||||
email: str | None = None,
|
||||
roles: list[str] | None = None,
|
||||
is_admin: bool = False,
|
||||
language_pref: str = "it",
|
||||
theme_pref: str = "light",
|
||||
) -> User:
|
||||
"""Create a new user with hashed password."""
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
display_name=display_name,
|
||||
email=email,
|
||||
roles=roles or [],
|
||||
is_admin=is_admin,
|
||||
language_pref=language_pref,
|
||||
theme_pref=theme_pref,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def regenerate_api_key(db: AsyncSession, user_id: int) -> str:
|
||||
"""Regenerate API key for a user."""
|
||||
new_key = generate_api_key()
|
||||
await db.execute(
|
||||
update(User).where(User.id == user_id).values(api_key=new_key)
|
||||
)
|
||||
await db.flush()
|
||||
return new_key
|
||||
@@ -1,77 +0,0 @@
|
||||
"""Measurement service - pass/fail calculation, data storage."""
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.measurement import Measurement
|
||||
from models.task import RecipeSubtask
|
||||
|
||||
|
||||
def calculate_pass_fail(
|
||||
value: float, subtask: RecipeSubtask
|
||||
) -> tuple[str, float | None]:
|
||||
"""Calculate pass/fail status and deviation based on tolerances.
|
||||
|
||||
Returns (status, deviation) where status is 'pass', 'warning', or 'fail'.
|
||||
|
||||
Logic:
|
||||
- If value is outside UTL/LTL -> 'fail'
|
||||
- If value is outside UWL/LWL but inside UTL/LTL -> 'warning'
|
||||
- Otherwise -> 'pass'
|
||||
"""
|
||||
deviation = None
|
||||
if subtask.nominal is not None:
|
||||
deviation = float(Decimal(str(value)) - Decimal(str(subtask.nominal)))
|
||||
|
||||
# Check fail (outside tolerance limits)
|
||||
if subtask.utl is not None and value > float(subtask.utl):
|
||||
return "fail", deviation
|
||||
if subtask.ltl is not None and value < float(subtask.ltl):
|
||||
return "fail", deviation
|
||||
|
||||
# Check warning (outside warning limits)
|
||||
if subtask.uwl is not None and value > float(subtask.uwl):
|
||||
return "warning", deviation
|
||||
if subtask.lwl is not None and value < float(subtask.lwl):
|
||||
return "warning", deviation
|
||||
|
||||
return "pass", deviation
|
||||
|
||||
|
||||
async def save_measurement(
|
||||
db: AsyncSession,
|
||||
subtask_id: int,
|
||||
version_id: int,
|
||||
measured_by: int,
|
||||
value: float,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
input_method: str = "manual",
|
||||
) -> Measurement:
|
||||
"""Save a single measurement with auto-calculated pass/fail."""
|
||||
# Get subtask for tolerance values
|
||||
result = await db.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
||||
)
|
||||
subtask = result.scalar_one_or_none()
|
||||
if subtask is None:
|
||||
raise ValueError(f"Subtask {subtask_id} not found")
|
||||
|
||||
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
||||
|
||||
measurement = Measurement(
|
||||
subtask_id=subtask_id,
|
||||
version_id=version_id,
|
||||
measured_by=measured_by,
|
||||
value=value,
|
||||
pass_fail=pass_fail,
|
||||
deviation=deviation,
|
||||
lot_number=lot_number,
|
||||
serial_number=serial_number,
|
||||
input_method=input_method,
|
||||
)
|
||||
db.add(measurement)
|
||||
await db.flush()
|
||||
await db.refresh(measurement)
|
||||
return measurement
|
||||
@@ -1,96 +0,0 @@
|
||||
"""Tests for authentication router (/api/auth)."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from tests.conftest import _create_user, auth_headers
|
||||
|
||||
|
||||
class TestLogin:
|
||||
"""POST /api/auth/login tests."""
|
||||
|
||||
async def test_login_success(self, client: AsyncClient, db_session):
|
||||
"""Successful login returns user data and API key."""
|
||||
user = await _create_user(
|
||||
db_session, username="loginuser", password="SecurePass1"
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "loginuser", "password": "SecurePass1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["user"]["username"] == "loginuser"
|
||||
assert "api_key" in data
|
||||
assert len(data["api_key"]) > 0
|
||||
|
||||
async def test_login_wrong_password(self, client: AsyncClient, db_session):
|
||||
"""Login with wrong password returns 401."""
|
||||
await _create_user(
|
||||
db_session, username="wrongpw", password="CorrectPass1"
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "wrongpw", "password": "WrongPass1"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Invalid" in resp.json()["detail"]
|
||||
|
||||
async def test_login_unknown_user(self, client: AsyncClient):
|
||||
"""Login with non-existent username returns 401."""
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "nonexistent", "password": "Anything1"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_login_inactive_user(self, client: AsyncClient, db_session):
|
||||
"""Login with inactive user returns 401."""
|
||||
await _create_user(
|
||||
db_session,
|
||||
username="inactive_user",
|
||||
password="InactivePass1",
|
||||
active=False,
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "inactive_user", "password": "InactivePass1"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_login_missing_fields(self, client: AsyncClient):
|
||||
"""Login with missing fields returns 422."""
|
||||
resp = await client.post("/api/auth/login", json={"username": "only"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_login_returns_api_key(self, client: AsyncClient, db_session):
|
||||
"""Login response contains a valid api_key string."""
|
||||
await _create_user(
|
||||
db_session, username="apikey_user", password="KeyPass123"
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "apikey_user", "password": "KeyPass123"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
api_key = resp.json()["api_key"]
|
||||
# API key should be a non-empty string (base64url, ~64 chars)
|
||||
assert isinstance(api_key, str)
|
||||
assert len(api_key) >= 32
|
||||
|
||||
|
||||
class TestHealthAndAuth:
|
||||
"""Health check and auth requirement tests."""
|
||||
|
||||
async def test_health_check_no_auth(self, client: AsyncClient):
|
||||
"""Health check endpoint works without authentication."""
|
||||
resp = await client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert "version" in data
|
||||
|
||||
async def test_endpoint_requires_api_key(self, client: AsyncClient):
|
||||
"""Protected endpoint returns 401 without API key."""
|
||||
resp = await client.get("/api/users")
|
||||
assert resp.status_code == 401
|
||||
assert "API key" in resp.json()["detail"] or "Missing" in resp.json()["detail"]
|
||||
@@ -1,175 +0,0 @@
|
||||
"""Tests for files router (/api/files)."""
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from models.user import User
|
||||
from tests.conftest import auth_headers
|
||||
|
||||
|
||||
class TestUploadFile:
|
||||
"""POST /api/files/upload tests."""
|
||||
|
||||
async def test_upload_image(
|
||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||
):
|
||||
"""Maker can upload a valid image."""
|
||||
# Create a minimal valid JPEG (1x1 pixel)
|
||||
jpeg_bytes = (
|
||||
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01"
|
||||
b"\x00\x01\x00\x00\xff\xdb\x00C\x00\x08\x06\x06\x07\x06"
|
||||
b"\x05\x08\x07\x07\x07\t\t\x08\n\x0c\x14\r\x0c\x0b\x0b"
|
||||
b"\x0c\x19\x12\x13\x0f\x14\x1d\x1a\x1f\x1e\x1d\x1a\x1c"
|
||||
b"\x1c $.\' \",#\x1c\x1c(7),01444\x1f\'9=82<.342\xff\xc0"
|
||||
b"\x00\x0b\x08\x00\x01\x00\x01\x01\x01\x11\x00\xff\xc4"
|
||||
b"\x00\x1f\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00"
|
||||
b"\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05\x06"
|
||||
b"\x07\x08\t\n\x0b\xff\xc4\x00\xb5\x10\x00\x02\x01\x03"
|
||||
b"\x03\x02\x04\x03\x05\x05\x04\x04\x00\x00\x01}\x01\x02"
|
||||
b"\x03\x00\x04\x11\x05\x12!1A\x06\x13Qa\x07\"q\x142\x81"
|
||||
b"\x91\xa1\x08#B\xb1\xc1\x15R\xd1\xf0$3br\x82\t\n\x16"
|
||||
b"\x17\x18\x19\x1a%&\'()*456789:CDEFGHIJSTUVWXYZcdefghij"
|
||||
b"stuvwxyz\x83\x84\x85\x86\x87\x88\x89\x8a\x92\x93\x94"
|
||||
b"\x95\x96\x97\x98\x99\x9a\xa2\xa3\xa4\xa5\xa6\xa7\xa8"
|
||||
b"\xa9\xaa\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xc2\xc3"
|
||||
b"\xc4\xc5\xc6\xc7\xc8\xc9\xca\xd2\xd3\xd4\xd5\xd6\xd7"
|
||||
b"\xd8\xd9\xda\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea"
|
||||
b"\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xff\xda\x00"
|
||||
b"\x08\x01\x01\x00\x00?\x00T\xdb\xae\x8e\xd3H\xa5(?"
|
||||
b"\xff\xd9"
|
||||
)
|
||||
|
||||
# Patch settings.upload_path to use tmp_path
|
||||
with patch("routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
mock_settings.max_upload_size_mb = 50
|
||||
|
||||
resp = await client.post(
|
||||
"/api/files/upload",
|
||||
headers=auth_headers(maker_user),
|
||||
files={"file": ("test.jpg", io.BytesIO(jpeg_bytes), "image/jpeg")},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "file_path" in data
|
||||
assert data["file_type"] == "image/jpeg"
|
||||
assert data["file_size"] > 0
|
||||
|
||||
async def test_upload_invalid_type(
|
||||
self, client: AsyncClient, maker_user: User
|
||||
):
|
||||
"""Uploading a disallowed file type returns 400."""
|
||||
resp = await client.post(
|
||||
"/api/files/upload",
|
||||
headers=auth_headers(maker_user),
|
||||
files={
|
||||
"file": ("malware.exe", io.BytesIO(b"MZ\x00\x00"), "application/x-msdownload")
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "not allowed" in resp.json()["detail"]
|
||||
|
||||
async def test_upload_too_large(
|
||||
self, client: AsyncClient, maker_user: User
|
||||
):
|
||||
"""Uploading a file that exceeds size limit returns 400."""
|
||||
# Create a large file content
|
||||
large_content = b"\x00" * (51 * 1024 * 1024) # 51MB
|
||||
|
||||
with patch("routers.files.settings") as mock_settings:
|
||||
mock_settings.max_upload_size_mb = 50
|
||||
mock_settings.upload_path = Path(tempfile.mkdtemp())
|
||||
|
||||
resp = await client.post(
|
||||
"/api/files/upload",
|
||||
headers=auth_headers(maker_user),
|
||||
files={
|
||||
"file": ("big.jpg", io.BytesIO(large_content), "image/jpeg")
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "exceeds" in resp.json()["detail"]
|
||||
|
||||
|
||||
class TestGetFile:
|
||||
"""GET /api/files/{file_path} tests."""
|
||||
|
||||
async def test_get_file(
|
||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||
):
|
||||
"""Authenticated user can retrieve an uploaded file."""
|
||||
# Create a test file in tmp_path
|
||||
test_file = tmp_path / "testfile.txt"
|
||||
test_file.write_text("hello test")
|
||||
|
||||
with patch("routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.get(
|
||||
"/api/files/testfile.txt",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_get_file_not_found(
|
||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||
):
|
||||
"""Requesting a non-existent file returns 404."""
|
||||
with patch("routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.get(
|
||||
"/api/files/nonexistent.png",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestDeleteFile:
|
||||
"""DELETE /api/files/{file_path} tests."""
|
||||
|
||||
async def test_delete_file(
|
||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||
):
|
||||
"""Maker can delete a file."""
|
||||
test_file = tmp_path / "deleteme.txt"
|
||||
test_file.write_text("delete me")
|
||||
|
||||
with patch("routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.delete(
|
||||
"/api/files/deleteme.txt",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
|
||||
assert resp.status_code == 204
|
||||
assert not test_file.exists()
|
||||
|
||||
|
||||
class TestFilenameSanitization:
|
||||
"""Path traversal protection tests."""
|
||||
|
||||
async def test_path_traversal_blocked(
|
||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||
):
|
||||
"""Path traversal attempt returns 403 or 404."""
|
||||
with patch("routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.get(
|
||||
"/api/files/../../etc/passwd",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
|
||||
# Should be blocked by security check
|
||||
assert resp.status_code in (403, 404)
|
||||
@@ -1,278 +0,0 @@
|
||||
"""Tests for measurements router (/api/measurements)."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.user import User
|
||||
from models.recipe import RecipeVersion
|
||||
from models.task import RecipeTask, RecipeSubtask
|
||||
from tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
async def _get_subtask_and_version(
|
||||
client: AsyncClient, user: User, recipe_id: int
|
||||
) -> tuple[int, int]:
|
||||
"""Helper: return (subtask_id, version_id) from the recipe's current version."""
|
||||
tasks_resp = await client.get(
|
||||
f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user)
|
||||
)
|
||||
task = tasks_resp.json()[0]
|
||||
subtask_id = task["subtasks"][0]["id"]
|
||||
version_id = task["version_id"]
|
||||
return subtask_id, version_id
|
||||
|
||||
|
||||
class TestCreateMeasurement:
|
||||
"""POST /api/measurements/ tests."""
|
||||
|
||||
async def test_create_measurement(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""MeasurementTec can create a measurement."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id
|
||||
)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": 10.1,
|
||||
"input_method": "manual",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["value"] == pytest.approx(10.1, rel=1e-4)
|
||||
assert data["pass_fail"] in ("pass", "warning", "fail")
|
||||
assert data["measured_by"] == measurement_tec_user.id
|
||||
|
||||
async def test_measurement_requires_auth(self, client: AsyncClient):
|
||||
"""Creating measurement without auth returns 401."""
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
json={
|
||||
"subtask_id": 1,
|
||||
"version_id": 1,
|
||||
"value": 10.0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_measurement_validation(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
):
|
||||
"""Measurement with non-existent subtask returns 400."""
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": 99999,
|
||||
"version_id": 99999,
|
||||
"value": 10.0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestMeasurementPassFail:
|
||||
"""Pass/fail calculation tests."""
|
||||
|
||||
async def test_measurement_pass(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Value within tolerance -> pass."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id
|
||||
)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
# nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": 10.0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["pass_fail"] == "pass"
|
||||
|
||||
async def test_measurement_warning(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Value outside warning but inside tolerance -> warning."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id
|
||||
)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
# uwl=10.3 < 10.4 < utl=10.5 -> warning
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": 10.4,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["pass_fail"] == "warning"
|
||||
|
||||
async def test_measurement_fail(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Value outside tolerance limits -> fail."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id
|
||||
)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
# value > utl=10.5 -> fail
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": 10.6,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["pass_fail"] == "fail"
|
||||
|
||||
|
||||
class TestListMeasurements:
|
||||
"""GET /api/measurements/ tests."""
|
||||
|
||||
async def test_list_measurements(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
metrologist_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Metrologist can list measurements with pagination."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id
|
||||
)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
# Create some measurements
|
||||
for val in [9.8, 10.0, 10.2]:
|
||||
await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": val,
|
||||
},
|
||||
)
|
||||
|
||||
# List as Metrologist
|
||||
resp = await client.get(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(metrologist_user),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert data["total"] >= 3
|
||||
|
||||
async def test_measurement_by_recipe_filter(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
metrologist_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Filtering measurements by recipe_id returns only matching records."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id
|
||||
)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": 10.0,
|
||||
},
|
||||
)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(metrologist_user),
|
||||
params={"recipe_id": recipe.id},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] >= 1
|
||||
|
||||
|
||||
class TestMeasurementStatistics:
|
||||
"""Measurement deviation / statistics edge case tests."""
|
||||
|
||||
async def test_measurement_has_deviation(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Measurement response includes deviation from nominal."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id
|
||||
)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": 10.2,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# deviation = 10.2 - nominal(10.0) = 0.2
|
||||
assert data["deviation"] is not None
|
||||
assert abs(data["deviation"] - 0.2) < 0.001
|
||||
@@ -1,107 +0,0 @@
|
||||
"""Tests for settings router (/api/settings)."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.setting import SystemSetting
|
||||
from models.user import User
|
||||
from tests.conftest import auth_headers
|
||||
|
||||
|
||||
class TestGetSettings:
|
||||
"""GET /api/settings/ tests."""
|
||||
|
||||
async def test_get_settings(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Authenticated user can retrieve settings."""
|
||||
# Seed a setting
|
||||
setting = SystemSetting(
|
||||
setting_key="test_setting",
|
||||
setting_value="test_value",
|
||||
setting_type="string",
|
||||
)
|
||||
db_session.add(setting)
|
||||
await db_session.flush()
|
||||
|
||||
resp = await client.get(
|
||||
"/api/settings/", headers=auth_headers(admin_user)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, dict)
|
||||
assert data.get("test_setting") == "test_value"
|
||||
|
||||
async def test_get_settings_empty(
|
||||
self, client: AsyncClient, maker_user: User
|
||||
):
|
||||
"""Empty settings returns empty dict."""
|
||||
resp = await client.get(
|
||||
"/api/settings/", headers=auth_headers(maker_user)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {} or isinstance(resp.json(), dict)
|
||||
|
||||
async def test_get_setting_by_key(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Settings dict contains specific key after creation."""
|
||||
setting = SystemSetting(
|
||||
setting_key="csv_delimiter",
|
||||
setting_value=";",
|
||||
setting_type="string",
|
||||
)
|
||||
db_session.add(setting)
|
||||
await db_session.flush()
|
||||
|
||||
resp = await client.get(
|
||||
"/api/settings/", headers=auth_headers(admin_user)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["csv_delimiter"] == ";"
|
||||
|
||||
|
||||
class TestUpdateSettings:
|
||||
"""PUT /api/settings/ tests."""
|
||||
|
||||
async def test_update_settings(
|
||||
self, client: AsyncClient, admin_user: User
|
||||
):
|
||||
"""Admin can update/create settings."""
|
||||
resp = await client.put(
|
||||
"/api/settings/",
|
||||
headers=auth_headers(admin_user),
|
||||
json={
|
||||
"company_name": "Tielogic Srl",
|
||||
"csv_delimiter": ",",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["message"].startswith("Updated")
|
||||
assert "company_name" in data["updated_keys"]
|
||||
assert "csv_delimiter" in data["updated_keys"]
|
||||
|
||||
# Verify persistence
|
||||
get_resp = await client.get(
|
||||
"/api/settings/", headers=auth_headers(admin_user)
|
||||
)
|
||||
settings = get_resp.json()
|
||||
assert settings["company_name"] == "Tielogic Srl"
|
||||
|
||||
async def test_settings_require_admin(
|
||||
self, client: AsyncClient, maker_user: User
|
||||
):
|
||||
"""Non-admin user cannot update settings."""
|
||||
resp = await client.put(
|
||||
"/api/settings/",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"some_key": "some_value"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user