Compare commits
240 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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__
|
# Build context exclusions: keep image small and rebuilds fast.
|
||||||
*.pyc
|
|
||||||
*.pyo
|
# VCS
|
||||||
.pytest_cache
|
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
.env
|
.gitattributes
|
||||||
*.md
|
|
||||||
node_modules
|
# Local envs and caches
|
||||||
.venv
|
.venv
|
||||||
venv
|
venv
|
||||||
*.egg-info
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache/
|
||||||
.coverage
|
.coverage
|
||||||
htmlcov
|
htmlcov/
|
||||||
.mypy_cache
|
.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 ---
|
# --- 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_HOST=localhost
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_NAME=tiemeasureflow
|
DB_NAME=tiemeasureflow
|
||||||
@@ -10,12 +12,20 @@ DB_USER=tmflow
|
|||||||
DB_PASSWORD=change_me_in_production
|
DB_PASSWORD=change_me_in_production
|
||||||
|
|
||||||
# --- Server ---
|
# --- 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_HOST=0.0.0.0
|
||||||
SERVER_PORT=8000
|
SERVER_PORT=8000
|
||||||
SERVER_SECRET_KEY=change-this-to-a-random-secret-key
|
SERVER_SECRET_KEY=change-this-to-a-random-secret-key
|
||||||
SERVER_CORS_ORIGINS=http://localhost:5000
|
SERVER_CORS_ORIGINS=http://localhost:5000
|
||||||
|
|
||||||
# --- Client ---
|
# --- 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_HOST=0.0.0.0
|
||||||
CLIENT_PORT=5000
|
CLIENT_PORT=5000
|
||||||
CLIENT_SECRET_KEY=change-this-to-another-random-secret-key
|
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.
|
# 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.
|
# Leave empty only for a single-station all-in-one demo using ST-DEFAULT.
|
||||||
STATION_CODE=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 ---
|
# --- 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
|
MAX_UPLOAD_SIZE_MB=50
|
||||||
|
|
||||||
# --- Setup Page ---
|
# --- Setup Page ---
|
||||||
SETUP_PASSWORD= # Password per /api/setup, vuoto = disabilitato
|
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 ---
|
# --- Docker ---
|
||||||
|
# DB_ROOT_PASSWORD: in produzione (docker-compose.yml) obbligatoria, senza default.
|
||||||
DB_ROOT_PASSWORD=root_password_change_me
|
DB_ROOT_PASSWORD=root_password_change_me
|
||||||
NGINX_PORT=80
|
NGINX_PORT=80
|
||||||
NGINX_SSL_PORT=443
|
NGINX_SSL_PORT=443
|
||||||
|
|||||||
+14
-11
@@ -33,21 +33,20 @@ env/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
desktop.ini
|
desktop.ini
|
||||||
|
|
||||||
# Uploads (server-side files)
|
# Uploads (server-side files, mounted as a volume in production): ignora tutto
|
||||||
server/uploads/images/*
|
# sotto uploads/, comprese le sottocartelle create a runtime (vision reference
|
||||||
server/uploads/pdfs/*
|
# images, cartelle per ricetta/versione) — tieni solo i .gitkeep che mantengono
|
||||||
server/uploads/logos/*
|
# le cartelle vuote in git.
|
||||||
server/uploads/reports/*
|
uploads/*
|
||||||
!server/uploads/images/.gitkeep
|
!uploads/**/.gitkeep
|
||||||
!server/uploads/pdfs/.gitkeep
|
|
||||||
!server/uploads/logos/.gitkeep
|
|
||||||
!server/uploads/reports/.gitkeep
|
|
||||||
|
|
||||||
# TailwindCSS output
|
# TailwindCSS output
|
||||||
client/static/css/tailwind.css
|
src/frontend/flask_app/static/css/tailwind.css
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
|
src/frontend/flask_app/package.json
|
||||||
|
src/frontend/flask_app/package-lock.json
|
||||||
|
|
||||||
# Flask-Babel compiled
|
# Flask-Babel compiled
|
||||||
*.mo
|
*.mo
|
||||||
@@ -63,10 +62,14 @@ node_modules/
|
|||||||
htmlcov/
|
htmlcov/
|
||||||
|
|
||||||
# Debug files
|
# Debug files
|
||||||
client/static/js/fabric-debug.js
|
src/frontend/flask_app/static/js/fabric-debug.js
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
nul
|
nul
|
||||||
|
|
||||||
# Competitor analysis (local only)
|
# Competitor analysis (local only)
|
||||||
Concorrente/
|
Concorrente/
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Subagent-driven development scratch: ledger, briefs, review packages
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "vendor/visionsuite"]
|
||||||
|
path = vendor/visionsuite
|
||||||
|
url = ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.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
|
## Panoramica
|
||||||
|
|
||||||
TieMeasureFlow by Tielogic - Sistema di gestione task per misurazioni con calibro manuale.
|
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
|
## Comandi di Sviluppo
|
||||||
|
|
||||||
|
### Setup iniziale
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
uv sync --extra server --extra client --extra dev # installa tutto
|
||||||
|
```
|
||||||
|
|
||||||
### Avvio servizi (Docker)
|
### Avvio servizi (Docker)
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.dev.yml up -d # Sviluppo (Nginx, porta 80)
|
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)
|
### Avvio manuale (senza Docker)
|
||||||
```bash
|
```bash
|
||||||
# Server (terminale 1)
|
# Backend (terminale 1)
|
||||||
cd server && uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
# Client (terminale 2)
|
# Frontend (terminale 2) — gunicorn richiede cwd interna per app:create_app()
|
||||||
cd client && flask run --host 0.0.0.0 --port 5000
|
cd src/frontend/flask_app && uv run --project ../../.. gunicorn --bind 0.0.0.0:5000 app:create_app()
|
||||||
|
|
||||||
# TailwindCSS watch (terminale 3)
|
# 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
|
### Database & Migrations
|
||||||
```bash
|
```bash
|
||||||
# alembic.ini è in server/migrations/, serve il flag -c
|
# alembic.ini in src/backend/migrations/, serve il flag -c
|
||||||
cd server && alembic -c migrations/alembic.ini upgrade head # Applica migrazioni
|
uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Applica migrazioni
|
||||||
cd server && alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione
|
uv run alembic -c src/backend/migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera
|
||||||
cd server && alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima
|
uv run alembic -c src/backend/migrations/alembic.ini downgrade -1 # Rollback
|
||||||
docker compose exec server alembic -c migrations/alembic.ini upgrade head # Via Docker
|
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
|
### Test
|
||||||
```bash
|
```bash
|
||||||
# Server (usa SQLite in-memory via aiosqlite, no MySQL richiesto)
|
# Tutti i test (backend + frontend)
|
||||||
cd server && pytest # Tutti i test
|
uv run pytest
|
||||||
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
|
|
||||||
|
|
||||||
# Client
|
# Solo backend (SQLite in-memory via aiosqlite, no MySQL richiesto)
|
||||||
cd client && pytest
|
uv run pytest src/backend/tests/
|
||||||
cd client && pytest tests/test_auth.py
|
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)
|
### i18n (Traduzioni)
|
||||||
```bash
|
```bash
|
||||||
# Estrai stringhe
|
# 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
|
# 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
|
# Compila .po → .mo
|
||||||
cd client && pybabel compile -d translations
|
cd src/frontend/flask_app && uv run pybabel compile -d translations
|
||||||
# oppure
|
# oppure
|
||||||
cd client && python compile_translations.py
|
cd src/frontend/flask_app && uv run python compile_translations.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Setup iniziale
|
### 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.
|
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`
|
- **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
|
- **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`
|
- **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)
|
- **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
|
- **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
|
- **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
|
- **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"`)
|
- **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`)
|
- **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
|
- **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"`
|
- **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"`
|
- **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`:
|
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.
|
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:
|
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 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
|
### 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 UTL/LTL → **fail**
|
||||||
- Fuori UWL/LWL ma dentro UTL/LTL → **warning**
|
- Fuori UWL/LWL ma dentro UTL/LTL → **warning**
|
||||||
- Dentro UWL/LWL → **pass**
|
- Dentro UWL/LWL → **pass**
|
||||||
|
|
||||||
### SPC (Statistical Process Control)
|
### 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
|
### Autenticazione
|
||||||
1. Login con username/password → server ritorna `api_key` (64 char random)
|
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
|
## 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)
|
- 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()`
|
- WeasyPrint mockato prima di qualsiasi import server: `sys.modules["weasyprint"] = MagicMock()`
|
||||||
- Rate limit buckets resettati tra test (walk middleware stack → clear dicts)
|
- 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}`
|
- Helper: `auth_headers(user)` → `{"X-API-Key": user.api_key}`
|
||||||
- Client httpx: `AsyncClient` con `ASGITransport(app=app)`, override di `get_db` dependency
|
- 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
|
- `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
|
- `logged_in_client` fixture pre-popola session con `api_key`, `user_id`, `language`, `theme` + user dict
|
||||||
- CSRF disabilitato nei test: `WTF_CSRF_ENABLED=False`
|
- 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.
|
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** su `ssh://git.tielogic.xyz:222`: un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
|
||||||
|
|
||||||
|
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. Pin attuale: bc0df16.
|
||||||
|
|
||||||
|
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
|
## Convenzioni Codice
|
||||||
- Python 3.11+, type hints ovunque
|
- Python 3.11+, type hints ovunque
|
||||||
- async/await per tutte le operazioni DB (server)
|
- 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)
|
- 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`
|
- Server: `SERVER_HOST`, `SERVER_PORT`, `SERVER_SECRET_KEY`, `SERVER_CORS_ORIGINS`
|
||||||
- Client: `CLIENT_HOST`, `CLIENT_PORT`, `CLIENT_SECRET_KEY`, `API_SERVER_URL`
|
- 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`
|
- Docker: `NGINX_PORT`, `NGINX_SSL_PORT`
|
||||||
- Setup: `SETUP_PASSWORD` (vuota = endpoint disabilitato)
|
- Setup: `SETUP_PASSWORD` (vuota = endpoint disabilitato)
|
||||||
- SSL: `SSL_CERTFILE`, `SSL_KEYFILE`
|
- SSL: `SSL_CERTFILE`, `SSL_KEYFILE`
|
||||||
|
|||||||
+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
|
# 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)
|
- 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)
|
- 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)
|
- 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
|
- 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
|
- 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)
|
Reverse Proxy (Nginx — sviluppo | Traefik+SSL — produzione)
|
||||||
|
|
|
|
||||||
Flask Client :5000 (rendering server-side, Jinja2 + Alpine.js)
|
Flask Frontend :5000 (rendering server-side, Jinja2 + Alpine.js)
|
||||||
| X-API-Key
|
| X-API-Key + X-Forwarded-For
|
||||||
FastAPI Server :8000 (API REST asincrona)
|
FastAPI Backend :8000 (API REST asincrona)
|
||||||
|
|
|
|
||||||
MySQL 8.0
|
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
|
## Stack Tecnologico
|
||||||
|
|
||||||
### Backend (server/)
|
### Backend (`src/backend/`)
|
||||||
|
|
||||||
| Componente | Versione | Ruolo |
|
| Componente | Versione | Ruolo |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| FastAPI | ultima stabile | Framework API REST asincrono |
|
| 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 |
|
| asyncmy | ultima stabile | Driver MySQL asincrono |
|
||||||
| MySQL | 8.0 | Database relazionale |
|
| MySQL | 8.0 | Database relazionale |
|
||||||
| Alembic | ultima stabile | Migrazioni schema |
|
| 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 |
|
| bcrypt | ultima stabile | Hashing password |
|
||||||
| Pillow | ultima stabile | Thumbnail automatici upload |
|
| Pillow | ultima stabile | Thumbnail automatici upload |
|
||||||
|
|
||||||
### Frontend (client/)
|
### Frontend (`src/frontend/flask_app/`)
|
||||||
|
|
||||||
| Componente | Versione | Ruolo |
|
| Componente | Versione | Ruolo |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Flask | 3.x | Framework web server-side |
|
| Flask | 3.x | Framework web server-side |
|
||||||
|
| gunicorn | 21+ | WSGI server (5 workers × 4 thread gthread) |
|
||||||
| Jinja2 | incluso in Flask | Template engine |
|
| Jinja2 | incluso in Flask | Template engine |
|
||||||
| Alpine.js | 3.x (CDN) | Reattivita leggera lato client |
|
| Alpine.js | 3.15.12 (locale) | Reattività leggera lato client |
|
||||||
| TailwindCSS | 3.x | CSS utility-first |
|
| TailwindCSS | 3.4.19 (build) | CSS utility-first, compilato nell'immagine |
|
||||||
| Plotly.js | CDN | Grafici SPC interattivi |
|
| Plotly.js | 2.32.0 (locale) | Grafici SPC interattivi |
|
||||||
| Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici |
|
| 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 |
|
| 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
|
## 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
|
```bash
|
||||||
# 1. Clona il repository
|
# 1. Clona il repository CON il sottomodulo di VisionSuite (vendor/visionsuite):
|
||||||
git clone <repository-url>
|
# 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
|
cd TieMeasureFlow
|
||||||
|
# Chi ha già clonato senza il sottomodulo:
|
||||||
|
# git submodule update --init
|
||||||
|
|
||||||
# 2. Configura le variabili d'ambiente
|
# 2. Configura le variabili d'ambiente
|
||||||
cp .env.example .env
|
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)
|
# 3. Valorizza VISION_ENGINE_VERSION PRIMA della build: il worker di visione
|
||||||
docker compose -f docker-compose.dev.yml up -d
|
# (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
|
# 4. Verifica lo stato dei container
|
||||||
docker compose -f docker-compose.dev.yml ps
|
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)
|
# 5. Setup iniziale (solo al primo avvio)
|
||||||
# Apri http://localhost/api/setup nel browser
|
# Apri http://localhost/api/setup nel browser
|
||||||
# Usa SETUP_PASSWORD configurata in .env
|
# 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:
|
L'applicazione sarà disponibile su:
|
||||||
@@ -97,8 +161,9 @@ L'applicazione sarà disponibile su:
|
|||||||
- Frontend: http://localhost
|
- Frontend: http://localhost
|
||||||
- API: http://localhost/api
|
- API: http://localhost/api
|
||||||
- Pagina setup: http://localhost/api/setup
|
- 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
|
- **Initialize Database** — crea tutte le tabelle
|
||||||
- **Create Admin User** — crea l'utente amministratore con credenziali da `.env`
|
- **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)
|
- **Reset Database** — elimina e ricrea tutte le tabelle (attenzione: cancella tutti i dati)
|
||||||
- **Gestione utenti** — crea, modifica, attiva/disattiva account dalla stessa pagina
|
- **Gestione utenti** — crea, modifica, attiva/disattiva account dalla stessa pagina
|
||||||
|
|
||||||
Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
|
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)
|
## Setup Manuale (Senza Docker)
|
||||||
@@ -121,8 +188,9 @@ Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
|
|||||||
### Requisiti
|
### Requisiti
|
||||||
|
|
||||||
- Python 3.11 o superiore
|
- Python 3.11 o superiore
|
||||||
- Node.js 18 o superiore
|
- Node.js 18 o superiore (per TailwindCSS)
|
||||||
- MySQL 8.0
|
- MySQL 8.0
|
||||||
|
- [uv](https://docs.astral.sh/uv/) installato
|
||||||
|
|
||||||
### 1. Database MySQL
|
### 1. Database MySQL
|
||||||
|
|
||||||
@@ -139,35 +207,37 @@ SQL
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
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
|
```bash
|
||||||
cd server
|
uv sync --extra server --extra client --extra dev
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Client Flask
|
### 4. Backend FastAPI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||||
python -m venv venv
|
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. TailwindCSS (watch per sviluppo)
|
### 5. Frontend Flask
|
||||||
|
|
||||||
```bash
|
```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
|
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 |
|
| 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 |
|
| **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 |
|
| **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`) |
|
||||||
| **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart |
|
| **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 |
|
||||||
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account |
|
| **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/
|
TieMeasureFlow/
|
||||||
├── server/ # FastAPI Backend
|
├── pyproject.toml # Dipendenze monorepo (uv)
|
||||||
│ ├── main.py # Entry point, lifespan, middleware, 10 router
|
├── uv.lock # Lockfile riproducibile
|
||||||
│ ├── config.py # Settings (pydantic_settings.BaseSettings)
|
├── .python-version # 3.11
|
||||||
│ ├── database.py # SQLAlchemy 2.0 async engine
|
├── Dockerfile # Backend (uv + uvicorn)
|
||||||
│ ├── models/ # ORM: User, Recipe, RecipeVersion, RecipeTask,
|
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
|
||||||
│ │ # 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)
|
├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80)
|
||||||
└── .env.example # Template variabili d'ambiente
|
├── 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 |
|
| 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 |
|
| `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 server` | Segui log server in tempo reale |
|
||||||
| `docker compose logs -f client` | Segui log client in tempo reale |
|
| `docker compose logs -f client` | Segui log client in tempo reale |
|
||||||
| `docker compose ps` | Stato di tutti i servizi |
|
| `docker compose ps` | Stato di tutti i servizi |
|
||||||
@@ -248,47 +342,153 @@ TieMeasureFlow/
|
|||||||
|
|
||||||
### Alembic (migrations)
|
### 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
|
```bash
|
||||||
cd server
|
uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Applica migrazioni
|
||||||
alembic -c migrations/alembic.ini upgrade head # Applica migrazioni
|
uv run alembic -c src/backend/migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera
|
||||||
alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione
|
uv run alembic -c src/backend/migrations/alembic.ini downgrade -1 # Rollback ultima
|
||||||
alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Via Docker:
|
Via Docker:
|
||||||
|
|
||||||
```bash
|
```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)
|
### i18n (Traduzioni)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
cd src/frontend/flask_app
|
||||||
pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai stringhe
|
uv run --project ../../.. pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai
|
||||||
pybabel update -i translations/messages.pot -d translations # Aggiorna catalogo
|
uv run --project ../../.. pybabel update -i translations/messages.pot -d translations # Aggiorna
|
||||||
pybabel compile -d translations # Compila .po → .mo
|
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
|
## 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
|
```bash
|
||||||
# Server
|
# Tutti i test (backend + frontend)
|
||||||
cd server && pytest # Tutti i test
|
uv run pytest
|
||||||
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
|
|
||||||
|
|
||||||
# Client
|
# Solo backend
|
||||||
cd client && pytest
|
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
|
## Variabili d'Ambiente
|
||||||
@@ -304,8 +504,12 @@ Copia `.env.example` in `.env` e configura:
|
|||||||
| `SERVER_CORS_ORIGINS` | Origini CORS ammesse |
|
| `SERVER_CORS_ORIGINS` | Origini CORS ammesse |
|
||||||
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
||||||
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
||||||
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, relativo a `server/`) |
|
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
|
||||||
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload |
|
| `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) |
|
| `NGINX_PORT`, `NGINX_SSL_PORT` | Porte Nginx (solo compose dev) |
|
||||||
| `SETUP_PASSWORD` | Password pagina setup (vuota = endpoint disabilitato) |
|
| `SETUP_PASSWORD` | Password pagina setup (vuota = endpoint disabilitato) |
|
||||||
| `SSL_CERTFILE`, `SSL_KEYFILE` | Certificato SSL (solo setup manuale) |
|
| `SSL_CERTFILE`, `SSL_KEYFILE` | Certificato SSL (solo setup manuale) |
|
||||||
@@ -314,16 +518,38 @@ Copia `.env.example` in `.env` e configura:
|
|||||||
|
|
||||||
## Documentazione
|
## Documentazione
|
||||||
|
|
||||||
|
Indice completo: [`docs/README.md`](docs/README.md).
|
||||||
|
|
||||||
|
### Stato e direzione
|
||||||
|
|
||||||
| Documento | Contenuto |
|
| Documento | Contenuto |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [docs/API.md](docs/API.md) | Riferimento completo API REST (endpoint, parametri, schemi) |
|
| [`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/DEPLOYMENT.md](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
|
| [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) | Snapshot V2.0.0: cosa funziona oggi, test status, decisioni architetturali |
|
||||||
| [docs/USER_GUIDE.md](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
|
| [`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
|
## 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.
|
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,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,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())
|
|
||||||
+52
-6
@@ -14,6 +14,7 @@ services:
|
|||||||
--authentication-policy=mysql_native_password
|
--authentication-policy=mysql_native_password
|
||||||
--character-set-server=utf8mb4
|
--character-set-server=utf8mb4
|
||||||
--collation-server=utf8mb4_unicode_ci
|
--collation-server=utf8mb4_unicode_ci
|
||||||
|
--default-time-zone=+00:00
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -24,35 +25,78 @@ services:
|
|||||||
|
|
||||||
server:
|
server:
|
||||||
build:
|
build:
|
||||||
context: ./server
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: tmflow-server
|
container_name: tmflow-server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
APP_ENV: dev
|
||||||
DB_HOST: mysql
|
DB_HOST: mysql
|
||||||
UPLOAD_DIR: uploads
|
UPLOAD_DIR: uploads
|
||||||
|
VISION_WORKER_URL: http://vision:8100
|
||||||
|
WEB_CONCURRENCY: ${WEB_CONCURRENCY:-4}
|
||||||
|
TRUSTED_PROXY_COUNT: ${TRUSTED_PROXY_COUNT:-1}
|
||||||
volumes:
|
volumes:
|
||||||
- upload_data:/app/uploads
|
- upload_data:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
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:
|
networks:
|
||||||
- tmflow-net
|
- tmflow-net
|
||||||
|
|
||||||
client:
|
client:
|
||||||
build:
|
build:
|
||||||
context: ./client
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile.frontend
|
||||||
|
args:
|
||||||
|
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
|
||||||
|
APP_COMMIT: ${APP_COMMIT:-}
|
||||||
container_name: tmflow-client
|
container_name: tmflow-client
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
API_SERVER_URL: http://server:8000
|
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}
|
||||||
depends_on:
|
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:
|
networks:
|
||||||
- tmflow-net
|
- tmflow-net
|
||||||
|
|
||||||
@@ -69,8 +113,10 @@ services:
|
|||||||
# - ./certbot/conf:/etc/letsencrypt:ro
|
# - ./certbot/conf:/etc/letsencrypt:ro
|
||||||
# - ./certbot/www:/var/www/certbot:ro
|
# - ./certbot/www:/var/www/certbot:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- server
|
server:
|
||||||
- client
|
condition: service_healthy
|
||||||
|
client:
|
||||||
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- tmflow-net
|
- tmflow-net
|
||||||
|
|
||||||
|
|||||||
+59
-12
@@ -4,16 +4,17 @@ services:
|
|||||||
container_name: tmflow-mysql
|
container_name: tmflow-mysql
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
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_DATABASE: ${DB_NAME:-tiemeasureflow}
|
||||||
MYSQL_USER: ${DB_USER:-tmflow}
|
MYSQL_USER: ${DB_USER:-tmflow}
|
||||||
MYSQL_PASSWORD: ${DB_PASSWORD:-change_me_in_production}
|
MYSQL_PASSWORD: ${DB_PASSWORD:?manca DB_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- mysql_data:/var/lib/mysql
|
- mysql_data:/var/lib/mysql
|
||||||
command: >
|
command: >
|
||||||
--authentication-policy=mysql_native_password
|
--authentication-policy=mysql_native_password
|
||||||
--character-set-server=utf8mb4
|
--character-set-server=utf8mb4
|
||||||
--collation-server=utf8mb4_unicode_ci
|
--collation-server=utf8mb4_unicode_ci
|
||||||
|
--default-time-zone=+00:00
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -24,59 +25,105 @@ services:
|
|||||||
|
|
||||||
server:
|
server:
|
||||||
build:
|
build:
|
||||||
context: ./server
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: tmflow-server
|
container_name: tmflow-server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
APP_ENV: production
|
||||||
DB_HOST: mysql
|
DB_HOST: mysql
|
||||||
UPLOAD_DIR: uploads
|
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:
|
volumes:
|
||||||
- upload_data:/app/uploads
|
- upload_data:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
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:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "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.entrypoints=web,websecure"
|
||||||
- "traefik.http.routers.tmflow-api.tls=true"
|
- "traefik.http.routers.tmflow-api.tls=true"
|
||||||
- "traefik.http.routers.tmflow-api.tls.certresolver=mytlschallenge"
|
- "traefik.http.routers.tmflow-api.tls.certresolver=mytlschallenge"
|
||||||
- "traefik.http.routers.tmflow-api.priority=100"
|
- "traefik.http.routers.tmflow-api.priority=100"
|
||||||
- "traefik.http.services.tmflow-api.loadbalancer.server.port=8000"
|
- "traefik.http.services.tmflow-api.loadbalancer.server.port=8000"
|
||||||
|
- "traefik.http.routers.tmflow-api.middlewares=tmflow-headers"
|
||||||
networks:
|
networks:
|
||||||
- tmflow-net
|
- tmflow-net
|
||||||
- traefik-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:
|
client:
|
||||||
build:
|
build:
|
||||||
context: ./client
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile.frontend
|
||||||
|
args:
|
||||||
|
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
|
||||||
|
APP_COMMIT: ${APP_COMMIT:-}
|
||||||
container_name: tmflow-client
|
container_name: tmflow-client
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
API_SERVER_URL: http://server:8000
|
API_SERVER_URL: http://server:8000
|
||||||
|
CLIENT_SECRET_KEY: ${CLIENT_SECRET_KEY:?manca CLIENT_SECRET_KEY}
|
||||||
depends_on:
|
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:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "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.entrypoints=web,websecure"
|
||||||
- "traefik.http.routers.tmflow-web.tls=true"
|
- "traefik.http.routers.tmflow-web.tls=true"
|
||||||
- "traefik.http.routers.tmflow-web.tls.certresolver=mytlschallenge"
|
- "traefik.http.routers.tmflow-web.tls.certresolver=mytlschallenge"
|
||||||
- "traefik.http.routers.tmflow-web.priority=10"
|
- "traefik.http.routers.tmflow-web.priority=10"
|
||||||
- "traefik.http.services.tmflow-web.loadbalancer.server.port=5000"
|
- "traefik.http.services.tmflow-web.loadbalancer.server.port=5000"
|
||||||
- "traefik.http.middlewares.tmflow-headers.headers.SSLRedirect=true"
|
# Traefik v3: SSLRedirect e browserXSSFilter non ci sono più (SSL redirect
|
||||||
- "traefik.http.middlewares.tmflow-headers.headers.STSSeconds=315360000"
|
# si fa a livello di entrypoint, l'XSS Auditor del browser non esiste più).
|
||||||
- "traefik.http.middlewares.tmflow-headers.headers.browserXSSFilter=true"
|
- "traefik.http.middlewares.tmflow-headers.headers.stsSeconds=315360000"
|
||||||
- "traefik.http.middlewares.tmflow-headers.headers.contentTypeNosniff=true"
|
- "traefik.http.middlewares.tmflow-headers.headers.contentTypeNosniff=true"
|
||||||
- "traefik.http.middlewares.tmflow-headers.headers.forceSTSHeader=true"
|
- "traefik.http.middlewares.tmflow-headers.headers.forceSTSHeader=true"
|
||||||
- "traefik.http.middlewares.tmflow-headers.headers.STSIncludeSubdomains=true"
|
- "traefik.http.middlewares.tmflow-headers.headers.STSIncludeSubdomains=true"
|
||||||
- "traefik.http.middlewares.tmflow-headers.headers.STSPreload=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"
|
- "traefik.http.routers.tmflow-web.middlewares=tmflow-headers"
|
||||||
networks:
|
networks:
|
||||||
- tmflow-net
|
- tmflow-net
|
||||||
@@ -93,4 +140,4 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
traefik-net:
|
traefik-net:
|
||||||
external: true
|
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)
|
- [Measurements](#measurements)
|
||||||
- [Files](#files)
|
- [Files](#files)
|
||||||
- [Settings](#settings)
|
- [Settings](#settings)
|
||||||
|
- [Stations](#stations)
|
||||||
- [Statistics](#statistics)
|
- [Statistics](#statistics)
|
||||||
- [Reports](#reports)
|
- [Reports](#reports)
|
||||||
7. [Pagination](#pagination)
|
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
|
### Statistics
|
||||||
|
|
||||||
All statistics endpoints **require Metrologist role**.
|
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.
|
||||||
+182
-282
@@ -134,13 +134,27 @@ SSL_KEYFILE=
|
|||||||
| `CLIENT_HOST` | string | 0.0.0.0 | Flask client bind address |
|
| `CLIENT_HOST` | string | 0.0.0.0 | Flask client bind address |
|
||||||
| `CLIENT_PORT` | int | 5000 | Flask client port |
|
| `CLIENT_PORT` | int | 5000 | Flask client port |
|
||||||
| `SERVER_CORS_ORIGINS` | string | http://localhost:5000 | Comma-separated CORS origins |
|
| `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 |
|
| `MAX_UPLOAD_SIZE_MB` | int | 50 | Maximum upload file size in MB |
|
||||||
| `RATE_LIMIT_LOGIN` | int | 5 | Login requests per minute |
|
| `RATE_LIMIT_LOGIN` | int | 5 | Login requests per minute, per real client IP |
|
||||||
| `RATE_LIMIT_GENERAL` | int | 100 | General requests per minute |
|
| `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_CERTFILE` | string | (empty) | Path to SSL certificate (production) |
|
||||||
| `SSL_KEYFILE` | string | (empty) | Path to SSL private key (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
|
## Database Setup
|
||||||
@@ -168,63 +182,33 @@ EXIT;
|
|||||||
|
|
||||||
### 2. Run Database Migrations
|
### 2. Run Database Migrations
|
||||||
|
|
||||||
```bash
|
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/`.
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||||
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())
|
|
||||||
"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## 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
|
### Development Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
uv sync --extra server --extra dev
|
||||||
pip install -r requirements.txt
|
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Runs at `http://0.0.0.0:8000`
|
Runs at `http://0.0.0.0:8000`
|
||||||
@@ -233,40 +217,15 @@ API Documentation:
|
|||||||
- Swagger UI: `http://localhost:8000/docs`
|
- Swagger UI: `http://localhost:8000/docs`
|
||||||
- ReDoc: `http://localhost:8000/redoc`
|
- 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
|
```bash
|
||||||
pip install gunicorn
|
uv run uvicorn src.backend.main:app \
|
||||||
```
|
--host 0.0.0.0 --port 8000 --workers ${WEB_CONCURRENCY:-4} \
|
||||||
|
--proxy-headers --forwarded-allow-ips='*'
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### systemd Service (Linux)
|
### systemd Service (Linux)
|
||||||
@@ -279,14 +238,14 @@ Description=TieMeasureFlow API Server
|
|||||||
After=network.target mysql.service
|
After=network.target mysql.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=notify
|
Type=simple
|
||||||
User=tiemeasureflow
|
User=tiemeasureflow
|
||||||
WorkingDirectory=/opt/tiemeasureflow/server
|
WorkingDirectory=/opt/tiemeasureflow
|
||||||
Environment="PATH=/opt/tiemeasureflow/venv/bin"
|
EnvironmentFile=/opt/tiemeasureflow/.env
|
||||||
ExecStart=/opt/tiemeasureflow/venv/bin/gunicorn main:app \
|
Environment="WEB_CONCURRENCY=4"
|
||||||
--workers 4 \
|
ExecStart=/opt/tiemeasureflow/.venv/bin/uv run uvicorn src.backend.main:app \
|
||||||
--worker-class uvicorn.workers.UvicornWorker \
|
--host 0.0.0.0 --port 8000 --workers ${WEB_CONCURRENCY} \
|
||||||
--bind 0.0.0.0:8000
|
--proxy-headers --forwarded-allow-ips='*'
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
@@ -314,12 +273,14 @@ sudo journalctl -u tiemeasureflow-api -f
|
|||||||
|
|
||||||
## Client Deployment
|
## Client Deployment
|
||||||
|
|
||||||
|
Anche qui, Docker Compose è il metodo raccomandato; questa sezione è per lo
|
||||||
|
sviluppo o un'installazione manuale senza container.
|
||||||
|
|
||||||
### Development Server
|
### Development Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
uv sync --extra client --extra dev
|
||||||
pip install -r requirements.txt
|
cd src/frontend/flask_app && uv run --project ../../.. flask run --host 0.0.0.0 --port 5000
|
||||||
flask run --host 0.0.0.0 --port 5000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Runs at `http://0.0.0.0: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)
|
### Compile Tailwind CSS (Optional)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
cd src/frontend/flask_app
|
||||||
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
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
|
```bash
|
||||||
pip install gunicorn
|
cd src/frontend/flask_app && uv run --project ../../.. gunicorn \
|
||||||
cd client
|
--workers 5 --threads 4 --worker-class gthread --timeout 60 \
|
||||||
gunicorn --workers 4 --bind 0.0.0.0:5000 app:app
|
--bind 0.0.0.0:5000 --forwarded-allow-ips='*' \
|
||||||
|
"app:create_app()"
|
||||||
```
|
```
|
||||||
|
|
||||||
### systemd Service (Linux)
|
### systemd Service (Linux)
|
||||||
@@ -349,15 +314,14 @@ Description=TieMeasureFlow Web Client
|
|||||||
After=network.target tiemeasureflow-api.service
|
After=network.target tiemeasureflow-api.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=notify
|
Type=simple
|
||||||
User=tiemeasureflow
|
User=tiemeasureflow
|
||||||
WorkingDirectory=/opt/tiemeasureflow/client
|
WorkingDirectory=/opt/tiemeasureflow/src/frontend/flask_app
|
||||||
Environment="PATH=/opt/tiemeasureflow/venv/bin"
|
EnvironmentFile=/opt/tiemeasureflow/.env
|
||||||
Environment="FLASK_ENV=production"
|
ExecStart=/opt/tiemeasureflow/.venv/bin/uv run --project /opt/tiemeasureflow gunicorn \
|
||||||
ExecStart=/opt/tiemeasureflow/venv/bin/gunicorn \
|
--workers 5 --threads 4 --worker-class gthread --timeout 60 \
|
||||||
--workers 4 \
|
--bind 0.0.0.0:5000 --forwarded-allow-ips='*' \
|
||||||
--bind 0.0.0.0:5000 \
|
"app:create_app()"
|
||||||
app:app
|
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
@@ -372,8 +336,9 @@ WantedBy=multi-user.target
|
|||||||
|
|
||||||
### Security Checklist
|
### 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)
|
- [ ] 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
|
- [ ] Set CORS origins to actual client domains
|
||||||
- [ ] Enable SSL/HTTPS (see SSL/TLS section)
|
- [ ] Enable SSL/HTTPS (see SSL/TLS section)
|
||||||
- [ ] Configure firewall rules
|
- [ ] Configure firewall rules
|
||||||
@@ -385,200 +350,140 @@ WantedBy=multi-user.target
|
|||||||
|
|
||||||
### Recommended Architecture
|
### 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)
|
On-premise (LAN) │ Nginx (:80) │
|
||||||
└────────┬────────┘
|
└─────────────┬────────────┘
|
||||||
│
|
│
|
||||||
┌────┴────┐
|
Produzione (dominio pubblico) → Traefik (:80/:443, TLS Let's Encrypt)
|
||||||
│ │
|
|
||||||
┌───▼──┐ ┌───▼──┐
|
|
||||||
│API │ │Web │
|
|
||||||
│:8000 │ │:5000 │
|
|
||||||
└──────┘ └──────┘
|
|
||||||
│ │
|
|
||||||
└────┬─────┘
|
|
||||||
│
|
│
|
||||||
┌────▼────┐
|
┌───────────┴───────────┐
|
||||||
│ MySQL │
|
│ │
|
||||||
│ :3306 │
|
┌─────▼─────┐ ┌──────▼─────┐
|
||||||
└─────────┘
|
│ server │ │ client │
|
||||||
|
│ :8000 │ │ :5000 │
|
||||||
|
└─────┬──────┘ └────────────┘
|
||||||
|
│
|
||||||
|
┌─────▼──────┐ ┌─────────────┐
|
||||||
|
│ vision │ │ MySQL 8.0 │
|
||||||
|
│ :8100 │ │ :3306 │
|
||||||
|
│ (interno) │ └─────────────┘
|
||||||
|
└─────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### Nginx Configuration
|
Il worker di visione (`vision`) non pubblica porte verso l'esterno: lo
|
||||||
|
raggiunge solo il `server`, sulla rete Docker interna `tmflow-net`.
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Docker Deployment
|
## 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
|
```bash
|
||||||
# Build images
|
# Sviluppo / on-premise
|
||||||
docker-compose build
|
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||||
|
docker compose -f docker-compose.dev.yml up -d --build
|
||||||
|
|
||||||
# Start services
|
# Produzione
|
||||||
docker-compose up -d
|
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||||
|
docker compose -f docker-compose.yml up -d --build
|
||||||
# View logs
|
|
||||||
docker-compose logs -f
|
|
||||||
|
|
||||||
# Stop services
|
|
||||||
docker-compose down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual Docker Build
|
Comandi comuni (sintassi `docker compose` v2, senza trattino — il plugin, non
|
||||||
|
il vecchio `docker-compose` standalone):
|
||||||
#### 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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f server/Dockerfile -t tiemeasureflow-api .
|
docker compose -f docker-compose.yml ps
|
||||||
docker run -p 8000:8000 --env-file .env tiemeasureflow-api
|
docker compose -f docker-compose.yml logs -f server
|
||||||
|
docker compose -f docker-compose.yml down
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Web Client
|
### Aggiornare un'installazione esistente
|
||||||
|
|
||||||
```dockerfile
|
Le immagini precedenti creavano l'utente non privilegiato senza una home
|
||||||
FROM python:3.11-slim
|
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
|
Prima del primo avvio con le nuove immagini, va quindi corretta la proprietà
|
||||||
|
del volume:
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f client/Dockerfile -t tiemeasureflow-web .
|
docker compose -f docker-compose.yml run --rm --user root --entrypoint chown server -R 10001:10001 /app/uploads
|
||||||
docker run -p 5000:5000 --env-file .env tiemeasureflow-web
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(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
|
## 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
|
- **Produzione** (`docker-compose.yml`): Traefik ottiene e rinnova da solo un
|
||||||
openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt -days 365 -nodes
|
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)
|
Un'installazione manuale (senza Docker, sezioni [Server](#server-deployment) e
|
||||||
|
[Client Deployment](#client-deployment) sopra) che debba terminare il TLS da
|
||||||
```bash
|
sola, dietro il proprio reverse proxy, può ancora valorizzare
|
||||||
sudo apt install certbot python3-certbot-nginx
|
`SSL_CERTFILE`/`SSL_KEYFILE` in `.env` — variabili lasciate per questo caso,
|
||||||
sudo certbot certonly --nginx -d yourdomain.com
|
non lette da uvicorn/gunicorn nei comandi mostrati sopra: vanno passate al
|
||||||
```
|
proprio reverse proxy o gestite fuori da questo progetto.
|
||||||
|
|
||||||
### 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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -643,14 +548,11 @@ sqlalchemy.exc.OperationalError: (asyncmy.errors.ProgrammingError) (2003, "Can't
|
|||||||
|
|
||||||
**Check:**
|
**Check:**
|
||||||
```bash
|
```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"
|
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
|
grep DB_ .env
|
||||||
|
|
||||||
# Test connection string
|
|
||||||
cd server && python -c "from config import settings; print(settings.database_url)"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Port Already in Use
|
### Port Already in Use
|
||||||
@@ -666,7 +568,7 @@ lsof -i :8000
|
|||||||
kill -9 <PID>
|
kill -9 <PID>
|
||||||
|
|
||||||
# Or use different port
|
# Or use different port
|
||||||
uvicorn main:app --port 8001
|
uv run uvicorn src.backend.main:app --port 8001
|
||||||
```
|
```
|
||||||
|
|
||||||
### Import Errors
|
### Import Errors
|
||||||
@@ -677,21 +579,20 @@ ModuleNotFoundError: No module named 'fastapi'
|
|||||||
|
|
||||||
**Solution:**
|
**Solution:**
|
||||||
```bash
|
```bash
|
||||||
cd server
|
uv sync --extra server --extra client --extra dev
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### File Upload Not Working
|
### File Upload Not Working
|
||||||
|
|
||||||
```
|
```
|
||||||
/app/server/uploads: Permission denied
|
uploads: Permission denied
|
||||||
```
|
```
|
||||||
|
|
||||||
**Solution:**
|
**Solution:**
|
||||||
```bash
|
```bash
|
||||||
# Create uploads directory with correct permissions
|
# Create uploads directory with correct permissions (radice del repo, non server/uploads)
|
||||||
mkdir -p server/uploads
|
mkdir -p uploads/images uploads/pdfs uploads/logos uploads/reports
|
||||||
chmod 755 server/uploads
|
chmod 755 uploads
|
||||||
```
|
```
|
||||||
|
|
||||||
### Migrations Failed
|
### Migrations Failed
|
||||||
@@ -702,13 +603,12 @@ alembic.util.exc.CommandError: Can't locate revision identified by...
|
|||||||
|
|
||||||
**Solution:**
|
**Solution:**
|
||||||
```bash
|
```bash
|
||||||
cd server
|
|
||||||
# Check migration status
|
# Check migration status
|
||||||
alembic current
|
uv run alembic -c src/backend/migrations/alembic.ini current
|
||||||
|
|
||||||
# Reset migrations (CAUTION - deletes data)
|
# Reset migrations (CAUTION - deletes data)
|
||||||
alembic downgrade base
|
uv run alembic -c src/backend/migrations/alembic.ini downgrade base
|
||||||
alembic upgrade head
|
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||||
```
|
```
|
||||||
|
|
||||||
### API Key Not Working
|
### API Key Not Working
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ TieMeasureFlow supports complete internationalization (i18n) with:
|
|||||||
## Directory Structure
|
## Directory Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
client/
|
src/frontend/flask_app/
|
||||||
├── translations/ # Flask-Babel translations
|
├── translations/ # Flask-Babel translations
|
||||||
│ ├── babel.cfg # Extraction config
|
│ ├── babel.cfg # Extraction config
|
||||||
│ ├── it/LC_MESSAGES/
|
│ ├── it/LC_MESSAGES/
|
||||||
@@ -99,7 +99,7 @@ flash(_("Profilo aggiornato con successo"))
|
|||||||
After editing .po files:
|
After editing .po files:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
cd src/frontend/flask_app
|
||||||
python compile_translations.py
|
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) |
|
| **Subtask** | Individual measurement point with tolerance limits (UTL/UWL/LWL/LTL) |
|
||||||
| **Measurement** | Individual recorded value with automatic pass/fail/warning status |
|
| **Measurement** | Individual recorded value with automatic pass/fail/warning status |
|
||||||
| **Lot/Serial** | Traceability fields linking measurements to physical parts |
|
| **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
|
### Architecture
|
||||||
|
|
||||||
@@ -175,23 +177,25 @@ TieMeasureFlow has four primary roles. Users can have multiple roles simultaneou
|
|||||||
|
|
||||||
### Admin
|
### Admin
|
||||||
|
|
||||||
**Purpose:** System administration and user management
|
**Purpose:** System administration, user management and station deployment
|
||||||
|
|
||||||
**Permissions:**
|
**Permissions:**
|
||||||
- Create/edit/delete users
|
- Create/edit/delete users
|
||||||
- Assign roles to users
|
- Assign roles to users
|
||||||
- Regenerate user API keys
|
- Regenerate user API keys
|
||||||
|
- Create/edit/delete stations and assign recipes to them
|
||||||
- Configure system settings
|
- Configure system settings
|
||||||
- Upload company logo
|
- Upload company logo
|
||||||
- Manage CSV export settings
|
- Manage CSV export settings
|
||||||
|
|
||||||
**Access:**
|
**Access:**
|
||||||
- Menu: **Admin** → User Management
|
- Menu: **Admin** → Utenti / Stazioni
|
||||||
- Can see: All users and system settings
|
- Can see: All users, all stations and system settings
|
||||||
|
|
||||||
**Typical Tasks:**
|
**Typical Tasks:**
|
||||||
- Onboard new users
|
- Onboard new users
|
||||||
- Reset lost API keys
|
- 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
|
- Configure locale/format settings
|
||||||
- Upload company branding
|
- Upload company branding
|
||||||
- Manage system access
|
- Manage system access
|
||||||
@@ -276,6 +280,12 @@ When you edit a recipe (add/remove tasks, change tolerances), a **new version**
|
|||||||
|
|
||||||
## MeasurementTec Workflow
|
## 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
|
### Select a Recipe
|
||||||
|
|
||||||
**Method 1: Search**
|
**Method 1: Search**
|
||||||
@@ -503,6 +513,82 @@ If user loses their API key:
|
|||||||
4. New key is generated and displayed
|
4. New key is generated and displayed
|
||||||
5. Provide new key to user (display once only)
|
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
|
### System Settings
|
||||||
|
|
||||||
#### Configure CSV Export
|
#### 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;
|
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
|
# All other requests → Flask client
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://client_frontend;
|
proxy_pass http://client_frontend;
|
||||||
@@ -50,12 +43,18 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Static file caching
|
# Static file caching. Limitata a /static/: un add_header in questo
|
||||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
|
# 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_pass http://client_frontend;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
expires 7d;
|
expires 7d;
|
||||||
add_header Cache-Control "public, immutable";
|
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)
|
# SSL configuration (uncomment when using Let's Encrypt)
|
||||||
|
|||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
[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'",
|
||||||
|
]
|
||||||
|
|
||||||
|
[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
|
||||||
|
|
||||||
|
[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,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())
|
||||||
@@ -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
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
"""Integration tests for /api/stations endpoints."""
|
|
||||||
import pytest
|
|
||||||
from httpx import AsyncClient
|
|
||||||
|
|
||||||
from tests.conftest import auth_headers, create_test_recipe
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_stations_requires_auth(client: AsyncClient):
|
|
||||||
resp = await client.get("/api/stations")
|
|
||||||
assert resp.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_station_as_admin(client: AsyncClient, admin_user):
|
|
||||||
resp = await client.post(
|
|
||||||
"/api/stations",
|
|
||||||
headers=auth_headers(admin_user),
|
|
||||||
json={"code": "ST-API", "name": "Via API"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 201, resp.text
|
|
||||||
body = resp.json()
|
|
||||||
assert body["code"] == "ST-API"
|
|
||||||
assert body["active"] is True
|
|
||||||
assert body["id"] > 0
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_station_non_admin_is_403(client: AsyncClient, maker_user):
|
|
||||||
resp = await client.post(
|
|
||||||
"/api/stations",
|
|
||||||
headers=auth_headers(maker_user),
|
|
||||||
json={"code": "ST-NO", "name": "No"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
async def test_update_station(client: AsyncClient, admin_user):
|
|
||||||
created = await client.post(
|
|
||||||
"/api/stations",
|
|
||||||
headers=auth_headers(admin_user),
|
|
||||||
json={"code": "ST-UP", "name": "Old"},
|
|
||||||
)
|
|
||||||
station_id = created.json()["id"]
|
|
||||||
resp = await client.put(
|
|
||||||
f"/api/stations/{station_id}",
|
|
||||||
headers=auth_headers(admin_user),
|
|
||||||
json={"name": "New", "active": False},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
body = resp.json()
|
|
||||||
assert body["name"] == "New"
|
|
||||||
assert body["active"] is False
|
|
||||||
|
|
||||||
|
|
||||||
async def test_delete_station(client: AsyncClient, admin_user):
|
|
||||||
created = await client.post(
|
|
||||||
"/api/stations",
|
|
||||||
headers=auth_headers(admin_user),
|
|
||||||
json={"code": "ST-D", "name": "D"},
|
|
||||||
)
|
|
||||||
sid = created.json()["id"]
|
|
||||||
resp = await client.delete(
|
|
||||||
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
|
||||||
)
|
|
||||||
assert resp.status_code == 204
|
|
||||||
again = await client.get(
|
|
||||||
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
|
||||||
)
|
|
||||||
assert again.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
async def test_assign_and_unassign_recipe(
|
|
||||||
client: AsyncClient, admin_user, db_session,
|
|
||||||
):
|
|
||||||
recipe = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-AS")
|
|
||||||
await db_session.commit()
|
|
||||||
created = await client.post(
|
|
||||||
"/api/stations",
|
|
||||||
headers=auth_headers(admin_user),
|
|
||||||
json={"code": "ST-ASSIGN", "name": "A"},
|
|
||||||
)
|
|
||||||
sid = created.json()["id"]
|
|
||||||
a = await client.post(
|
|
||||||
f"/api/stations/{sid}/recipes",
|
|
||||||
headers=auth_headers(admin_user),
|
|
||||||
json={"recipe_id": recipe.id},
|
|
||||||
)
|
|
||||||
assert a.status_code == 201
|
|
||||||
r = await client.get(
|
|
||||||
f"/api/stations/{sid}/recipes",
|
|
||||||
headers=auth_headers(admin_user),
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
assert [rec["code"] for rec in r.json()] == ["REC-AS"]
|
|
||||||
u = await client.delete(
|
|
||||||
f"/api/stations/{sid}/recipes/{recipe.id}",
|
|
||||||
headers=auth_headers(admin_user),
|
|
||||||
)
|
|
||||||
assert u.status_code == 204
|
|
||||||
r2 = await client.get(
|
|
||||||
f"/api/stations/{sid}/recipes",
|
|
||||||
headers=auth_headers(admin_user),
|
|
||||||
)
|
|
||||||
assert r2.json() == []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_recipes_by_station_code(
|
|
||||||
client: AsyncClient, admin_user, measurement_tec_user, db_session,
|
|
||||||
):
|
|
||||||
recipe = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-BC")
|
|
||||||
await db_session.commit()
|
|
||||||
created = await client.post(
|
|
||||||
"/api/stations",
|
|
||||||
headers=auth_headers(admin_user),
|
|
||||||
json={"code": "ST-BC", "name": "BC"},
|
|
||||||
)
|
|
||||||
sid = created.json()["id"]
|
|
||||||
await client.post(
|
|
||||||
f"/api/stations/{sid}/recipes",
|
|
||||||
headers=auth_headers(admin_user),
|
|
||||||
json={"recipe_id": recipe.id},
|
|
||||||
)
|
|
||||||
resp = await client.get(
|
|
||||||
"/api/stations/by-code/ST-BC/recipes",
|
|
||||||
headers=auth_headers(measurement_tec_user),
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert [r["code"] for r in resp.json()] == ["REC-BC"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_recipes_by_unknown_code_404(
|
|
||||||
client: AsyncClient, measurement_tec_user,
|
|
||||||
):
|
|
||||||
resp = await client.get(
|
|
||||||
"/api/stations/by-code/ST-DOES-NOT-EXIST/recipes",
|
|
||||||
headers=auth_headers(measurement_tec_user),
|
|
||||||
)
|
|
||||||
assert resp.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
async def test_admin_can_list_stations(client: AsyncClient, admin_user):
|
|
||||||
await client.post(
|
|
||||||
"/api/stations",
|
|
||||||
headers=auth_headers(admin_user),
|
|
||||||
json={"code": "ST-L1", "name": "A"},
|
|
||||||
)
|
|
||||||
await client.post(
|
|
||||||
"/api/stations",
|
|
||||||
headers=auth_headers(admin_user),
|
|
||||||
json={"code": "ST-L2", "name": "B", "active": False},
|
|
||||||
)
|
|
||||||
resp = await client.get("/api/stations", headers=auth_headers(admin_user))
|
|
||||||
assert resp.status_code == 200
|
|
||||||
codes = {s["code"] for s in resp.json()}
|
|
||||||
assert {"ST-L1", "ST-L2"}.issubset(codes)
|
|
||||||
|
|
||||||
resp_active = await client.get(
|
|
||||||
"/api/stations?active_only=true", headers=auth_headers(admin_user),
|
|
||||||
)
|
|
||||||
assert resp_active.status_code == 200
|
|
||||||
active_codes = {s["code"] for s in resp_active.json()}
|
|
||||||
assert "ST-L1" in active_codes
|
|
||||||
assert "ST-L2" not in active_codes
|
|
||||||
|
|
||||||
|
|
||||||
async def test_assign_recipe_not_found_returns_404(
|
|
||||||
client: AsyncClient, admin_user,
|
|
||||||
):
|
|
||||||
created = await client.post(
|
|
||||||
"/api/stations",
|
|
||||||
headers=auth_headers(admin_user),
|
|
||||||
json={"code": "ST-NR", "name": "NR"},
|
|
||||||
)
|
|
||||||
sid = created.json()["id"]
|
|
||||||
resp = await client.post(
|
|
||||||
f"/api/stations/{sid}/recipes",
|
|
||||||
headers=auth_headers(admin_user),
|
|
||||||
json={"recipe_id": 99999},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
async def test_duplicate_assignment_returns_409(
|
|
||||||
client: AsyncClient, admin_user, db_session,
|
|
||||||
):
|
|
||||||
recipe = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-DUP")
|
|
||||||
await db_session.commit()
|
|
||||||
created = await client.post(
|
|
||||||
"/api/stations",
|
|
||||||
headers=auth_headers(admin_user),
|
|
||||||
json={"code": "ST-DUP-A", "name": "Dup"},
|
|
||||||
)
|
|
||||||
sid = created.json()["id"]
|
|
||||||
first = await client.post(
|
|
||||||
f"/api/stations/{sid}/recipes",
|
|
||||||
headers=auth_headers(admin_user),
|
|
||||||
json={"recipe_id": recipe.id},
|
|
||||||
)
|
|
||||||
assert first.status_code == 201
|
|
||||||
second = await client.post(
|
|
||||||
f"/api/stations/{sid}/recipes",
|
|
||||||
headers=auth_headers(admin_user),
|
|
||||||
json={"recipe_id": recipe.id},
|
|
||||||
)
|
|
||||||
assert second.status_code == 409
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
"""Tests for statistics router (/api/statistics)."""
|
|
||||||
import pytest
|
|
||||||
from httpx import AsyncClient
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from models.user import User
|
|
||||||
from models.measurement import Measurement
|
|
||||||
from tests.conftest import auth_headers, create_test_recipe
|
|
||||||
|
|
||||||
|
|
||||||
async def _seed_measurements(
|
|
||||||
client: AsyncClient,
|
|
||||||
tec_user: User,
|
|
||||||
recipe_id: int,
|
|
||||||
values: list[float],
|
|
||||||
) -> tuple[int, int]:
|
|
||||||
"""Create measurements and return (subtask_id, version_id)."""
|
|
||||||
tasks_resp = await client.get(
|
|
||||||
f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(tec_user)
|
|
||||||
)
|
|
||||||
task = tasks_resp.json()[0]
|
|
||||||
subtask_id = task["subtasks"][0]["id"]
|
|
||||||
version_id = task["version_id"]
|
|
||||||
|
|
||||||
for val in values:
|
|
||||||
await client.post(
|
|
||||||
"/api/measurements/",
|
|
||||||
headers=auth_headers(tec_user),
|
|
||||||
json={
|
|
||||||
"subtask_id": subtask_id,
|
|
||||||
"version_id": version_id,
|
|
||||||
"value": val,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return subtask_id, version_id
|
|
||||||
|
|
||||||
|
|
||||||
class TestSummary:
|
|
||||||
"""GET /api/statistics/summary tests."""
|
|
||||||
|
|
||||||
async def test_get_statistics_by_recipe(
|
|
||||||
self,
|
|
||||||
client: AsyncClient,
|
|
||||||
measurement_tec_user: User,
|
|
||||||
metrologist_user: User,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
):
|
|
||||||
"""Metrologist can get summary statistics."""
|
|
||||||
recipe = await create_test_recipe(
|
|
||||||
db_session, measurement_tec_user.id
|
|
||||||
)
|
|
||||||
await _seed_measurements(
|
|
||||||
client, measurement_tec_user, recipe.id,
|
|
||||||
[10.0, 10.1, 10.4, 10.6, 9.4], # pass, pass, warning, fail, fail
|
|
||||||
)
|
|
||||||
|
|
||||||
resp = await client.get(
|
|
||||||
"/api/statistics/summary",
|
|
||||||
headers=auth_headers(metrologist_user),
|
|
||||||
params={"recipe_id": recipe.id},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert data["total"] == 5
|
|
||||||
assert data["pass_count"] >= 0
|
|
||||||
assert data["warning_count"] >= 0
|
|
||||||
assert data["fail_count"] >= 0
|
|
||||||
assert data["pass_rate"] >= 0
|
|
||||||
# Total rates should sum to ~100
|
|
||||||
total_rate = data["pass_rate"] + data["warning_rate"] + data["fail_rate"]
|
|
||||||
assert abs(total_rate - 100.0) < 0.1
|
|
||||||
|
|
||||||
async def test_statistics_require_metrologist(
|
|
||||||
self,
|
|
||||||
client: AsyncClient,
|
|
||||||
maker_user: User,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
):
|
|
||||||
"""Non-Metrologist cannot access statistics."""
|
|
||||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
|
||||||
resp = await client.get(
|
|
||||||
"/api/statistics/summary",
|
|
||||||
headers=auth_headers(maker_user),
|
|
||||||
params={"recipe_id": recipe.id},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 403
|
|
||||||
|
|
||||||
async def test_statistics_empty(
|
|
||||||
self,
|
|
||||||
client: AsyncClient,
|
|
||||||
metrologist_user: User,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
):
|
|
||||||
"""Statistics on recipe with no measurements returns zeros."""
|
|
||||||
recipe = await create_test_recipe(
|
|
||||||
db_session, metrologist_user.id
|
|
||||||
)
|
|
||||||
resp = await client.get(
|
|
||||||
"/api/statistics/summary",
|
|
||||||
headers=auth_headers(metrologist_user),
|
|
||||||
params={"recipe_id": recipe.id},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert data["total"] == 0
|
|
||||||
assert data["pass_count"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestCapability:
|
|
||||||
"""GET /api/statistics/capability tests."""
|
|
||||||
|
|
||||||
async def test_capability_indices(
|
|
||||||
self,
|
|
||||||
client: AsyncClient,
|
|
||||||
measurement_tec_user: User,
|
|
||||||
metrologist_user: User,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
):
|
|
||||||
"""Capability endpoint returns Cp/Cpk/Pp/Ppk indices."""
|
|
||||||
recipe = await create_test_recipe(
|
|
||||||
db_session, measurement_tec_user.id
|
|
||||||
)
|
|
||||||
subtask_id, _ = await _seed_measurements(
|
|
||||||
client, measurement_tec_user, recipe.id,
|
|
||||||
[9.9, 10.0, 10.1, 9.8, 10.2, 10.0, 9.95, 10.05, 10.1, 9.9],
|
|
||||||
)
|
|
||||||
|
|
||||||
resp = await client.get(
|
|
||||||
"/api/statistics/capability",
|
|
||||||
headers=auth_headers(metrologist_user),
|
|
||||||
params={
|
|
||||||
"recipe_id": recipe.id,
|
|
||||||
"subtask_id": subtask_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert data["n"] == 10
|
|
||||||
assert data["mean"] is not None
|
|
||||||
assert data["std_dev"] is not None
|
|
||||||
# With UTL=10.5 and LTL=9.5, spread=1.0, Cp should be > 0
|
|
||||||
if data["cp"] is not None:
|
|
||||||
assert data["cp"] > 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestSPCData:
|
|
||||||
"""GET /api/statistics/control-chart tests."""
|
|
||||||
|
|
||||||
async def test_spc_data(
|
|
||||||
self,
|
|
||||||
client: AsyncClient,
|
|
||||||
measurement_tec_user: User,
|
|
||||||
metrologist_user: User,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
):
|
|
||||||
"""Control chart returns values, mean, UCL, LCL."""
|
|
||||||
recipe = await create_test_recipe(
|
|
||||||
db_session, measurement_tec_user.id
|
|
||||||
)
|
|
||||||
subtask_id, _ = await _seed_measurements(
|
|
||||||
client, measurement_tec_user, recipe.id,
|
|
||||||
[10.0, 10.1, 9.9, 10.05, 9.95],
|
|
||||||
)
|
|
||||||
|
|
||||||
resp = await client.get(
|
|
||||||
"/api/statistics/control-chart",
|
|
||||||
headers=auth_headers(metrologist_user),
|
|
||||||
params={
|
|
||||||
"recipe_id": recipe.id,
|
|
||||||
"subtask_id": subtask_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert len(data["values"]) == 5
|
|
||||||
assert data["mean"] is not None
|
|
||||||
assert data["ucl"] is not None
|
|
||||||
assert data["lcl"] is not None
|
|
||||||
assert data["ucl"] > data["mean"]
|
|
||||||
assert data["lcl"] < data["mean"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestDateFilter:
|
|
||||||
"""Date filter tests."""
|
|
||||||
|
|
||||||
async def test_statistics_date_filter(
|
|
||||||
self,
|
|
||||||
client: AsyncClient,
|
|
||||||
measurement_tec_user: User,
|
|
||||||
metrologist_user: User,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
):
|
|
||||||
"""Date filter parameters are accepted without error."""
|
|
||||||
recipe = await create_test_recipe(
|
|
||||||
db_session, measurement_tec_user.id
|
|
||||||
)
|
|
||||||
await _seed_measurements(
|
|
||||||
client, measurement_tec_user, recipe.id,
|
|
||||||
[10.0, 10.1],
|
|
||||||
)
|
|
||||||
|
|
||||||
resp = await client.get(
|
|
||||||
"/api/statistics/summary",
|
|
||||||
headers=auth_headers(metrologist_user),
|
|
||||||
params={
|
|
||||||
"recipe_id": recipe.id,
|
|
||||||
"date_from": "2020-01-01T00:00:00",
|
|
||||||
"date_to": "2099-12-31T23:59:59",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
# All measurements should be within this wide range
|
|
||||||
assert resp.json()["total"] >= 2
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
"""Tests for tasks router (/api/recipes/{id}/tasks, /api/tasks, /api/subtasks)."""
|
|
||||||
import pytest
|
|
||||||
from httpx import AsyncClient
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from models.user import User
|
|
||||||
from tests.conftest import auth_headers, create_test_recipe
|
|
||||||
|
|
||||||
|
|
||||||
class TestListTasks:
|
|
||||||
"""GET /api/recipes/{recipe_id}/tasks tests."""
|
|
||||||
|
|
||||||
async def test_list_tasks(
|
|
||||||
self,
|
|
||||||
client: AsyncClient,
|
|
||||||
maker_user: User,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
):
|
|
||||||
"""Authenticated user can list tasks for a recipe."""
|
|
||||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
|
||||||
resp = await client.get(
|
|
||||||
f"/api/recipes/{recipe.id}/tasks",
|
|
||||||
headers=auth_headers(maker_user),
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert isinstance(data, list)
|
|
||||||
assert len(data) >= 1
|
|
||||||
assert data[0]["title"] == "Test Task"
|
|
||||||
assert len(data[0]["subtasks"]) >= 1
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreateTask:
|
|
||||||
"""POST /api/recipes/{recipe_id}/tasks tests."""
|
|
||||||
|
|
||||||
async def test_create_task(
|
|
||||||
self,
|
|
||||||
client: AsyncClient,
|
|
||||||
maker_user: User,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
):
|
|
||||||
"""Maker can add a task (triggers copy-on-write new version)."""
|
|
||||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
|
||||||
resp = await client.post(
|
|
||||||
f"/api/recipes/{recipe.id}/tasks",
|
|
||||||
headers=auth_headers(maker_user),
|
|
||||||
json={
|
|
||||||
"title": "New Task",
|
|
||||||
"directive": "Inspect surface",
|
|
||||||
"subtasks": [
|
|
||||||
{
|
|
||||||
"marker_number": 1,
|
|
||||||
"description": "Surface roughness",
|
|
||||||
"nominal": 5.0,
|
|
||||||
"utl": 5.5,
|
|
||||||
"ltl": 4.5,
|
|
||||||
"unit": "um",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 201
|
|
||||||
data = resp.json()
|
|
||||||
assert data["title"] == "New Task"
|
|
||||||
assert len(data["subtasks"]) == 1
|
|
||||||
assert data["subtasks"][0]["description"] == "Surface roughness"
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateTask:
|
|
||||||
"""PUT /api/tasks/{task_id} tests."""
|
|
||||||
|
|
||||||
async def test_update_task(
|
|
||||||
self,
|
|
||||||
client: AsyncClient,
|
|
||||||
maker_user: User,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
):
|
|
||||||
"""Maker can update a task on the current version."""
|
|
||||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
|
||||||
# Get task list to find the task ID
|
|
||||||
tasks_resp = await client.get(
|
|
||||||
f"/api/recipes/{recipe.id}/tasks",
|
|
||||||
headers=auth_headers(maker_user),
|
|
||||||
)
|
|
||||||
task_id = tasks_resp.json()[0]["id"]
|
|
||||||
|
|
||||||
resp = await client.put(
|
|
||||||
f"/api/tasks/{task_id}",
|
|
||||||
headers=auth_headers(maker_user),
|
|
||||||
json={"title": "Updated Task Title"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.json()["title"] == "Updated Task Title"
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteTask:
|
|
||||||
"""DELETE /api/tasks/{task_id} tests."""
|
|
||||||
|
|
||||||
async def test_delete_task(
|
|
||||||
self,
|
|
||||||
client: AsyncClient,
|
|
||||||
maker_user: User,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
):
|
|
||||||
"""Maker can delete a task from the current version."""
|
|
||||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
|
||||||
tasks_resp = await client.get(
|
|
||||||
f"/api/recipes/{recipe.id}/tasks",
|
|
||||||
headers=auth_headers(maker_user),
|
|
||||||
)
|
|
||||||
task_id = tasks_resp.json()[0]["id"]
|
|
||||||
|
|
||||||
resp = await client.delete(
|
|
||||||
f"/api/tasks/{task_id}", headers=auth_headers(maker_user)
|
|
||||||
)
|
|
||||||
assert resp.status_code == 204
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreateSubtask:
|
|
||||||
"""POST /api/tasks/{task_id}/subtasks tests."""
|
|
||||||
|
|
||||||
async def test_create_subtask(
|
|
||||||
self,
|
|
||||||
client: AsyncClient,
|
|
||||||
maker_user: User,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
):
|
|
||||||
"""Maker can add a subtask to a task."""
|
|
||||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
|
||||||
tasks_resp = await client.get(
|
|
||||||
f"/api/recipes/{recipe.id}/tasks",
|
|
||||||
headers=auth_headers(maker_user),
|
|
||||||
)
|
|
||||||
task_id = tasks_resp.json()[0]["id"]
|
|
||||||
|
|
||||||
resp = await client.post(
|
|
||||||
f"/api/tasks/{task_id}/subtasks",
|
|
||||||
headers=auth_headers(maker_user),
|
|
||||||
json={
|
|
||||||
"marker_number": 2,
|
|
||||||
"description": "Width measurement",
|
|
||||||
"nominal": 20.0,
|
|
||||||
"utl": 20.5,
|
|
||||||
"ltl": 19.5,
|
|
||||||
"unit": "mm",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 201
|
|
||||||
data = resp.json()
|
|
||||||
assert data["marker_number"] == 2
|
|
||||||
assert data["description"] == "Width measurement"
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateSubtask:
|
|
||||||
"""PUT /api/subtasks/{subtask_id} tests."""
|
|
||||||
|
|
||||||
async def test_update_subtask(
|
|
||||||
self,
|
|
||||||
client: AsyncClient,
|
|
||||||
maker_user: User,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
):
|
|
||||||
"""Maker can update a subtask."""
|
|
||||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
|
||||||
tasks_resp = await client.get(
|
|
||||||
f"/api/recipes/{recipe.id}/tasks",
|
|
||||||
headers=auth_headers(maker_user),
|
|
||||||
)
|
|
||||||
subtask_id = tasks_resp.json()[0]["subtasks"][0]["id"]
|
|
||||||
|
|
||||||
resp = await client.put(
|
|
||||||
f"/api/subtasks/{subtask_id}",
|
|
||||||
headers=auth_headers(maker_user),
|
|
||||||
json={"description": "Updated description", "nominal": 12.0},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.json()["description"] == "Updated description"
|
|
||||||
|
|
||||||
|
|
||||||
class TestReorderTasks:
|
|
||||||
"""PUT /api/tasks/reorder tests."""
|
|
||||||
|
|
||||||
async def test_reorder_tasks(
|
|
||||||
self,
|
|
||||||
client: AsyncClient,
|
|
||||||
maker_user: User,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
):
|
|
||||||
"""Maker can reorder tasks by providing task IDs in desired order."""
|
|
||||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
|
||||||
|
|
||||||
# Add a second task via copy-on-write
|
|
||||||
add_resp = await client.post(
|
|
||||||
f"/api/recipes/{recipe.id}/tasks",
|
|
||||||
headers=auth_headers(maker_user),
|
|
||||||
json={"title": "Second Task", "subtasks": []},
|
|
||||||
)
|
|
||||||
assert add_resp.status_code == 201
|
|
||||||
|
|
||||||
# Get tasks from new version
|
|
||||||
tasks_resp = await client.get(
|
|
||||||
f"/api/recipes/{recipe.id}/tasks",
|
|
||||||
headers=auth_headers(maker_user),
|
|
||||||
)
|
|
||||||
tasks = tasks_resp.json()
|
|
||||||
assert len(tasks) >= 2
|
|
||||||
|
|
||||||
task_ids = [t["id"] for t in tasks]
|
|
||||||
# Reverse the order
|
|
||||||
reversed_ids = list(reversed(task_ids))
|
|
||||||
|
|
||||||
resp = await client.put(
|
|
||||||
"/api/tasks/reorder",
|
|
||||||
headers=auth_headers(maker_user),
|
|
||||||
json={"task_ids": reversed_ids},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
reordered = resp.json()
|
|
||||||
assert [t["id"] for t in reordered] == reversed_ids
|
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
"""FastAPI middleware for TieMeasureFlow."""
|
"""FastAPI middleware for TieMeasureFlow."""
|
||||||
from middleware.api_key import (
|
from src.backend.api.middleware.api_key import (
|
||||||
get_current_user,
|
get_current_user,
|
||||||
require_role,
|
require_role,
|
||||||
require_admin,
|
require_admin,
|
||||||
require_maker,
|
require_maker,
|
||||||
require_measurement_tec,
|
require_measurement_tec,
|
||||||
require_metrologist,
|
require_metrologist,
|
||||||
|
require_supervisor,
|
||||||
require_admin_user,
|
require_admin_user,
|
||||||
)
|
)
|
||||||
from middleware.logging import AccessLogMiddleware
|
from src.backend.api.middleware.logging import AccessLogMiddleware
|
||||||
from middleware.rate_limit import RateLimitMiddleware
|
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||||
from middleware.security_headers import SecurityHeadersMiddleware
|
from src.backend.api.middleware.security_headers import SecurityHeadersMiddleware
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"get_current_user",
|
"get_current_user",
|
||||||
@@ -19,6 +20,7 @@ __all__ = [
|
|||||||
"require_maker",
|
"require_maker",
|
||||||
"require_measurement_tec",
|
"require_measurement_tec",
|
||||||
"require_metrologist",
|
"require_metrologist",
|
||||||
|
"require_supervisor",
|
||||||
"require_admin_user",
|
"require_admin_user",
|
||||||
"AccessLogMiddleware",
|
"AccessLogMiddleware",
|
||||||
"RateLimitMiddleware",
|
"RateLimitMiddleware",
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
"""API Key authentication dependency for FastAPI."""
|
"""API Key authentication dependency for FastAPI."""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from models.user import User
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.api_key import ApiKey
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services.auth_service import hash_api_key
|
||||||
|
from src.backend.utils.tempo import utcnow
|
||||||
|
|
||||||
|
_RENEW_INTERVAL = timedelta(minutes=5)
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
@@ -24,15 +32,25 @@ async def get_current_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(User).where(User.api_key == api_key, User.active == True)
|
select(ApiKey, User)
|
||||||
|
.join(User, User.id == ApiKey.user_id)
|
||||||
|
.where(ApiKey.key_hash == hash_api_key(api_key), User.active == True)
|
||||||
)
|
)
|
||||||
user = result.scalar_one_or_none()
|
row = result.first()
|
||||||
|
now = utcnow()
|
||||||
|
|
||||||
if user is None:
|
if row is None or row[0].expires_at <= now:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid or inactive API key",
|
detail="Invalid or expired API key",
|
||||||
)
|
)
|
||||||
|
key_row, user = row
|
||||||
|
|
||||||
|
# Sliding TTL, written at most once per _RENEW_INTERVAL to spare the DB.
|
||||||
|
if key_row.last_used_at is None or now - key_row.last_used_at > _RENEW_INTERVAL:
|
||||||
|
key_row.last_used_at = now
|
||||||
|
key_row.expires_at = now + timedelta(hours=settings.api_key_ttl_hours)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
# Store user_id in request state for access logging middleware
|
# Store user_id in request state for access logging middleware
|
||||||
request.state.user_id = user.id
|
request.state.user_id = user.id
|
||||||
@@ -68,4 +86,5 @@ def require_admin():
|
|||||||
require_maker = require_role("Maker")
|
require_maker = require_role("Maker")
|
||||||
require_measurement_tec = require_role("MeasurementTec")
|
require_measurement_tec = require_role("MeasurementTec")
|
||||||
require_metrologist = require_role("Metrologist")
|
require_metrologist = require_role("Metrologist")
|
||||||
|
require_supervisor = require_role("Supervisor")
|
||||||
require_admin_user = require_admin()
|
require_admin_user = require_admin()
|
||||||
@@ -6,8 +6,13 @@ from fastapi import Request, Response
|
|||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from sqlalchemy import insert
|
from sqlalchemy import insert
|
||||||
|
|
||||||
from database import async_session_factory
|
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||||
from models.access_log import AccessLog
|
from src.backend.database import async_session_factory
|
||||||
|
from src.backend.models.orm.access_log import AccessLog
|
||||||
|
|
||||||
|
# AccessLog.action is String(100): a long path (with query-string-like
|
||||||
|
# segments) silently failed the insert before this truncation existed.
|
||||||
|
_ACTION_MAX_LENGTH = 100
|
||||||
|
|
||||||
|
|
||||||
class AccessLogMiddleware(BaseHTTPMiddleware):
|
class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||||
@@ -30,11 +35,12 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
# Log asynchronously (don't block response)
|
# Log asynchronously (don't block response)
|
||||||
try:
|
try:
|
||||||
|
action = f"{request.method} {request.url.path}"[:_ACTION_MAX_LENGTH]
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
await session.execute(
|
await session.execute(
|
||||||
insert(AccessLog).values(
|
insert(AccessLog).values(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
action=f"{request.method} {request.url.path}",
|
action=action,
|
||||||
details={
|
details={
|
||||||
"method": request.method,
|
"method": request.method,
|
||||||
"path": request.url.path,
|
"path": request.url.path,
|
||||||
@@ -42,7 +48,7 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
|
|||||||
"status_code": response.status_code,
|
"status_code": response.status_code,
|
||||||
"duration_ms": round(duration_ms, 2),
|
"duration_ms": round(duration_ms, 2),
|
||||||
},
|
},
|
||||||
ip_address=request.client.host if request.client else None,
|
ip_address=RateLimitMiddleware._client_ip(request),
|
||||||
user_agent=request.headers.get("user-agent", "")[:500],
|
user_agent=request.headers.get("user-agent", "")[:500],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Rate limiting middleware for FastAPI.
|
||||||
|
|
||||||
|
Implements in-memory sliding window rate limiting per client IP.
|
||||||
|
Configurable limits for login and endpoints that carry credentials, and for
|
||||||
|
everything else.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
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 src.backend.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Middleware that enforces per-IP rate limits using a sliding window.
|
||||||
|
|
||||||
|
- Login bucket: `/api/auth/login`, the paths that carry a capoturno's
|
||||||
|
credentials (`/api/measurements/*/authorise`,
|
||||||
|
`/api/production-runs/{id}/pause|resume|close`), and everything under
|
||||||
|
`/api/setup/` (the setup password). Limited to `rate_limit_login`
|
||||||
|
req/min. Guessing a supervisor's or the setup password is the same
|
||||||
|
attack as guessing a login password, just aimed at a different endpoint.
|
||||||
|
- All other endpoints: limited to `rate_limit_general` req/min.
|
||||||
|
Returns HTTP 429 with Retry-After header when limit exceeded.
|
||||||
|
|
||||||
|
Both configured limits are divided across worker processes before being
|
||||||
|
applied (see `_effective_limit`): each bucket lives in this process's
|
||||||
|
memory, so with `--workers N` a single configured limit is enforced N
|
||||||
|
times over across the cluster unless it is divided down first.
|
||||||
|
"""
|
||||||
|
|
||||||
|
LOGIN_PATH = "/api/auth/login"
|
||||||
|
# #59: il login con PIN e' lo stesso attacco (4 cifre: ancora piu' facile da indovinare)
|
||||||
|
LOGIN_PIN_PATH = "/api/auth/login-pin"
|
||||||
|
WINDOW_SECONDS = 60
|
||||||
|
|
||||||
|
# Paths that carry a supervisor's (capoturno) credentials in the request
|
||||||
|
# body. Matched by pattern, not equality, because the id in the middle
|
||||||
|
# varies per request.
|
||||||
|
_SUPERVISOR_CREDENTIAL_PATTERNS = (
|
||||||
|
re.compile(r"^/api/measurements/\d+/authorise$"),
|
||||||
|
re.compile(r"^/api/production-runs/\d+/(?:pause|resume|close)$"),
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _client_ip(request: Request) -> str:
|
||||||
|
"""Resolve the originating client IP behind `trusted_proxy_count` proxies.
|
||||||
|
|
||||||
|
Nginx (and, behind it, the Flask client) each add one hop to
|
||||||
|
X-Forwarded-For. The *leftmost* entries are whatever the original
|
||||||
|
caller claimed and are trivially spoofed; trust only extends to the
|
||||||
|
hops actually appended by our own trusted proxies, counted from the
|
||||||
|
*right*. With `trusted_proxy_count = N`, the client IP is the Nth
|
||||||
|
element from the end of XFF (the one added by the outermost trusted
|
||||||
|
proxy). If XFF has fewer elements than that, or is absent, fall back
|
||||||
|
to `request.client.host` - except that `X-Real-IP` is tried first when
|
||||||
|
XFF is simply missing and at least one proxy is trusted.
|
||||||
|
|
||||||
|
With `trusted_proxy_count = 0`, no proxy header is honored at all
|
||||||
|
(neither XFF nor X-Real-IP) and only `request.client.host` is used.
|
||||||
|
"""
|
||||||
|
count = settings.trusted_proxy_count
|
||||||
|
if count > 0:
|
||||||
|
xff = request.headers.get("x-forwarded-for")
|
||||||
|
if xff:
|
||||||
|
hops = [h.strip() for h in xff.split(",") if h.strip()]
|
||||||
|
if len(hops) >= count:
|
||||||
|
return hops[-count]
|
||||||
|
# Fewer hops than trusted proxies: the chain is short of what
|
||||||
|
# was configured, so fall back rather than trust an
|
||||||
|
# under-populated header.
|
||||||
|
else:
|
||||||
|
real = request.headers.get("x-real-ip")
|
||||||
|
if real:
|
||||||
|
return real.strip()
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _is_login_bucket_request(cls, method: str, path: str) -> bool:
|
||||||
|
"""Whether this request counts against the login bucket rather than general.
|
||||||
|
|
||||||
|
`/api/setup/*` is checked regardless of method: `GET /api/setup/status`
|
||||||
|
now accepts an optional password (see setup.py), so guessing it there
|
||||||
|
is the same attack as guessing it on any of the POST endpoints.
|
||||||
|
"""
|
||||||
|
if path.startswith("/api/setup/"):
|
||||||
|
return True
|
||||||
|
if method != "POST":
|
||||||
|
return False
|
||||||
|
if path in (cls.LOGIN_PATH, cls.LOGIN_PIN_PATH):
|
||||||
|
return True
|
||||||
|
return any(p.match(path) for p in cls._SUPERVISOR_CREDENTIAL_PATTERNS)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _effective_limit(configured: int) -> int:
|
||||||
|
"""Divide a configured per-minute limit across worker processes.
|
||||||
|
|
||||||
|
Rounds up and never returns less than 1, so a low configured limit
|
||||||
|
with high concurrency still allows at least one request per worker
|
||||||
|
per window rather than locking everyone out.
|
||||||
|
"""
|
||||||
|
concurrency = max(settings.web_concurrency, 1)
|
||||||
|
return max(1, -(-configured // concurrency))
|
||||||
|
|
||||||
|
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 = self._client_ip(request)
|
||||||
|
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-bucket rate limit (login itself, supervisor-credential
|
||||||
|
# paths, and the setup password)
|
||||||
|
if self._is_login_bucket_request(request.method, path):
|
||||||
|
allowed, retry_after = self._check_rate_limit(
|
||||||
|
self._login_requests, client_ip,
|
||||||
|
self._effective_limit(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,
|
||||||
|
self._effective_limit(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)
|
||||||
+9
-7
@@ -8,18 +8,20 @@ from typing import Callable
|
|||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
|
|
||||||
# Content Security Policy - allows CDN resources used by the client
|
# Content Security Policy - same-origin only: every third-party library now ships with
|
||||||
|
# the app (see frontend static/vendor/), so no CDN host needs allowing. The install runs
|
||||||
|
# on an isolated shop-floor network where an outbound fetch would simply fail.
|
||||||
# Note: 'unsafe-eval' required for Plotly.js runtime evaluation in SPC charts
|
# Note: 'unsafe-eval' required for Plotly.js runtime evaluation in SPC charts
|
||||||
CSP = (
|
CSP = (
|
||||||
"default-src 'self'; "
|
"default-src 'self'; "
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' "
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
||||||
"https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://cdn.plot.ly; "
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
"font-src 'self'; "
|
||||||
"font-src 'self' https://fonts.gstatic.com; "
|
|
||||||
"img-src 'self' data: blob:; "
|
"img-src 'self' data: blob:; "
|
||||||
"connect-src 'self'"
|
"connect-src 'self'; "
|
||||||
|
"worker-src 'self' blob:"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Authentication router - login, logout, profile."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.database import get_db
|
||||||
|
from src.backend.api.middleware.api_key import get_current_user
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.models.api.user import (
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
PinLoginRequest,
|
||||||
|
PinUserItem,
|
||||||
|
UserProfileUpdate,
|
||||||
|
UserResponse,
|
||||||
|
)
|
||||||
|
from src.backend.services.auth_service import (
|
||||||
|
authenticate_pin,
|
||||||
|
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.get("/pin-users", response_model=list[PinUserItem])
|
||||||
|
async def pin_users(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Utenti che possono entrare con PIN (#59): attivi, non admin, con PIN.
|
||||||
|
|
||||||
|
Pubblico per costruzione (serve prima del login): espone solo id e nome.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(
|
||||||
|
User.active == True, # noqa: E712
|
||||||
|
User.is_admin == False, # noqa: E712
|
||||||
|
User.pin_hash.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
items = [
|
||||||
|
PinUserItem(id=u.id, display_name=u.display_name or u.username)
|
||||||
|
for u in result.scalars().all()
|
||||||
|
]
|
||||||
|
items.sort(key=lambda i: i.display_name.casefold())
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login-pin", response_model=LoginResponse)
|
||||||
|
async def login_pin(data: PinLoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Login con user_id e PIN a 4 cifre (#59); stessa risposta del login."""
|
||||||
|
user = await authenticate_pin(db, data.user_id, data.pin)
|
||||||
|
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(
|
||||||
|
request: Request,
|
||||||
|
all_sessions: bool = False,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Logout. Deletes this session's API key only; ?all_sessions=true
|
||||||
|
deletes every key of the user."""
|
||||||
|
await logout_user(
|
||||||
|
db, user, key=request.headers.get("X-API-Key"), all_sessions=all_sessions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
"""File upload/download/delete router for images and PDFs."""
|
||||||
|
import logging
|
||||||
|
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 sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.database import get_db
|
||||||
|
from src.backend.api.middleware.api_key import get_current_user, require_maker
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
# Magic-byte signatures: the client-declared Content-Type is only what the
|
||||||
|
# browser claimed and cannot be trusted (#36 point 2 — a file named "x.html"
|
||||||
|
# declared image/png was stored and then served by extension as text/html,
|
||||||
|
# which a browser executes). SVG is intentionally not recognised: it is not
|
||||||
|
# in ALLOWED_TYPES and can itself carry script content.
|
||||||
|
_MAGIC_SIGNATURES: list[tuple[bytes, str, str]] = [
|
||||||
|
# (signature, mime type, canonical extension)
|
||||||
|
(b"\x89PNG\r\n\x1a\n", "image/png", ".png"),
|
||||||
|
(b"\xff\xd8\xff", "image/jpeg", ".jpg"),
|
||||||
|
(b"GIF87a", "image/gif", ".gif"),
|
||||||
|
(b"GIF89a", "image/gif", ".gif"),
|
||||||
|
(b"%PDF-", "application/pdf", ".pdf"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Read uploads in chunks so a declared-huge body is rejected as soon as it
|
||||||
|
# crosses the limit, instead of being buffered into memory in full first.
|
||||||
|
_UPLOAD_CHUNK_SIZE = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def detect_file_type(content: bytes) -> tuple[str, str] | None:
|
||||||
|
"""Identify a file's real type from its magic bytes.
|
||||||
|
|
||||||
|
Returns (mime_type, canonical_extension), or None when the content does
|
||||||
|
not match any type this system serves.
|
||||||
|
"""
|
||||||
|
for signature, mime, ext in _MAGIC_SIGNATURES:
|
||||||
|
if content.startswith(signature):
|
||||||
|
return mime, ext
|
||||||
|
# WebP: "RIFF" + 4-byte little-endian size + "WEBP"; the size varies.
|
||||||
|
if len(content) >= 12 and content[0:4] == b"RIFF" and content[8:12] == b"WEBP":
|
||||||
|
return "image/webp", ".webp"
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Top-level folders nothing may DELETE through this router: statistics/reports are
|
||||||
|
# audit records, the logo is only ever replaced via POST /api/settings/logo.
|
||||||
|
# task_results/: allegati degli esiti dei task (#55), record di audit.
|
||||||
|
_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/", "task_results/")
|
||||||
|
|
||||||
|
# GET under these needs a role that is meant to see production data, not just
|
||||||
|
# any authenticated user (a Maker or MeasurementTec has no business reading
|
||||||
|
# every production's statistics/report file by guessing its name).
|
||||||
|
_RESTRICTED_GET_PREFIXES = ("statistics/", "reports/")
|
||||||
|
|
||||||
|
|
||||||
|
def _can_read_restricted(user: User) -> bool:
|
||||||
|
return user.is_admin or user.has_role("Metrologist") or user.has_role("Supervisor")
|
||||||
|
|
||||||
|
|
||||||
|
async def _is_recipe_file_protected(db: AsyncSession, relative_path: str) -> bool:
|
||||||
|
"""True when `relative_path` (uploads-relative, forward slashes) must not be
|
||||||
|
deleted: it is a recipe's cover image, or a task/subtask file belonging to a
|
||||||
|
version that is either not the current one (kept for history) or already has
|
||||||
|
measurements against it (the audit trail needs it to make sense of them).
|
||||||
|
|
||||||
|
A path not referenced anywhere is safe to delete (orphaned upload).
|
||||||
|
"""
|
||||||
|
recipe_hit = (await db.execute(
|
||||||
|
select(Recipe.id).where(Recipe.image_path == relative_path)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if recipe_hit is not None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
version_id = (await db.execute(
|
||||||
|
select(RecipeTask.version_id).where(RecipeTask.file_path == relative_path)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
if version_id is None:
|
||||||
|
version_id = (await db.execute(
|
||||||
|
select(RecipeTask.version_id)
|
||||||
|
.join(RecipeSubtask, RecipeSubtask.task_id == RecipeTask.id)
|
||||||
|
.where(RecipeSubtask.image_path == relative_path)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
if version_id is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
version = (await db.execute(
|
||||||
|
select(RecipeVersion).where(RecipeVersion.id == version_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if version is None or not version.is_current:
|
||||||
|
return True
|
||||||
|
|
||||||
|
has_measurements = (await db.execute(
|
||||||
|
select(Measurement.id).where(Measurement.version_id == version_id).limit(1)
|
||||||
|
)).scalar_one_or_none() is not None
|
||||||
|
return has_measurements
|
||||||
|
|
||||||
|
|
||||||
|
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 resolve_upload_path(file_path: str) -> Path:
|
||||||
|
"""Resolve a user-supplied relative path inside the uploads directory.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if the path escapes the uploads directory,
|
||||||
|
cannot be resolved, or does not point to an existing file.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
full_path = (settings.upload_path / file_path).resolve()
|
||||||
|
if not full_path.is_relative_to(settings.upload_path.resolve()):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Access denied",
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except (OSError, RuntimeError, ValueError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="File not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
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 full_path
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
logger.warning("Thumbnail generation failed for %s: %s", image_path, 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. The type is authoritatively
|
||||||
|
determined from the file's magic bytes, not the client-declared
|
||||||
|
Content-Type (#36 point 2): the stored extension always matches what was
|
||||||
|
actually detected, so it can never be served back as something else.
|
||||||
|
"""
|
||||||
|
# Read in chunks, stopping as soon as the size limit is crossed instead
|
||||||
|
# of buffering an oversized body into memory first.
|
||||||
|
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||||
|
buffer = bytearray()
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(_UPLOAD_CHUNK_SIZE)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buffer.extend(chunk)
|
||||||
|
if len(buffer) > max_bytes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB",
|
||||||
|
)
|
||||||
|
content = bytes(buffer)
|
||||||
|
file_size = len(content)
|
||||||
|
|
||||||
|
# Validate file type from the actual bytes, not file.content_type
|
||||||
|
detected = detect_file_type(content)
|
||||||
|
if detected is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="File type not allowed. Must be a recognised image or PDF.",
|
||||||
|
)
|
||||||
|
detected_mime, detected_ext = detected
|
||||||
|
|
||||||
|
# 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, then force its extension to the one
|
||||||
|
# actually detected: a declared/renamed extension is not trusted either.
|
||||||
|
safe_filename = sanitize_filename(file.filename or "")
|
||||||
|
base_name = Path(safe_filename).stem or "file"
|
||||||
|
safe_filename = f"{base_name}{detected_ext}"
|
||||||
|
|
||||||
|
# Generate unique filename if file already exists
|
||||||
|
file_path = storage_dir / safe_filename
|
||||||
|
counter = 1
|
||||||
|
while file_path.exists():
|
||||||
|
file_path = storage_dir / f"{base_name}_{counter}{detected_ext}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
# Write file
|
||||||
|
file_path.write_bytes(content)
|
||||||
|
|
||||||
|
# Generate thumbnail for images
|
||||||
|
thumbnail_path = None
|
||||||
|
if detected_mime 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": detected_mime,
|
||||||
|
"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; statistics/ and reports/ additionally require
|
||||||
|
Metrologist, Supervisor or admin, since those are production data.
|
||||||
|
"""
|
||||||
|
if file_path.startswith(_RESTRICTED_GET_PREFIXES) and not _can_read_restricted(user):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Metrologist, Supervisor or admin role required",
|
||||||
|
)
|
||||||
|
full_path = resolve_upload_path(file_path)
|
||||||
|
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),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a file from the uploads directory.
|
||||||
|
|
||||||
|
Also deletes associated thumbnail if it exists. statistics/, reports/ and
|
||||||
|
logos/ can never be deleted here (403); a recipe file still referenced by
|
||||||
|
its current version or by a version with measurements is kept (409).
|
||||||
|
"""
|
||||||
|
if file_path.startswith(_UNDELETABLE_PREFIXES):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="This folder cannot be deleted through the files API.",
|
||||||
|
)
|
||||||
|
|
||||||
|
full_path = resolve_upload_path(file_path)
|
||||||
|
|
||||||
|
relative_path = str(full_path.relative_to(settings.upload_path)).replace("\\", "/")
|
||||||
|
if await _is_recipe_file_protected(db, relative_path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="File is referenced by a current or measured recipe version.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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()
|
||||||
@@ -2,29 +2,36 @@
|
|||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from src.backend.models.api.tipi import UtcNaiveInput
|
||||||
|
from src.backend.utils.tempo import as_utc_naive, iso_utc, to_local, utcnow
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy import and_, func, select
|
from sqlalchemy import and_, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import (
|
from src.backend.api.middleware.api_key import (
|
||||||
get_current_user,
|
get_current_user,
|
||||||
require_measurement_tec,
|
require_measurement_tec,
|
||||||
require_metrologist,
|
require_metrologist,
|
||||||
)
|
)
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from models.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
from models.setting import SystemSetting
|
from src.backend.models.orm.setting import SystemSetting
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.measurement import (
|
from src.backend.models.api.measurement import (
|
||||||
|
CurrentCycleResponse,
|
||||||
|
MeasurementAuthorisation,
|
||||||
MeasurementBatchCreate,
|
MeasurementBatchCreate,
|
||||||
MeasurementCreate,
|
MeasurementCreate,
|
||||||
MeasurementListResponse,
|
MeasurementListResponse,
|
||||||
MeasurementResponse,
|
MeasurementResponse,
|
||||||
|
TaskProgressListResponse,
|
||||||
)
|
)
|
||||||
from services.measurement_service import save_measurement
|
from src.backend.services import auth_service, measurement_service
|
||||||
|
from src.backend.services.measurement_service import save_measurement
|
||||||
|
from src.backend.services.production_export_service import neutralise_csv_cell
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
|
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
|
||||||
|
|
||||||
@@ -46,6 +53,8 @@ async def create_measurement(
|
|||||||
lot_number=data.lot_number,
|
lot_number=data.lot_number,
|
||||||
serial_number=data.serial_number,
|
serial_number=data.serial_number,
|
||||||
input_method=data.input_method,
|
input_method=data.input_method,
|
||||||
|
input_duration_ms=data.input_duration_ms,
|
||||||
|
production_run_id=data.production_run_id,
|
||||||
)
|
)
|
||||||
return MeasurementResponse.model_validate(measurement)
|
return MeasurementResponse.model_validate(measurement)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -55,6 +64,97 @@ async def create_measurement(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pending-authorisation", response_model=MeasurementResponse | None)
|
||||||
|
async def get_pending_authorisation(
|
||||||
|
version_id: int = Query(..., gt=0),
|
||||||
|
production_run_id: int | None = Query(None, gt=0),
|
||||||
|
user: User = Depends(require_measurement_tec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""The out-of-tolerance measurement holding this operator up, or null.
|
||||||
|
|
||||||
|
Asked on load, so that reloading the page is not a way past the gate: the
|
||||||
|
screen finds the same block waiting for it that the server enforces.
|
||||||
|
"""
|
||||||
|
blocking = await measurement_service.pending_authorisation(
|
||||||
|
db, version_id, user.id, production_run_id,
|
||||||
|
)
|
||||||
|
return MeasurementResponse.model_validate(blocking) if blocking else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/task-progress", response_model=TaskProgressListResponse)
|
||||||
|
async def get_task_progress(
|
||||||
|
version_id: int = Query(..., gt=0),
|
||||||
|
production_run_id: int | None = Query(None, gt=0),
|
||||||
|
user: User = Depends(require_measurement_tec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""How many quotes of each measurement task are already taken.
|
||||||
|
|
||||||
|
The task list is where an operator picks up work they left behind, and until
|
||||||
|
now it showed a task begun and a task never touched identically.
|
||||||
|
"""
|
||||||
|
tasks = await measurement_service.task_progress(
|
||||||
|
db, version_id, user.id, production_run_id,
|
||||||
|
)
|
||||||
|
return TaskProgressListResponse(tasks=tasks)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/current-cycle", response_model=CurrentCycleResponse)
|
||||||
|
async def get_current_cycle(
|
||||||
|
production_run_id: int = Query(..., gt=0),
|
||||||
|
task_id: int = Query(..., gt=0),
|
||||||
|
user: User = Depends(require_measurement_tec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Quotes of this task already measured in the cycle under way.
|
||||||
|
|
||||||
|
Asked on load, so that a reload mid-cycle does not present quotes again that
|
||||||
|
were taken a moment ago.
|
||||||
|
"""
|
||||||
|
rows = await measurement_service.current_cycle_measurements(
|
||||||
|
db, production_run_id, task_id,
|
||||||
|
)
|
||||||
|
return CurrentCycleResponse(measurements=rows)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{measurement_id}/authorise", response_model=MeasurementResponse)
|
||||||
|
async def authorise_measurement(
|
||||||
|
measurement_id: int,
|
||||||
|
action: MeasurementAuthorisation,
|
||||||
|
user: User = Depends(require_measurement_tec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Let an out-of-tolerance value stand, on the capoturno's name.
|
||||||
|
|
||||||
|
Credentials go to this endpoint rather than to a separate check because the
|
||||||
|
approval has to end up attached to the measurement. A check whose answer is
|
||||||
|
thrown away is what this point had before: a modal that closed.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Measurement).where(Measurement.id == measurement_id)
|
||||||
|
)
|
||||||
|
measurement = result.scalar_one_or_none()
|
||||||
|
if measurement is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Measurement not found",
|
||||||
|
)
|
||||||
|
if measurement.pass_fail != "fail":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="This measurement is within tolerance: there is nothing to authorise",
|
||||||
|
)
|
||||||
|
|
||||||
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
|
db, action.supervisor_username, action.supervisor_password,
|
||||||
|
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
|
||||||
|
)
|
||||||
|
measurement = await measurement_service.authorise_measurement(
|
||||||
|
db, measurement, supervisor,
|
||||||
|
)
|
||||||
|
return MeasurementResponse.model_validate(measurement)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batch", response_model=list[MeasurementResponse])
|
@router.post("/batch", response_model=list[MeasurementResponse])
|
||||||
async def create_measurement_batch(
|
async def create_measurement_batch(
|
||||||
data: MeasurementBatchCreate,
|
data: MeasurementBatchCreate,
|
||||||
@@ -74,6 +174,8 @@ async def create_measurement_batch(
|
|||||||
lot_number=measurement_data.lot_number,
|
lot_number=measurement_data.lot_number,
|
||||||
serial_number=measurement_data.serial_number,
|
serial_number=measurement_data.serial_number,
|
||||||
input_method=measurement_data.input_method,
|
input_method=measurement_data.input_method,
|
||||||
|
input_duration_ms=measurement_data.input_duration_ms,
|
||||||
|
production_run_id=measurement_data.production_run_id,
|
||||||
)
|
)
|
||||||
measurements.append(measurement)
|
measurements.append(measurement)
|
||||||
return [MeasurementResponse.model_validate(m) for m in measurements]
|
return [MeasurementResponse.model_validate(m) for m in measurements]
|
||||||
@@ -84,24 +186,19 @@ async def create_measurement_batch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=MeasurementListResponse)
|
def _build_measurement_filters(
|
||||||
async def get_measurements(
|
recipe_id: int | None,
|
||||||
recipe_id: int | None = Query(None),
|
version_id: int | None,
|
||||||
version_id: int | None = Query(None),
|
subtask_id: int | None,
|
||||||
subtask_id: int | None = Query(None),
|
measured_by: int | None,
|
||||||
measured_by: int | None = Query(None),
|
lot_number: str | None,
|
||||||
lot_number: str | None = Query(None),
|
serial_number: str | None,
|
||||||
serial_number: str | None = Query(None),
|
date_from: datetime | None,
|
||||||
date_from: datetime | None = Query(None),
|
date_to: datetime | None,
|
||||||
date_to: datetime | None = Query(None),
|
pass_fail: str | None,
|
||||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
production_run_id: int | None = None,
|
||||||
page: int = Query(1, ge=1),
|
) -> list:
|
||||||
per_page: int = Query(50, ge=1, le=500),
|
"""Build SQLAlchemy filter conditions shared by list and CSV export."""
|
||||||
user: User = Depends(require_metrologist),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Query measurements with filters and pagination."""
|
|
||||||
# Build filter conditions
|
|
||||||
filters = []
|
filters = []
|
||||||
if recipe_id is not None:
|
if recipe_id is not None:
|
||||||
# Filter by recipe via subquery on RecipeVersion
|
# Filter by recipe via subquery on RecipeVersion
|
||||||
@@ -125,6 +222,40 @@ async def get_measurements(
|
|||||||
filters.append(Measurement.measured_at <= date_to)
|
filters.append(Measurement.measured_at <= date_to)
|
||||||
if pass_fail is not None:
|
if pass_fail is not None:
|
||||||
filters.append(Measurement.pass_fail == pass_fail)
|
filters.append(Measurement.pass_fail == pass_fail)
|
||||||
|
if production_run_id is not None:
|
||||||
|
filters.append(Measurement.production_run_id == production_run_id)
|
||||||
|
return filters
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=MeasurementListResponse)
|
||||||
|
async def get_measurements(
|
||||||
|
recipe_id: int | None = Query(None),
|
||||||
|
version_id: int | None = Query(None),
|
||||||
|
subtask_id: int | None = Query(None),
|
||||||
|
measured_by: int | None = Query(None),
|
||||||
|
lot_number: str | None = Query(None),
|
||||||
|
serial_number: str | None = Query(None),
|
||||||
|
date_from: UtcNaiveInput = Query(None),
|
||||||
|
date_to: UtcNaiveInput = Query(None),
|
||||||
|
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||||
|
production_run_id: int | None = Query(None, gt=0),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(50, ge=1, le=500),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Query measurements with filters and pagination.
|
||||||
|
|
||||||
|
Available to any authenticated user. The MeasurementTec workflow needs
|
||||||
|
this endpoint to render `task_complete.html` after running through a
|
||||||
|
recipe; the Metrologist dashboard uses the same endpoint with broader
|
||||||
|
filters. Measurements carry no PII beyond numeric values + a recipe
|
||||||
|
reference, so role-gating beyond authentication isn't justified.
|
||||||
|
"""
|
||||||
|
filters = _build_measurement_filters(
|
||||||
|
recipe_id, version_id, subtask_id, measured_by, lot_number,
|
||||||
|
serial_number, date_from, date_to, pass_fail, production_run_id,
|
||||||
|
)
|
||||||
|
|
||||||
# Build query
|
# Build query
|
||||||
query = select(Measurement).where(and_(*filters) if filters else True)
|
query = select(Measurement).where(and_(*filters) if filters else True)
|
||||||
@@ -162,15 +293,18 @@ async def export_measurements_csv(
|
|||||||
measured_by: int | None = Query(None),
|
measured_by: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Export measurements to CSV with configurable delimiter and decimal separator."""
|
"""Export measurements to CSV with configurable delimiter and decimal separator."""
|
||||||
# Check user has MeasurementTec or Metrologist role
|
# Check user has MeasurementTec or Metrologist role, same as require_role: an
|
||||||
if not (user.has_role("MeasurementTec") or user.has_role("Metrologist")):
|
# admin with no explicit role assigned is still let through (#36 point 3).
|
||||||
|
if not (
|
||||||
|
user.has_role("MeasurementTec") or user.has_role("Metrologist") or user.is_admin
|
||||||
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="MeasurementTec or Metrologist role required",
|
detail="MeasurementTec or Metrologist role required",
|
||||||
@@ -189,29 +323,10 @@ async def export_measurements_csv(
|
|||||||
decimal_setting = decimal_result.scalar_one_or_none()
|
decimal_setting = decimal_result.scalar_one_or_none()
|
||||||
decimal_separator = decimal_setting.setting_value if decimal_setting else "."
|
decimal_separator = decimal_setting.setting_value if decimal_setting else "."
|
||||||
|
|
||||||
# Build filter conditions (same as get_measurements)
|
filters = _build_measurement_filters(
|
||||||
filters = []
|
recipe_id, version_id, subtask_id, measured_by, lot_number,
|
||||||
if recipe_id is not None:
|
serial_number, date_from, date_to, pass_fail,
|
||||||
version_ids = select(RecipeVersion.id).where(
|
|
||||||
RecipeVersion.recipe_id == recipe_id
|
|
||||||
)
|
)
|
||||||
filters.append(Measurement.version_id.in_(version_ids))
|
|
||||||
if version_id is not None:
|
|
||||||
filters.append(Measurement.version_id == version_id)
|
|
||||||
if subtask_id is not None:
|
|
||||||
filters.append(Measurement.subtask_id == subtask_id)
|
|
||||||
if measured_by is not None:
|
|
||||||
filters.append(Measurement.measured_by == measured_by)
|
|
||||||
if lot_number is not None:
|
|
||||||
filters.append(Measurement.lot_number == lot_number)
|
|
||||||
if serial_number is not None:
|
|
||||||
filters.append(Measurement.serial_number == serial_number)
|
|
||||||
if date_from is not None:
|
|
||||||
filters.append(Measurement.measured_at >= date_from)
|
|
||||||
if date_to is not None:
|
|
||||||
filters.append(Measurement.measured_at <= date_to)
|
|
||||||
if pass_fail is not None:
|
|
||||||
filters.append(Measurement.pass_fail == pass_fail)
|
|
||||||
|
|
||||||
# Query all matching measurements
|
# Query all matching measurements
|
||||||
query = select(Measurement).where(and_(*filters) if filters else True)
|
query = select(Measurement).where(and_(*filters) if filters else True)
|
||||||
@@ -257,10 +372,10 @@ async def export_measurements_csv(
|
|||||||
value_str,
|
value_str,
|
||||||
m.pass_fail,
|
m.pass_fail,
|
||||||
deviation_str,
|
deviation_str,
|
||||||
m.lot_number or "",
|
neutralise_csv_cell(m.lot_number or ""),
|
||||||
m.serial_number or "",
|
neutralise_csv_cell(m.serial_number or ""),
|
||||||
m.input_method,
|
m.input_method,
|
||||||
m.measured_at.isoformat(),
|
iso_utc(m.measured_at),
|
||||||
m.synced_to_csv,
|
m.synced_to_csv,
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -270,6 +385,6 @@ async def export_measurements_csv(
|
|||||||
iter([output.getvalue()]),
|
iter([output.getvalue()]),
|
||||||
media_type="text/csv",
|
media_type="text/csv",
|
||||||
headers={
|
headers={
|
||||||
"Content-Disposition": f"attachment; filename=measurements_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
"Content-Disposition": f"attachment; filename=measurements_{to_local(utcnow()).strftime('%Y%m%d_%H%M%S')}.csv"
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Production runs router - open, read, and drive the life of a production.
|
||||||
|
|
||||||
|
Deliberately stateless: nothing lives in process memory. With one station app
|
||||||
|
installed per PC the database is the only shared place, and any station app must be
|
||||||
|
able to ask "what is going on here?" and get the same answer.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.api.middleware.api_key import get_current_user
|
||||||
|
from src.backend.database import get_db
|
||||||
|
from src.backend.models.api.production import (
|
||||||
|
CycleCompletePayload,
|
||||||
|
ProductionEventResponse,
|
||||||
|
ProductionRunCreate,
|
||||||
|
ProductionRunResponse,
|
||||||
|
ProductionRunWithEventsResponse,
|
||||||
|
RemeasurePayload,
|
||||||
|
SupervisorAction,
|
||||||
|
)
|
||||||
|
from src.backend.models.orm.production import ProductionRun
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.models.api.monitor import MonitorRun
|
||||||
|
from src.backend.services import auth_service, monitor_service, production_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
||||||
|
|
||||||
|
_DERIVED = {
|
||||||
|
"seconds_to_next_measurement", "overdue", "server_time", "measurement_task_ids",
|
||||||
|
"close_task_ids",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_run_operator(user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Open/advance/pause/resume/close a run: MeasurementTec, Supervisor, or admin.
|
||||||
|
|
||||||
|
A Maker with no floor role could otherwise start and drive a production the
|
||||||
|
same way an operator does (gitea#24) - reading a run stays open to any
|
||||||
|
authenticated user, only the lifecycle actions are gated.
|
||||||
|
"""
|
||||||
|
if user.is_admin or user.has_role("MeasurementTec") or user.has_role("Supervisor"):
|
||||||
|
return user
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="MeasurementTec or Supervisor role required",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _as_response(db: AsyncSession, run: ProductionRun) -> ProductionRunResponse:
|
||||||
|
"""The stored run plus everything the client cannot work out on its own."""
|
||||||
|
return ProductionRunResponse(
|
||||||
|
**ProductionRunResponse.model_validate(run).model_dump(exclude=_DERIVED),
|
||||||
|
**production_service.describe(run),
|
||||||
|
measurement_task_ids=await production_service.measurement_task_ids(
|
||||||
|
db, run.version_id,
|
||||||
|
),
|
||||||
|
# #53: task da compilare alla fine produzione
|
||||||
|
close_task_ids=await production_service.close_task_ids(db, run.version_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ProductionRunResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def open_production_run(
|
||||||
|
data: ProductionRunCreate,
|
||||||
|
user: User = Depends(_require_run_operator),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Start a production at a station."""
|
||||||
|
run = await production_service.open_run(db, data, user)
|
||||||
|
return await _as_response(db, run)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/current", response_model=ProductionRunResponse | None)
|
||||||
|
async def get_current_production_run(
|
||||||
|
station_code: str = Query(..., min_length=1),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""The run open at this station, or null.
|
||||||
|
|
||||||
|
This is what every page asks on load instead of keeping the timer in memory.
|
||||||
|
"""
|
||||||
|
station = await production_service.get_station_by_code(db, station_code)
|
||||||
|
run = await production_service.get_open_run_for_station(db, station.id)
|
||||||
|
return await _as_response(db, run) if run is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_high_authority(user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Monitor of all stations (#57): admin or Supervisor (capoturno) only."""
|
||||||
|
if user.is_admin or user.has_role("Supervisor"):
|
||||||
|
return user
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Supervisor role required",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/open", response_model=list[MonitorRun])
|
||||||
|
async def list_open_production_runs(
|
||||||
|
user: User = Depends(_require_high_authority),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Every open run with timer, last event and recent measurements (#57).
|
||||||
|
|
||||||
|
Declared before /{run_id} so "open" is not parsed as a run id.
|
||||||
|
"""
|
||||||
|
return await monitor_service.list_open_runs(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse)
|
||||||
|
async def get_production_run(
|
||||||
|
run_id: int,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""A run with its full trace - the history a production did not have before."""
|
||||||
|
run = await production_service.get_run(db, run_id)
|
||||||
|
events = await production_service.list_run_events(db, run_id)
|
||||||
|
return ProductionRunWithEventsResponse(
|
||||||
|
**(await _as_response(db, run)).model_dump(),
|
||||||
|
events=[ProductionEventResponse.model_validate(e) for e in events],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{run_id}/cycle", response_model=ProductionRunResponse)
|
||||||
|
async def complete_measurement_cycle(
|
||||||
|
run_id: int,
|
||||||
|
payload: CycleCompletePayload | None = None,
|
||||||
|
user: User = Depends(_require_run_operator),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Record a finished measurement task; the last one restarts the interval."""
|
||||||
|
run = await production_service.get_run(db, run_id)
|
||||||
|
run = await production_service.complete_cycle(
|
||||||
|
db, run, user,
|
||||||
|
task_id=payload.task_id if payload else None,
|
||||||
|
note=payload.note if payload else None,
|
||||||
|
)
|
||||||
|
return await _as_response(db, run)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{run_id}/remeasure", response_model=ProductionRunResponse)
|
||||||
|
async def remeasure_in_cycle(
|
||||||
|
run_id: int,
|
||||||
|
payload: RemeasurePayload | None = None,
|
||||||
|
user: User = Depends(_require_run_operator),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Turn the piece over and measure again, without closing the cycle."""
|
||||||
|
run = await production_service.get_run(db, run_id)
|
||||||
|
run = await production_service.remeasure(
|
||||||
|
db, run, user,
|
||||||
|
task_id=payload.task_id if payload else None,
|
||||||
|
note=payload.note if payload else None,
|
||||||
|
)
|
||||||
|
return await _as_response(db, run)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{run_id}/pause", response_model=ProductionRunResponse)
|
||||||
|
async def pause_production_run(
|
||||||
|
run_id: int,
|
||||||
|
action: SupervisorAction,
|
||||||
|
user: User = Depends(_require_run_operator),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Fermo linea - requires a supervisor."""
|
||||||
|
run = await production_service.get_run(db, run_id)
|
||||||
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
|
db, action.supervisor_username, action.supervisor_password,
|
||||||
|
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
|
||||||
|
)
|
||||||
|
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
|
||||||
|
return await _as_response(db, run)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{run_id}/resume", response_model=ProductionRunResponse)
|
||||||
|
async def resume_production_run(
|
||||||
|
run_id: int,
|
||||||
|
action: SupervisorAction,
|
||||||
|
user: User = Depends(_require_run_operator),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Restart a stopped line - requires a supervisor."""
|
||||||
|
run = await production_service.get_run(db, run_id)
|
||||||
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
|
db, action.supervisor_username, action.supervisor_password,
|
||||||
|
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
|
||||||
|
)
|
||||||
|
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
|
||||||
|
return await _as_response(db, run)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{run_id}/close", response_model=ProductionRunResponse)
|
||||||
|
async def close_production_run(
|
||||||
|
run_id: int,
|
||||||
|
action: SupervisorAction,
|
||||||
|
user: User = Depends(_require_run_operator),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Fine produzione - requires a supervisor. Stops the timer for good."""
|
||||||
|
run = await production_service.get_run(db, run_id)
|
||||||
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
|
db, action.supervisor_username, action.supervisor_password,
|
||||||
|
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
|
||||||
|
)
|
||||||
|
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
|
||||||
|
return await _as_response(db, run)
|
||||||
@@ -1,18 +1,21 @@
|
|||||||
"""Recipe router - CRUD, versioning, barcode lookup."""
|
"""Recipe router - CRUD, versioning, barcode lookup, AI parsing."""
|
||||||
from fastapi import APIRouter, Depends, Query
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import get_current_user, require_maker, require_measurement_tec
|
from src.backend.api.middleware.api_key import get_current_user, require_maker, require_measurement_tec
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.recipe import (
|
from src.backend.models.api.recipe import (
|
||||||
RecipeCreate,
|
RecipeCreate,
|
||||||
|
RecipeDuplicate,
|
||||||
RecipeListResponse,
|
RecipeListResponse,
|
||||||
RecipeResponse,
|
RecipeResponse,
|
||||||
RecipeUpdate,
|
RecipeUpdate,
|
||||||
RecipeVersionResponse,
|
RecipeVersionResponse,
|
||||||
)
|
)
|
||||||
from services import recipe_service
|
from src.backend.services import recipe_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/recipes", tags=["recipes"])
|
router = APIRouter(prefix="/api/recipes", tags=["recipes"])
|
||||||
|
|
||||||
@@ -50,6 +53,18 @@ async def create_recipe(
|
|||||||
return _recipe_to_response(recipe)
|
return _recipe_to_response(recipe)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{recipe_id}/duplicate", response_model=RecipeResponse, status_code=201)
|
||||||
|
async def duplicate_recipe(
|
||||||
|
recipe_id: int,
|
||||||
|
data: RecipeDuplicate,
|
||||||
|
user: User = Depends(require_maker),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Duplicate a recipe with its current tasks under a new code (#52). Requires Maker role."""
|
||||||
|
recipe = await recipe_service.duplicate_recipe(db, recipe_id, data.code, data.name, user)
|
||||||
|
return _recipe_to_response(recipe)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=RecipeListResponse)
|
@router.get("", response_model=RecipeListResponse)
|
||||||
async def list_recipes(
|
async def list_recipes(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
@@ -164,3 +179,36 @@ async def get_measurement_count(
|
|||||||
"""
|
"""
|
||||||
count = await recipe_service.get_measurement_count(db, recipe_id, version_number)
|
count = await recipe_service.get_measurement_count(db, recipe_id, version_number)
|
||||||
return {"recipe_id": recipe_id, "version_number": version_number, "measurement_count": count}
|
return {"recipe_id": recipe_id, "version_number": version_number, "measurement_count": count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{recipe_id}/parse-technical-sheet")
|
||||||
|
async def parse_technical_sheet(
|
||||||
|
recipe_id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
_user: User = Depends(require_maker),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Parse a PDF technical sheet using AI and return suggested tasks."""
|
||||||
|
from src.backend.services import ai_service
|
||||||
|
|
||||||
|
if not file.content_type or "pdf" not in file.content_type:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Il file deve essere un PDF")
|
||||||
|
|
||||||
|
pdf_bytes = await file.read()
|
||||||
|
if len(pdf_bytes) > 20 * 1024 * 1024:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File troppo grande (max 20MB)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
tasks = await ai_service.parse_technical_sheet(pdf_bytes)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
except ai_service.AIResponseError as e:
|
||||||
|
# The AI model's own response was malformed (bad JSON, missing
|
||||||
|
# fields): that is the upstream service's fault, not ours (400).
|
||||||
|
logging.getLogger(__name__).error("AI response malformed: %s", e)
|
||||||
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Risposta del servizio AI non valida")
|
||||||
|
except Exception as e:
|
||||||
|
logging.getLogger(__name__).error("AI parsing error: %s", e)
|
||||||
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Errore nel servizio AI")
|
||||||
|
|
||||||
|
return {"recipe_id": recipe_id, "suggested_tasks": tasks}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
"""Reports router — PDF report generation endpoints for Metrologist."""
|
"""Reports router — PDF report generation endpoints for Metrologist."""
|
||||||
from datetime import datetime
|
from src.backend.models.api.tipi import UtcNaiveInput
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import require_metrologist
|
from src.backend.api.middleware.api_key import require_metrologist
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from services.report_service import generate_measurement_report, generate_spc_report
|
from src.backend.services.report_service import generate_measurement_report, generate_spc_report
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||||
|
|
||||||
@@ -18,11 +18,12 @@ async def download_spc_report(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int = Query(...),
|
subtask_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
|
production_run_id: int | None = Query(None), # #58
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -32,6 +33,7 @@ async def download_spc_report(
|
|||||||
db, recipe_id, subtask_id,
|
db, recipe_id, subtask_id,
|
||||||
version_id, date_from, date_to,
|
version_id, date_from, date_to,
|
||||||
operator_id, lot_number, serial_number,
|
operator_id, lot_number, serial_number,
|
||||||
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -52,11 +54,12 @@ async def download_measurement_report(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int | None = Query(None),
|
subtask_id: int | None = Query(None),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
|
production_run_id: int | None = Query(None), # #58
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -66,6 +69,7 @@ async def download_measurement_report(
|
|||||||
db, recipe_id, subtask_id,
|
db, recipe_id, subtask_id,
|
||||||
version_id, date_from, date_to,
|
version_id, date_from, date_to,
|
||||||
operator_id, lot_number, serial_number,
|
operator_id, lot_number, serial_number,
|
||||||
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""System settings router - read/update settings, upload company logo."""
|
||||||
|
import json
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.database import get_db
|
||||||
|
from src.backend.api.middleware.api_key import get_current_user, require_admin_user
|
||||||
|
from src.backend.api.routers.files import ALLOWED_IMAGE_TYPES, detect_file_type
|
||||||
|
from src.backend.models.orm.setting import SystemSetting
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
|
# Domains for the settings keys the backend actually reads. Values outside these
|
||||||
|
# would only fail later (csv.writer TypeError on close_run, etc) — reject up front.
|
||||||
|
_VALID_CSV_DELIMITERS = {",", ";", "\t", "|"}
|
||||||
|
_VALID_CSV_DECIMAL_SEPARATORS = {",", "."}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_string_list(key: str, value: str) -> None:
|
||||||
|
"""#49: array JSON di stringhe non vuote, max 50 voci (vuoto = nessuna lista)."""
|
||||||
|
try:
|
||||||
|
items = json.loads(value)
|
||||||
|
except ValueError:
|
||||||
|
items = None
|
||||||
|
if (
|
||||||
|
not isinstance(items, list)
|
||||||
|
or len(items) > 50
|
||||||
|
or not all(isinstance(i, str) and i.strip() for i in items)
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"{key} deve essere vuoto o un array JSON di al massimo 50 stringhe non vuote",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_setting_value(key: str, value: str) -> None:
|
||||||
|
"""Raise 422 for a value that would break the code reading this key.
|
||||||
|
|
||||||
|
Unrecognised keys pass through unvalidated: the settings store is generic.
|
||||||
|
"""
|
||||||
|
if key == "csv_delimiter":
|
||||||
|
if value not in _VALID_CSV_DELIMITERS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="csv_delimiter deve essere un carattere singolo fra ',' ';' TAB '|'",
|
||||||
|
)
|
||||||
|
elif key == "csv_decimal_separator":
|
||||||
|
if value not in _VALID_CSV_DECIMAL_SEPARATORS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="csv_decimal_separator deve essere ',' o '.'",
|
||||||
|
)
|
||||||
|
elif key == "auto_logout_minutes":
|
||||||
|
if value != "" and (not value.isdigit() or not (1 <= int(value) <= 480)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="auto_logout_minutes deve essere vuoto (disabilitato) o un intero fra 1 e 480",
|
||||||
|
)
|
||||||
|
elif key == "default_measurement_interval_minutes":
|
||||||
|
# #47: vuoto = nessun default
|
||||||
|
if value != "" and (not value.isdigit() or not (1 <= int(value) <= 1440)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="default_measurement_interval_minutes deve essere vuoto o un intero fra 1 e 1440",
|
||||||
|
)
|
||||||
|
elif key in ("general_tolerance_plus", "general_tolerance_minus"):
|
||||||
|
# #48: decimale >= 0 con punto; vuoto = nessun default
|
||||||
|
if value != "":
|
||||||
|
try:
|
||||||
|
number = Decimal(value)
|
||||||
|
ok = number.is_finite() and number >= 0
|
||||||
|
except InvalidOperation:
|
||||||
|
ok = False
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"{key} deve essere vuoto o un numero decimale maggiore o uguale a 0 (punto decimale)",
|
||||||
|
)
|
||||||
|
elif key in ("list_measurement_types", "list_units"):
|
||||||
|
if value != "":
|
||||||
|
_validate_string_list(key, value)
|
||||||
|
elif key in ("company_name", "company_logo_path"):
|
||||||
|
if not value.strip() or len(value) > 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"{key} deve essere una stringa non vuota di massimo 200 caratteri",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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():
|
||||||
|
_validate_setting_value(key, value)
|
||||||
|
|
||||||
|
# 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. The type is determined from the file's
|
||||||
|
magic bytes, not the client-declared Content-Type (#36 point 2). SVG is
|
||||||
|
refused outright: it can itself carry script content, and there is no
|
||||||
|
safe way to serve it inline for a logo shown on every page.
|
||||||
|
"""
|
||||||
|
# Read in chunks, stopping as soon as the size limit is crossed instead
|
||||||
|
# of buffering an oversized body into memory first.
|
||||||
|
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||||
|
buffer = bytearray()
|
||||||
|
chunk_size = 1024 * 1024
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buffer.extend(chunk)
|
||||||
|
if len(buffer) > max_bytes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB",
|
||||||
|
)
|
||||||
|
content = bytes(buffer)
|
||||||
|
file_size = len(content)
|
||||||
|
|
||||||
|
# Validate file type from the actual bytes (SVG never matches: refused)
|
||||||
|
detected = detect_file_type(content)
|
||||||
|
if detected is None or detected[0] not in ALLOWED_IMAGE_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="File type not allowed. Must be a recognised image (SVG is not accepted).",
|
||||||
|
)
|
||||||
|
_detected_mime, detected_ext = detected
|
||||||
|
|
||||||
|
# Create logos directory
|
||||||
|
logos_dir = settings.upload_path / "logos"
|
||||||
|
logos_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Fixed filename for the company logo, extension forced to what was
|
||||||
|
# actually detected (not what the client claimed or the filename said).
|
||||||
|
logo_filename = f"company_logo{detected_ext}"
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -3,11 +3,14 @@
|
|||||||
Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404.
|
Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404.
|
||||||
NOT protected by API Key auth (standalone access).
|
NOT protected by API Key auth (standalone access).
|
||||||
"""
|
"""
|
||||||
|
import hmac
|
||||||
import random
|
import random
|
||||||
import secrets
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from src.backend.utils.tempo import iso_utc, utcnow
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
from fastapi import APIRouter, HTTPException, Request, status
|
from fastapi import APIRouter, HTTPException, Request, status
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@@ -15,21 +18,36 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import inspect as sa_inspect, select
|
from sqlalchemy import inspect as sa_inspect, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config import settings
|
from src.backend.api.routers.users import VALID_ROLES
|
||||||
from database import Base, engine, async_session_factory
|
from src.backend.config import settings
|
||||||
from models import (
|
from src.backend.database import Base, engine, async_session_factory
|
||||||
|
from src.backend.models.orm import (
|
||||||
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
|
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
|
||||||
)
|
)
|
||||||
from models.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from services.auth_service import hash_password
|
from src.backend.services.auth_service import hash_password, issue_api_key, revoke_all_keys
|
||||||
from services.measurement_service import calculate_pass_fail
|
from src.backend.services.measurement_service import calculate_pass_fail
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
||||||
|
|
||||||
_templates_dir = Path(__file__).resolve().parent.parent / "templates"
|
_backend_dir = Path(__file__).resolve().parent.parent.parent
|
||||||
|
_templates_dir = _backend_dir / "templates"
|
||||||
|
_alembic_ini = _backend_dir / "migrations" / "alembic.ini"
|
||||||
templates = Jinja2Templates(directory=str(_templates_dir))
|
templates = Jinja2Templates(directory=str(_templates_dir))
|
||||||
|
|
||||||
|
|
||||||
|
def _stamp_head() -> None:
|
||||||
|
"""Mark the schema as fully migrated - the equivalent of `alembic stamp
|
||||||
|
head` - after `Base.metadata.create_all()`.
|
||||||
|
|
||||||
|
A database that got its tables this way instead of through
|
||||||
|
`upgrade head` has no row in `alembic_version`; without this, its next
|
||||||
|
deploy would try to replay migrations 001-013 and fail on a column that
|
||||||
|
is already there (see migration 000_base_schema.py).
|
||||||
|
"""
|
||||||
|
command.stamp(Config(str(_alembic_ini)), "head")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Request schemas
|
# Request schemas
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -86,13 +104,57 @@ def _check_setup_enabled() -> None:
|
|||||||
def _check_password(password: str) -> None:
|
def _check_password(password: str) -> None:
|
||||||
"""Verify the provided password matches the setup password."""
|
"""Verify the provided password matches the setup password."""
|
||||||
_check_setup_enabled()
|
_check_setup_enabled()
|
||||||
if password != settings.setup_password:
|
if not hmac.compare_digest(password, settings.setup_password):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Invalid setup password",
|
detail="Invalid setup password",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _password_matches(password: str | None) -> bool:
|
||||||
|
"""Constant-time password check that tolerates a missing password."""
|
||||||
|
if not password or not settings.setup_password:
|
||||||
|
return False
|
||||||
|
return hmac.compare_digest(password, settings.setup_password)
|
||||||
|
|
||||||
|
|
||||||
|
async def _admin_exists() -> bool:
|
||||||
|
"""Whether at least one admin user has been created.
|
||||||
|
|
||||||
|
False on a database with no `users` table yet, so init-db and the first
|
||||||
|
seed are never blocked by a query against a table that does not exist.
|
||||||
|
"""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
has_users = await conn.run_sync(
|
||||||
|
lambda sync_conn: sa_inspect(sync_conn).has_table("users")
|
||||||
|
)
|
||||||
|
if not has_users:
|
||||||
|
return False
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
result = await session.execute(select(User).where(User.is_admin == True)) # noqa: E712
|
||||||
|
return result.scalars().first() is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_not_initialised() -> None:
|
||||||
|
"""Refuse privileged/destructive setup endpoints once an admin exists.
|
||||||
|
|
||||||
|
reset-db, seed, manage-user, change-user-password and init-db all reach
|
||||||
|
into a live database once setup is done: reset-db wipes it, seed adds
|
||||||
|
demo users and data, manage-user/change-user-password can take over an
|
||||||
|
account, init-db races Alembic. All of them are meant for the one-time
|
||||||
|
bring-up before there is an admin to log in as.
|
||||||
|
`settings.setup_allow_after_init` is the explicit override for scripted
|
||||||
|
re-provisioning of a demo/test deploy.
|
||||||
|
"""
|
||||||
|
if settings.setup_allow_after_init:
|
||||||
|
return
|
||||||
|
if await _admin_exists():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="setup already completed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _generate_measurements_for_subtask(
|
def _generate_measurements_for_subtask(
|
||||||
subtask: RecipeSubtask,
|
subtask: RecipeSubtask,
|
||||||
version_id: int,
|
version_id: int,
|
||||||
@@ -215,18 +277,28 @@ async def _seed_default_station(session: AsyncSession, admin_user: User) -> None
|
|||||||
async def setup_page(request: Request):
|
async def setup_page(request: Request):
|
||||||
"""Serve the setup page HTML."""
|
"""Serve the setup page HTML."""
|
||||||
_check_setup_enabled()
|
_check_setup_enabled()
|
||||||
return templates.TemplateResponse("setup/setup.html", {"request": request})
|
return templates.TemplateResponse(request, "setup/setup.html")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def setup_status():
|
async def setup_status(password: str | None = None):
|
||||||
"""Return database table status (no password required, but setup must be enabled)."""
|
"""Database status. Without a valid password, only whether setup is done.
|
||||||
|
|
||||||
|
The table listing is only returned with the setup password: it is
|
||||||
|
internal detail, not something to hand to whoever happens to load the
|
||||||
|
page.
|
||||||
|
"""
|
||||||
_check_setup_enabled()
|
_check_setup_enabled()
|
||||||
|
initialised = await _admin_exists()
|
||||||
|
if not _password_matches(password):
|
||||||
|
return {"initialised": initialised}
|
||||||
|
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
table_names: list[str] = await conn.run_sync(
|
table_names: list[str] = await conn.run_sync(
|
||||||
lambda sync_conn: sa_inspect(sync_conn).get_table_names()
|
lambda sync_conn: sa_inspect(sync_conn).get_table_names()
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
|
"initialised": initialised,
|
||||||
"tables_exist": len(table_names) > 0,
|
"tables_exist": len(table_names) > 0,
|
||||||
"table_count": len(table_names),
|
"table_count": len(table_names),
|
||||||
"tables": table_names,
|
"tables": table_names,
|
||||||
@@ -237,8 +309,10 @@ async def setup_status():
|
|||||||
async def init_db(body: PasswordBody):
|
async def init_db(body: PasswordBody):
|
||||||
"""Create all database tables."""
|
"""Create all database tables."""
|
||||||
_check_password(body.password)
|
_check_password(body.password)
|
||||||
|
await _require_not_initialised()
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
_stamp_head()
|
||||||
return {"status": "ok", "message": "Database tables created successfully"}
|
return {"status": "ok", "message": "Database tables created successfully"}
|
||||||
|
|
||||||
|
|
||||||
@@ -247,8 +321,6 @@ async def create_admin(body: CreateAdminBody):
|
|||||||
"""Create an admin user with all roles."""
|
"""Create an admin user with all roles."""
|
||||||
_check_password(body.password)
|
_check_password(body.password)
|
||||||
|
|
||||||
api_key = secrets.token_hex(32)
|
|
||||||
|
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
async with session.begin():
|
async with session.begin():
|
||||||
user = User(
|
user = User(
|
||||||
@@ -258,9 +330,10 @@ async def create_admin(body: CreateAdminBody):
|
|||||||
display_name=body.admin_display_name,
|
display_name=body.admin_display_name,
|
||||||
roles=["Maker", "MeasurementTec", "Metrologist"],
|
roles=["Maker", "MeasurementTec", "Metrologist"],
|
||||||
is_admin=True,
|
is_admin=True,
|
||||||
api_key=api_key,
|
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
api_key = await issue_api_key(session, user)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
@@ -273,6 +346,7 @@ async def create_admin(body: CreateAdminBody):
|
|||||||
async def seed_demo_data(body: PasswordBody):
|
async def seed_demo_data(body: PasswordBody):
|
||||||
"""Seed database with demo users and a sample recipe."""
|
"""Seed database with demo users and a sample recipe."""
|
||||||
_check_password(body.password)
|
_check_password(body.password)
|
||||||
|
await _require_not_initialised()
|
||||||
|
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
async with session.begin():
|
async with session.begin():
|
||||||
@@ -329,7 +403,6 @@ async def seed_demo_data(body: PasswordBody):
|
|||||||
email=u["email"],
|
email=u["email"],
|
||||||
roles=u["roles"],
|
roles=u["roles"],
|
||||||
is_admin=u["is_admin"],
|
is_admin=u["is_admin"],
|
||||||
api_key=secrets.token_hex(32),
|
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
created_users.append(user)
|
created_users.append(user)
|
||||||
@@ -483,7 +556,7 @@ async def seed_demo_data(body: PasswordBody):
|
|||||||
tec_user = created_users[2] # tec1
|
tec_user = created_users[2] # tec1
|
||||||
lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"]
|
lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"]
|
||||||
serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)]
|
serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)]
|
||||||
base_date = datetime.now()
|
base_date = utcnow()
|
||||||
|
|
||||||
all_subtasks = subtasks_1 + subtasks_2
|
all_subtasks = subtasks_1 + subtasks_2
|
||||||
measurements_created = 0
|
measurements_created = 0
|
||||||
@@ -531,8 +604,8 @@ async def list_users(body: PasswordBody):
|
|||||||
"roles": u.roles or [],
|
"roles": u.roles or [],
|
||||||
"is_admin": u.is_admin,
|
"is_admin": u.is_admin,
|
||||||
"active": u.active,
|
"active": u.active,
|
||||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
"created_at": iso_utc(u.created_at) or None,
|
||||||
"last_login": u.last_login.isoformat() if u.last_login else None,
|
"last_login": iso_utc(u.last_login) or None,
|
||||||
}
|
}
|
||||||
for u in users
|
for u in users
|
||||||
],
|
],
|
||||||
@@ -543,8 +616,8 @@ async def list_users(body: PasswordBody):
|
|||||||
async def manage_user(body: ManageUserBody):
|
async def manage_user(body: ManageUserBody):
|
||||||
"""Create or update a user. user_id=null creates new, user_id=int updates."""
|
"""Create or update a user. user_id=null creates new, user_id=int updates."""
|
||||||
_check_password(body.password)
|
_check_password(body.password)
|
||||||
|
await _require_not_initialised()
|
||||||
|
|
||||||
VALID_ROLES = {"Maker", "MeasurementTec", "Metrologist"}
|
|
||||||
invalid = set(body.roles) - VALID_ROLES
|
invalid = set(body.roles) - VALID_ROLES
|
||||||
if invalid:
|
if invalid:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -577,7 +650,6 @@ async def manage_user(body: ManageUserBody):
|
|||||||
email=body.email,
|
email=body.email,
|
||||||
roles=body.roles,
|
roles=body.roles,
|
||||||
is_admin=body.is_admin,
|
is_admin=body.is_admin,
|
||||||
api_key=secrets.token_hex(32),
|
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@@ -618,6 +690,7 @@ async def manage_user(body: ManageUserBody):
|
|||||||
async def change_user_password(body: ChangeUserPasswordBody):
|
async def change_user_password(body: ChangeUserPasswordBody):
|
||||||
"""Change a user's password and invalidate their API key."""
|
"""Change a user's password and invalidate their API key."""
|
||||||
_check_password(body.password)
|
_check_password(body.password)
|
||||||
|
await _require_not_initialised()
|
||||||
|
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
async with session.begin():
|
async with session.begin():
|
||||||
@@ -631,7 +704,7 @@ async def change_user_password(body: ChangeUserPasswordBody):
|
|||||||
detail=f"User ID {body.user_id} not found",
|
detail=f"User ID {body.user_id} not found",
|
||||||
)
|
)
|
||||||
user.password_hash = hash_password(body.new_password)
|
user.password_hash = hash_password(body.new_password)
|
||||||
user.api_key = None # Invalidate sessions
|
await revoke_all_keys(session, user.id) # Invalidate sessions
|
||||||
|
|
||||||
return {"status": "ok", "message": f"Password changed for '{user.username}'"}
|
return {"status": "ok", "message": f"Password changed for '{user.username}'"}
|
||||||
|
|
||||||
@@ -654,7 +727,7 @@ async def toggle_user_active(body: ToggleUserActiveBody):
|
|||||||
)
|
)
|
||||||
user.active = not user.active
|
user.active = not user.active
|
||||||
if not user.active:
|
if not user.active:
|
||||||
user.api_key = None # Invalidate sessions on deactivation
|
await revoke_all_keys(session, user.id) # on deactivation
|
||||||
new_status = "active" if user.active else "inactive"
|
new_status = "active" if user.active else "inactive"
|
||||||
|
|
||||||
return {"status": "ok", "message": f"User '{user.username}' is now {new_status}", "active": user.active}
|
return {"status": "ok", "message": f"User '{user.username}' is now {new_status}", "active": user.active}
|
||||||
@@ -664,6 +737,7 @@ async def toggle_user_active(body: ToggleUserActiveBody):
|
|||||||
async def reset_db(body: ResetDbBody):
|
async def reset_db(body: ResetDbBody):
|
||||||
"""Drop all tables and recreate them. Requires confirm=true."""
|
"""Drop all tables and recreate them. Requires confirm=true."""
|
||||||
_check_password(body.password)
|
_check_password(body.password)
|
||||||
|
await _require_not_initialised()
|
||||||
|
|
||||||
if not body.confirm:
|
if not body.confirm:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -674,5 +748,6 @@ async def reset_db(body: ResetDbBody):
|
|||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.drop_all)
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
_stamp_head()
|
||||||
|
|
||||||
return {"status": "ok", "message": "Database reset successfully"}
|
return {"status": "ok", "message": "Database reset successfully"}
|
||||||
@@ -2,31 +2,54 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import get_current_user, require_admin_user
|
from src.backend.api.middleware.api_key import get_current_user, require_admin_user
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.station import (
|
from src.backend.models.api.station import (
|
||||||
StationCreate,
|
StationCreate,
|
||||||
StationUpdate,
|
StationUpdate,
|
||||||
StationResponse,
|
StationResponse,
|
||||||
StationRecipeAssignmentCreate,
|
StationRecipeAssignmentCreate,
|
||||||
StationRecipeAssignmentResponse,
|
StationRecipeAssignmentResponse,
|
||||||
|
StationResetResponse,
|
||||||
|
StationWithRecipesResponse,
|
||||||
RecipeSummary,
|
RecipeSummary,
|
||||||
)
|
)
|
||||||
from services import station_service
|
from src.backend.services import station_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/stations", tags=["stations"])
|
router = APIRouter(prefix="/api/stations", tags=["stations"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[StationResponse])
|
@router.get("", response_model=list[StationWithRecipesResponse])
|
||||||
async def list_stations(
|
async def list_stations(
|
||||||
active_only: bool = False,
|
active_only: bool = False,
|
||||||
admin: User = Depends(require_admin_user),
|
admin: User = Depends(require_admin_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""List all stations (admin only)."""
|
"""List all stations with the recipes assigned to each (admin only).
|
||||||
|
|
||||||
|
The admin station list shows which products a station handles, so the
|
||||||
|
assignments travel with the station instead of needing a call per row.
|
||||||
|
Station.assignments is selectin-loaded, so this costs no extra query.
|
||||||
|
|
||||||
|
Only active recipes are listed, matching what an operator actually sees
|
||||||
|
at that station via /by-code/{code}/recipes.
|
||||||
|
"""
|
||||||
stations = await station_service.list_stations(db, active_only=active_only)
|
stations = await station_service.list_stations(db, active_only=active_only)
|
||||||
return [StationResponse.model_validate(s) for s in stations]
|
return [
|
||||||
|
StationWithRecipesResponse(
|
||||||
|
**StationResponse.model_validate(s).model_dump(),
|
||||||
|
recipes=sorted(
|
||||||
|
(
|
||||||
|
RecipeSummary.model_validate(a.recipe)
|
||||||
|
for a in s.assignments
|
||||||
|
if a.recipe is not None and a.recipe.active
|
||||||
|
),
|
||||||
|
key=lambda r: r.code,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for s in stations
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=StationResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=StationResponse, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -128,6 +151,22 @@ async def assign_recipe_to_station(
|
|||||||
return StationRecipeAssignmentResponse.model_validate(assignment)
|
return StationRecipeAssignmentResponse.model_validate(assignment)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{station_id}/recipes", response_model=StationResetResponse)
|
||||||
|
async def reset_station_recipes(
|
||||||
|
station_id: int,
|
||||||
|
admin: User = Depends(require_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Clear every recipe assignment of a station (admin only).
|
||||||
|
|
||||||
|
Backs the per-row reset in the admin list: the station is left with no
|
||||||
|
recipes and can be reassigned from scratch, without deleting the station
|
||||||
|
itself. Idempotent - resetting an already empty station reports removed=0.
|
||||||
|
"""
|
||||||
|
removed = await station_service.unassign_all_recipes(db, station_id)
|
||||||
|
return StationResetResponse(station_id=station_id, removed=removed)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/{station_id}/recipes/{recipe_id}",
|
"/{station_id}/recipes/{recipe_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
@@ -1,23 +1,26 @@
|
|||||||
"""Statistics router - SPC endpoints for Metrologist dashboard."""
|
"""Statistics router - SPC endpoints for Metrologist dashboard."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from src.backend.models.api.tipi import UtcNaiveInput
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import and_, select
|
from sqlalchemy import and_, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import require_metrologist
|
from src.backend.api.middleware.api_key import require_metrologist
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from models.recipe import RecipeVersion
|
from src.backend.models.orm.production import ProductionRun
|
||||||
from models.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
from models.user import User
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
from schemas.statistics import (
|
from src.backend.models.orm.task_result import TaskResult # #58: esiti dei task della sessione
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.models.api.statistics import (
|
||||||
CapabilityData,
|
CapabilityData,
|
||||||
ControlChartData,
|
ControlChartData,
|
||||||
HistogramData,
|
HistogramData,
|
||||||
SummaryData,
|
SummaryData,
|
||||||
)
|
)
|
||||||
from services.spc_service import (
|
from src.backend.services.spc_service import (
|
||||||
compute_capability,
|
compute_capability,
|
||||||
compute_control_chart,
|
compute_control_chart,
|
||||||
compute_histogram,
|
compute_histogram,
|
||||||
@@ -37,6 +40,7 @@ async def _query_measurements(
|
|||||||
operator_id: int | None = None,
|
operator_id: int | None = None,
|
||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
|
production_run_id: int | None = None, # #58: solo le misure di una sessione
|
||||||
) -> list[Measurement]:
|
) -> list[Measurement]:
|
||||||
"""Query measurements with common filters.
|
"""Query measurements with common filters.
|
||||||
|
|
||||||
@@ -66,6 +70,9 @@ async def _query_measurements(
|
|||||||
if serial_number is not None:
|
if serial_number is not None:
|
||||||
filters.append(Measurement.serial_number == serial_number)
|
filters.append(Measurement.serial_number == serial_number)
|
||||||
|
|
||||||
|
if production_run_id is not None:
|
||||||
|
filters.append(Measurement.production_run_id == production_run_id)
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(Measurement)
|
select(Measurement)
|
||||||
.where(and_(*filters) if filters else True)
|
.where(and_(*filters) if filters else True)
|
||||||
@@ -100,11 +107,12 @@ async def get_summary(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
subtask_id: int | None = Query(None),
|
subtask_id: int | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
|
production_run_id: int | None = Query(None), # #58
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> SummaryData:
|
) -> SummaryData:
|
||||||
@@ -112,6 +120,7 @@ async def get_summary(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
pass_fail_values = [m.pass_fail for m in measurements]
|
pass_fail_values = [m.pass_fail for m in measurements]
|
||||||
return compute_summary(pass_fail_values)
|
return compute_summary(pass_fail_values)
|
||||||
@@ -122,11 +131,12 @@ async def get_capability(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int = Query(...),
|
subtask_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
|
production_run_id: int | None = Query(None), # #58
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> CapabilityData:
|
) -> CapabilityData:
|
||||||
@@ -134,6 +144,7 @@ async def get_capability(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
values = [float(m.value) for m in measurements]
|
values = [float(m.value) for m in measurements]
|
||||||
tol = await _get_subtask_tolerances(db, subtask_id)
|
tol = await _get_subtask_tolerances(db, subtask_id)
|
||||||
@@ -145,11 +156,12 @@ async def get_control_chart(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int = Query(...),
|
subtask_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
|
production_run_id: int | None = Query(None), # #58
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> ControlChartData:
|
) -> ControlChartData:
|
||||||
@@ -157,6 +169,7 @@ async def get_control_chart(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
values = [float(m.value) for m in measurements]
|
values = [float(m.value) for m in measurements]
|
||||||
timestamps = [m.measured_at for m in measurements]
|
timestamps = [m.measured_at for m in measurements]
|
||||||
@@ -172,11 +185,12 @@ async def get_histogram(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int = Query(...),
|
subtask_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
|
production_run_id: int | None = Query(None), # #58
|
||||||
n_bins: int = Query(20, ge=5, le=100),
|
n_bins: int = Query(20, ge=5, le=100),
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -185,6 +199,7 @@ async def get_histogram(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
values = [float(m.value) for m in measurements]
|
values = [float(m.value) for m in measurements]
|
||||||
return compute_histogram(values, n_bins)
|
return compute_histogram(values, n_bins)
|
||||||
@@ -234,3 +249,122 @@ async def get_recipe_subtasks(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return subtasks_list
|
return subtasks_list
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs")
|
||||||
|
async def get_recipe_runs(
|
||||||
|
recipe_id: int = Query(...),
|
||||||
|
user: User = Depends(require_metrologist),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> list[dict]:
|
||||||
|
"""#58: production runs of a recipe (for the "Sessione" filter), newest first."""
|
||||||
|
runs = (await db.execute(
|
||||||
|
select(ProductionRun)
|
||||||
|
.where(ProductionRun.recipe_id == recipe_id)
|
||||||
|
.order_by(ProductionRun.started_at.desc(), ProductionRun.id.desc())
|
||||||
|
)).scalars().all()
|
||||||
|
# #58: conta solo le misure dei run della ricetta, non tutta la tabella.
|
||||||
|
run_ids = [r.id for r in runs]
|
||||||
|
counts = {}
|
||||||
|
if run_ids:
|
||||||
|
counts = dict((await db.execute(
|
||||||
|
select(Measurement.production_run_id, func.count(Measurement.id))
|
||||||
|
.where(Measurement.production_run_id.in_(run_ids))
|
||||||
|
.group_by(Measurement.production_run_id)
|
||||||
|
)).all())
|
||||||
|
operator_ids = {r.operator_id for r in runs}
|
||||||
|
names = {}
|
||||||
|
if operator_ids:
|
||||||
|
names = dict((await db.execute(
|
||||||
|
select(User.id, User.display_name).where(User.id.in_(operator_ids))
|
||||||
|
)).all())
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"station_id": r.station_id,
|
||||||
|
"station_name": r.station.name if r.station else None,
|
||||||
|
"operator_id": r.operator_id,
|
||||||
|
"operator_name": names.get(r.operator_id),
|
||||||
|
"started_at": r.started_at.isoformat() if r.started_at else None,
|
||||||
|
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
|
||||||
|
"status": r.status,
|
||||||
|
"lot_number": r.lot_number,
|
||||||
|
"serial_number": r.serial_number,
|
||||||
|
"cycle_count": r.cycle_count,
|
||||||
|
"measurement_count": counts.get(r.id, 0),
|
||||||
|
}
|
||||||
|
for r in runs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs/{run_id}/task-results")
|
||||||
|
async def get_run_task_results(
|
||||||
|
run_id: int,
|
||||||
|
user: User = Depends(require_metrologist),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""#58: esiti, note, campi e allegati dei task di una sessione di produzione.
|
||||||
|
|
||||||
|
Endpoint di statistica (require_metrologist): GET /api/task-results vuole
|
||||||
|
il ruolo MeasurementTec e non si puo' riusare qui.
|
||||||
|
"""
|
||||||
|
run = (await db.execute(
|
||||||
|
select(ProductionRun.id).where(ProductionRun.id == run_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if run is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Sessione non trovata")
|
||||||
|
|
||||||
|
# #58: una query per i risultati della run, una per i task, una per gli utenti.
|
||||||
|
results = (await db.execute(
|
||||||
|
select(TaskResult)
|
||||||
|
.where(TaskResult.production_run_id == run_id)
|
||||||
|
.order_by(TaskResult.created_at.asc(), TaskResult.id.asc())
|
||||||
|
)).scalars().all()
|
||||||
|
|
||||||
|
task_ids = {r.task_id for r in results}
|
||||||
|
tasks = {}
|
||||||
|
if task_ids:
|
||||||
|
tasks = {t.id: t for t in (await db.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.id.in_(task_ids))
|
||||||
|
)).scalars().all()}
|
||||||
|
user_ids = {r.user_id for r in results}
|
||||||
|
names = {}
|
||||||
|
if user_ids:
|
||||||
|
names = dict((await db.execute(
|
||||||
|
select(User.id, User.display_name).where(User.id.in_(user_ids))
|
||||||
|
)).all())
|
||||||
|
|
||||||
|
counts = {"ok": 0, "nok": 0, "done": 0}
|
||||||
|
out = []
|
||||||
|
for r in results:
|
||||||
|
counts[r.outcome] = counts.get(r.outcome, 0) + 1
|
||||||
|
task = tasks.get(r.task_id)
|
||||||
|
# #58: etichette dai campi del task; se il campo e' stato tolto vale la chiave.
|
||||||
|
labels = {
|
||||||
|
f.get("key"): f.get("label") or f.get("key")
|
||||||
|
for f in (task.fields or []) if isinstance(f, dict)
|
||||||
|
} if task else {}
|
||||||
|
out.append({
|
||||||
|
"id": r.id,
|
||||||
|
"task_id": r.task_id,
|
||||||
|
"task_title": task.title if task else None,
|
||||||
|
"task_type": task.task_type if task else None,
|
||||||
|
"outcome": r.outcome,
|
||||||
|
"note": r.note,
|
||||||
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
|
"user_name": names.get(r.user_id),
|
||||||
|
"values": [
|
||||||
|
{"key": k, "label": labels.get(k, k), "value": v}
|
||||||
|
for k, v in (r.values or {}).items()
|
||||||
|
],
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"key": a.get("key"),
|
||||||
|
"label": labels.get(a.get("key"), a.get("key")),
|
||||||
|
"name": a.get("name"),
|
||||||
|
"path": a.get("path"),
|
||||||
|
}
|
||||||
|
for a in (r.attachments or []) if isinstance(a, dict)
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return {"run_id": run_id, "counts": counts, "results": out}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Task results router: l'esito di un task 'check' (OK/NOK) o 'note' (#54 #55).
|
||||||
|
|
||||||
|
Un 'check' registra ok o nok (nok vuole la nota); un 'note' registra 'done' con i
|
||||||
|
valori dei campi extra e gli allegati. I task di misura non passano di qui: le
|
||||||
|
loro letture sono Measurement.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.api.middleware.api_key import require_measurement_tec
|
||||||
|
from src.backend.api.routers.files import (
|
||||||
|
_UPLOAD_CHUNK_SIZE, detect_file_type, sanitize_filename,
|
||||||
|
)
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.database import get_db
|
||||||
|
from src.backend.models.api.task_result import TaskResultCreate, TaskResultResponse
|
||||||
|
from src.backend.models.orm.production import ProductionRun
|
||||||
|
from src.backend.models.orm.task import RecipeTask
|
||||||
|
from src.backend.models.orm.task_result import TaskResult
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/task-results", tags=["task-results"])
|
||||||
|
|
||||||
|
|
||||||
|
def _unprocessable(detail: str) -> HTTPException:
|
||||||
|
# 422 con detail testuale: l'api_client del frontend legge solo `detail`
|
||||||
|
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail)
|
||||||
|
|
||||||
|
|
||||||
|
# Radice degli allegati degli esiti: sotto upload_path, mai cancellabile (audit).
|
||||||
|
ATTACHMENT_DIR = "task_results"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_relative_path(path: str) -> bool:
|
||||||
|
"""Allegato: relativo, dentro task_results/, senza '..', unita' o backslash."""
|
||||||
|
if path.startswith("/") or "\\" in path or ":" in path:
|
||||||
|
return False
|
||||||
|
parts = path.split("/")
|
||||||
|
return ".." not in parts and parts[0] == ATTACHMENT_DIR and len(parts) > 1
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/attachments", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def upload_attachment(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
_user: User = Depends(require_measurement_tec),
|
||||||
|
):
|
||||||
|
"""Carica l'allegato di un task 'note' (l'operatore non ha accesso a /api/files/upload).
|
||||||
|
|
||||||
|
Stessi controlli di upload_file: tipo dai magic bytes, limite di dimensione
|
||||||
|
con lettura a blocchi, nome ripulito. Salva sotto task_results/<AAAA>/<MM>/.
|
||||||
|
"""
|
||||||
|
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||||
|
buffer = bytearray()
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(_UPLOAD_CHUNK_SIZE)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buffer.extend(chunk)
|
||||||
|
if len(buffer) > max_bytes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB",
|
||||||
|
)
|
||||||
|
content = bytes(buffer)
|
||||||
|
|
||||||
|
detected = detect_file_type(content)
|
||||||
|
if detected is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="File type not allowed. Must be a recognised image or PDF.",
|
||||||
|
)
|
||||||
|
_mime, ext = detected
|
||||||
|
|
||||||
|
# nome originale ripulito (per mostrarlo); sul disco solo uuid + estensione rilevata
|
||||||
|
name = sanitize_filename(file.filename or "")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
storage_dir = settings.upload_path / ATTACHMENT_DIR / f"{now:%Y}" / f"{now:%m}"
|
||||||
|
storage_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = storage_dir / f"{uuid.uuid4().hex}{ext}"
|
||||||
|
target.write_bytes(content)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"file_path": str(target.relative_to(settings.upload_path)).replace("\\", "/"),
|
||||||
|
"name": name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TaskResultResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_task_result(
|
||||||
|
data: TaskResultCreate,
|
||||||
|
user: User = Depends(require_measurement_tec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Registra l'esito di un task 'check' o 'note'."""
|
||||||
|
task = (await db.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.id == data.task_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if task is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
|
||||||
|
|
||||||
|
note = data.note.strip() if data.note else None
|
||||||
|
note = note or None
|
||||||
|
values: Optional[dict] = None
|
||||||
|
attachments: Optional[list] = None
|
||||||
|
|
||||||
|
if task.task_type == "check":
|
||||||
|
if data.outcome not in ("ok", "nok"):
|
||||||
|
raise _unprocessable("Un controllo si registra come ok o nok")
|
||||||
|
if data.outcome == "nok" and not note:
|
||||||
|
raise _unprocessable("Per un NOK serve la nota")
|
||||||
|
elif task.task_type == "note":
|
||||||
|
if data.outcome != "done":
|
||||||
|
raise _unprocessable("Una nota si registra come done")
|
||||||
|
fields = {f["key"]: f for f in (task.fields or [])}
|
||||||
|
values = {}
|
||||||
|
for key, value in (data.values or {}).items():
|
||||||
|
if key not in fields:
|
||||||
|
raise _unprocessable(f"Campo '{key}' non previsto dal task")
|
||||||
|
if fields[key]["type"] == "number" and value.strip() != "":
|
||||||
|
try:
|
||||||
|
float(value.replace(",", "."))
|
||||||
|
except ValueError:
|
||||||
|
raise _unprocessable(f"Il campo '{fields[key]['label']}' deve essere un numero")
|
||||||
|
values[key] = value
|
||||||
|
attachments = []
|
||||||
|
for att in data.attachments or []:
|
||||||
|
if not _safe_relative_path(att.path):
|
||||||
|
raise _unprocessable(
|
||||||
|
f"Percorso allegato non valido (deve stare in {ATTACHMENT_DIR}/): {att.path}"
|
||||||
|
)
|
||||||
|
if att.key is not None and att.key not in fields:
|
||||||
|
raise _unprocessable(f"Campo allegato '{att.key}' non previsto dal task")
|
||||||
|
attachments.append(att.model_dump())
|
||||||
|
values = values or None
|
||||||
|
attachments = attachments or None
|
||||||
|
else:
|
||||||
|
raise _unprocessable(
|
||||||
|
f"I task di tipo '{task.task_type}' non registrano un esito"
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.production_run_id is not None:
|
||||||
|
run = (await db.execute(
|
||||||
|
select(ProductionRun).where(ProductionRun.id == data.production_run_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if run is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Production run not found"
|
||||||
|
)
|
||||||
|
if run.status == "closed":
|
||||||
|
raise _unprocessable("La produzione e' gia' chiusa")
|
||||||
|
if run.version_id != task.version_id:
|
||||||
|
raise _unprocessable("Il task non appartiene alla versione di questa produzione")
|
||||||
|
|
||||||
|
result = TaskResult(
|
||||||
|
task_id=task.id,
|
||||||
|
version_id=task.version_id, # presa dal task, non dal client
|
||||||
|
production_run_id=data.production_run_id,
|
||||||
|
user_id=user.id,
|
||||||
|
outcome=data.outcome,
|
||||||
|
note=note,
|
||||||
|
values=values,
|
||||||
|
attachments=attachments,
|
||||||
|
)
|
||||||
|
db.add(result)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(result) # created_at e' un server_default
|
||||||
|
return TaskResultResponse.model_validate(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[TaskResultResponse])
|
||||||
|
async def list_task_results(
|
||||||
|
production_run_id: Optional[int] = Query(None, gt=0),
|
||||||
|
task_id: Optional[int] = Query(None, gt=0),
|
||||||
|
_user: User = Depends(require_measurement_tec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Esiti registrati, filtrabili per produzione e/o task; il piu' recente prima."""
|
||||||
|
stmt = select(TaskResult)
|
||||||
|
if production_run_id is not None:
|
||||||
|
stmt = stmt.where(TaskResult.production_run_id == production_run_id)
|
||||||
|
if task_id is not None:
|
||||||
|
stmt = stmt.where(TaskResult.task_id == task_id)
|
||||||
|
rows = (await db.execute(
|
||||||
|
stmt.order_by(TaskResult.created_at.desc(), TaskResult.id.desc())
|
||||||
|
)).scalars().all()
|
||||||
|
return [TaskResultResponse.model_validate(r) for r in rows]
|
||||||
@@ -4,12 +4,12 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import require_maker, get_current_user
|
from src.backend.api.middleware.api_key import require_maker, get_current_user
|
||||||
from models.recipe import Recipe, RecipeVersion
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
from models.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.task import (
|
from src.backend.models.api.task import (
|
||||||
SubtaskCreate,
|
SubtaskCreate,
|
||||||
SubtaskResponse,
|
SubtaskResponse,
|
||||||
SubtaskUpdate,
|
SubtaskUpdate,
|
||||||
@@ -17,8 +17,13 @@ from schemas.task import (
|
|||||||
TaskReorderRequest,
|
TaskReorderRequest,
|
||||||
TaskResponse,
|
TaskResponse,
|
||||||
TaskUpdate,
|
TaskUpdate,
|
||||||
|
check_task_loop_options,
|
||||||
|
default_in_loop,
|
||||||
|
normalize_note_fields,
|
||||||
|
validate_tolerance_order,
|
||||||
)
|
)
|
||||||
from services import recipe_service
|
from src.backend.models.orm.vision import VisionReferenceImage
|
||||||
|
from src.backend.services import recipe_service, vision_service
|
||||||
|
|
||||||
router = APIRouter(tags=["tasks"])
|
router = APIRouter(tags=["tasks"])
|
||||||
|
|
||||||
@@ -99,6 +104,64 @@ async def _ensure_version_is_current(db: AsyncSession, version_id: int) -> Recip
|
|||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
async def _task_for_write(db: AsyncSession, task: RecipeTask, user: User) -> RecipeTask:
|
||||||
|
"""Return the task to write to: the same one, or its copy in a new version.
|
||||||
|
|
||||||
|
A version that already has measurements cannot be edited on the spot - its
|
||||||
|
recorded pass/fail would silently start meaning something else. Copy-on-write
|
||||||
|
(the same one create_task/update_recipe use) gives the edit a fresh version
|
||||||
|
and leaves the measured one exactly as it was when the readings were taken.
|
||||||
|
The task is matched in the copy by its position (order_index), which
|
||||||
|
_copy_tasks_to_version preserves exactly.
|
||||||
|
"""
|
||||||
|
if not await recipe_service.version_has_measurements(db, task.version_id):
|
||||||
|
return task
|
||||||
|
|
||||||
|
recipe_id = task.recipe_id
|
||||||
|
if recipe_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Cannot determine the recipe for this task",
|
||||||
|
)
|
||||||
|
from src.backend.models.api.recipe import RecipeUpdate
|
||||||
|
new_version = await recipe_service.create_new_version(
|
||||||
|
db, recipe_id, RecipeUpdate(change_notes=f"Edited task: {task.title}"), user,
|
||||||
|
)
|
||||||
|
for candidate in new_version.tasks:
|
||||||
|
if candidate.order_index == task.order_index:
|
||||||
|
# TaskResponse.recipe_id reads task.version.recipe_id; new_version's
|
||||||
|
# own tasks are loaded via selectinload, which does not also
|
||||||
|
# populate the child->parent backref, so left alone this would
|
||||||
|
# lazy-load .version outside of an awaited context and crash.
|
||||||
|
candidate.version = new_version
|
||||||
|
return candidate
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Could not locate the task in the new version",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _subtask_for_write(
|
||||||
|
db: AsyncSession, task: RecipeTask, subtask: RecipeSubtask, user: User,
|
||||||
|
) -> tuple[RecipeTask, RecipeSubtask]:
|
||||||
|
"""Return (task, subtask) to write to: the same ones, or their copies.
|
||||||
|
|
||||||
|
Same copy-on-write as _task_for_write, one level down: once the task's copy
|
||||||
|
is found, the quote is matched inside it by marker_number (unique per task).
|
||||||
|
"""
|
||||||
|
new_task = await _task_for_write(db, task, user)
|
||||||
|
if new_task is task:
|
||||||
|
return task, subtask
|
||||||
|
|
||||||
|
for candidate in new_task.subtasks:
|
||||||
|
if candidate.marker_number == subtask.marker_number:
|
||||||
|
return new_task, candidate
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Could not locate the quote in the new version",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Task endpoints
|
# Task endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -142,6 +205,13 @@ async def create_task(
|
|||||||
This creates a NEW version via copy-on-write, then appends the task to
|
This creates a NEW version via copy-on-write, then appends the task to
|
||||||
the new version. Requires Maker role.
|
the new version. Requires Maker role.
|
||||||
"""
|
"""
|
||||||
|
# #53 #55: validazione di in_loop/at_close/fields prima di toccare il db
|
||||||
|
try:
|
||||||
|
fields = normalize_note_fields(data.fields)
|
||||||
|
check_task_loop_options(data.task_type, data.at_close, fields)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
|
||||||
# Ensure recipe exists and is active
|
# Ensure recipe exists and is active
|
||||||
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
||||||
recipe = result.scalar_one_or_none()
|
recipe = result.scalar_one_or_none()
|
||||||
@@ -159,7 +229,7 @@ async def create_task(
|
|||||||
|
|
||||||
if has_measurements:
|
if has_measurements:
|
||||||
# Copy-on-write: create new version preserving measurement data
|
# Copy-on-write: create new version preserving measurement data
|
||||||
from schemas.recipe import RecipeUpdate
|
from src.backend.models.api.recipe import RecipeUpdate
|
||||||
new_version = await recipe_service.create_new_version(
|
new_version = await recipe_service.create_new_version(
|
||||||
db, recipe_id, RecipeUpdate(change_notes=f"Added task: {data.title}"), user
|
db, recipe_id, RecipeUpdate(change_notes=f"Added task: {data.title}"), user
|
||||||
)
|
)
|
||||||
@@ -172,12 +242,18 @@ async def create_task(
|
|||||||
new_task = RecipeTask(
|
new_task = RecipeTask(
|
||||||
version_id=new_version.id,
|
version_id=new_version.id,
|
||||||
order_index=max_order + 1,
|
order_index=max_order + 1,
|
||||||
|
task_type=data.task_type,
|
||||||
title=data.title,
|
title=data.title,
|
||||||
directive=data.directive,
|
directive=data.directive,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
file_path=data.file_path,
|
file_path=data.file_path,
|
||||||
file_type=data.file_type,
|
file_type=data.file_type,
|
||||||
annotations_json=data.annotations_json,
|
annotations_json=data.annotations_json,
|
||||||
|
vision_json=data.vision_json,
|
||||||
|
# #53 #55: ciclo (default dal tipo), chiusura, campi extra
|
||||||
|
in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type),
|
||||||
|
at_close=data.at_close,
|
||||||
|
fields=fields,
|
||||||
)
|
)
|
||||||
db.add(new_task)
|
db.add(new_task)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -196,6 +272,7 @@ async def create_task(
|
|||||||
ltl=sub_data.ltl,
|
ltl=sub_data.ltl,
|
||||||
unit=sub_data.unit,
|
unit=sub_data.unit,
|
||||||
image_path=sub_data.image_path,
|
image_path=sub_data.image_path,
|
||||||
|
vision_output=sub_data.vision_output,
|
||||||
)
|
)
|
||||||
db.add(sub)
|
db.add(sub)
|
||||||
|
|
||||||
@@ -223,7 +300,10 @@ async def reorder_tasks(
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(RecipeTask)
|
select(RecipeTask)
|
||||||
.where(RecipeTask.id.in_(data.task_ids))
|
.where(RecipeTask.id.in_(data.task_ids))
|
||||||
.options(selectinload(RecipeTask.subtasks))
|
.options(
|
||||||
|
selectinload(RecipeTask.subtasks),
|
||||||
|
selectinload(RecipeTask.version),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
tasks_map = {t.id: t for t in result.scalars().all()}
|
tasks_map = {t.id: t for t in result.scalars().all()}
|
||||||
|
|
||||||
@@ -258,10 +338,16 @@ async def reorder_tasks(
|
|||||||
async def update_task(
|
async def update_task(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
data: TaskUpdate,
|
data: TaskUpdate,
|
||||||
_user: User = Depends(require_maker),
|
user: User = Depends(require_maker),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Update a task. The task must belong to the current version. Requires Maker role."""
|
"""Update a task. The task must belong to the current version. Requires Maker role.
|
||||||
|
|
||||||
|
If that version already has measurements the edit goes through copy-on-write:
|
||||||
|
a new version is created and its copy of the task is the one changed. The
|
||||||
|
version with measurements is left untouched, so its recorded pass/fail keeps
|
||||||
|
meaning what it meant when it was taken.
|
||||||
|
"""
|
||||||
task = await _get_task_or_404(db, task_id)
|
task = await _get_task_or_404(db, task_id)
|
||||||
await _ensure_version_is_current(db, task.version_id)
|
await _ensure_version_is_current(db, task.version_id)
|
||||||
|
|
||||||
@@ -269,6 +355,28 @@ async def update_task(
|
|||||||
if not update_data:
|
if not update_data:
|
||||||
return TaskResponse.model_validate(task)
|
return TaskResponse.model_validate(task)
|
||||||
|
|
||||||
|
# #53 #55: i tre campi nuovi si validano sulla combinazione finale (valori
|
||||||
|
# nuovi, o quelli gia' sul task per cio' che il client non manda)
|
||||||
|
if "fields" in update_data:
|
||||||
|
try:
|
||||||
|
update_data["fields"] = normalize_note_fields(update_data["fields"])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
for flag in ("in_loop", "at_close"):
|
||||||
|
if flag in update_data and update_data[flag] is None:
|
||||||
|
del update_data[flag] # i booleani non sono nullable: null = non toccare
|
||||||
|
final_type = update_data.get("task_type") or task.task_type
|
||||||
|
try:
|
||||||
|
check_task_loop_options(
|
||||||
|
final_type,
|
||||||
|
update_data.get("at_close", task.at_close),
|
||||||
|
update_data.get("fields", task.fields),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
|
||||||
|
task = await _task_for_write(db, task, user)
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(task, field, value)
|
setattr(task, field, value)
|
||||||
|
|
||||||
@@ -280,18 +388,37 @@ async def update_task(
|
|||||||
@router.delete("/api/tasks/{task_id}", status_code=204)
|
@router.delete("/api/tasks/{task_id}", status_code=204)
|
||||||
async def delete_task(
|
async def delete_task(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
_user: User = Depends(require_maker),
|
user: User = Depends(require_maker),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Delete a task and its subtasks. The task must belong to the current version.
|
"""Delete a task and its subtasks. The task must belong to the current version.
|
||||||
|
|
||||||
Requires Maker role.
|
Requires Maker role. Copy-on-write applies as in update_task: a version with
|
||||||
|
measurements is never touched, the deletion lands on its copy instead.
|
||||||
"""
|
"""
|
||||||
task = await _get_task_or_404(db, task_id)
|
task = await _get_task_or_404(db, task_id)
|
||||||
await _ensure_version_is_current(db, task.version_id)
|
await _ensure_version_is_current(db, task.version_id)
|
||||||
|
|
||||||
|
task = await _task_for_write(db, task, user)
|
||||||
|
|
||||||
|
# The rows go with the task (migration 014 cascades them), the files do not:
|
||||||
|
# collect them now, remove the files only once the commit is durable - same
|
||||||
|
# order as vision.py::delete_reference_image. Rows are deleted explicitly so
|
||||||
|
# file_still_referenced sees the post-delete state even where the database
|
||||||
|
# does not enforce the cascade.
|
||||||
|
references = (await db.execute(
|
||||||
|
select(VisionReferenceImage).where(VisionReferenceImage.task_id == task.id)
|
||||||
|
)).scalars().all()
|
||||||
|
for reference in references:
|
||||||
|
await db.delete(reference)
|
||||||
|
|
||||||
await db.delete(task)
|
await db.delete(task)
|
||||||
await db.flush()
|
await db.commit()
|
||||||
|
|
||||||
|
# A copy-on-write sibling may still point at the same file (#38.6).
|
||||||
|
for reference in references:
|
||||||
|
if not await vision_service.file_still_referenced(db, reference.path):
|
||||||
|
vision_service.remove_reference_file(reference)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -306,16 +433,19 @@ async def delete_task(
|
|||||||
async def create_subtask(
|
async def create_subtask(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
data: SubtaskCreate,
|
data: SubtaskCreate,
|
||||||
_user: User = Depends(require_maker),
|
user: User = Depends(require_maker),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Add a subtask to a task. The task must belong to the current version.
|
"""Add a subtask to a task. The task must belong to the current version.
|
||||||
|
|
||||||
Requires Maker role.
|
Requires Maker role. Copy-on-write applies as in create_task: a version with
|
||||||
|
measurements gets a new version, and the subtask is added there instead.
|
||||||
"""
|
"""
|
||||||
task = await _get_task_or_404(db, task_id)
|
task = await _get_task_or_404(db, task_id)
|
||||||
await _ensure_version_is_current(db, task.version_id)
|
await _ensure_version_is_current(db, task.version_id)
|
||||||
|
|
||||||
|
task = await _task_for_write(db, task, user)
|
||||||
|
|
||||||
# Check marker_number uniqueness within the task
|
# Check marker_number uniqueness within the task
|
||||||
existing_markers = {s.marker_number for s in task.subtasks}
|
existing_markers = {s.marker_number for s in task.subtasks}
|
||||||
if data.marker_number in existing_markers:
|
if data.marker_number in existing_markers:
|
||||||
@@ -325,7 +455,7 @@ async def create_subtask(
|
|||||||
)
|
)
|
||||||
|
|
||||||
subtask = RecipeSubtask(
|
subtask = RecipeSubtask(
|
||||||
task_id=task_id,
|
task_id=task.id,
|
||||||
marker_number=data.marker_number,
|
marker_number=data.marker_number,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
measurement_type=data.measurement_type,
|
measurement_type=data.measurement_type,
|
||||||
@@ -336,6 +466,7 @@ async def create_subtask(
|
|||||||
ltl=data.ltl,
|
ltl=data.ltl,
|
||||||
unit=data.unit,
|
unit=data.unit,
|
||||||
image_path=data.image_path,
|
image_path=data.image_path,
|
||||||
|
vision_output=data.vision_output,
|
||||||
)
|
)
|
||||||
db.add(subtask)
|
db.add(subtask)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -347,12 +478,13 @@ async def create_subtask(
|
|||||||
async def update_subtask(
|
async def update_subtask(
|
||||||
subtask_id: int,
|
subtask_id: int,
|
||||||
data: SubtaskUpdate,
|
data: SubtaskUpdate,
|
||||||
_user: User = Depends(require_maker),
|
user: User = Depends(require_maker),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Update a subtask. Its parent task must belong to the current version.
|
"""Update a subtask. Its parent task must belong to the current version.
|
||||||
|
|
||||||
Requires Maker role.
|
Requires Maker role. Copy-on-write applies as in update_task, one level
|
||||||
|
down: a version with measurements is never touched, the quote's copy is.
|
||||||
"""
|
"""
|
||||||
subtask = await _get_subtask_or_404(db, subtask_id)
|
subtask = await _get_subtask_or_404(db, subtask_id)
|
||||||
|
|
||||||
@@ -364,6 +496,24 @@ async def update_subtask(
|
|||||||
if not update_data:
|
if not update_data:
|
||||||
return SubtaskResponse.model_validate(subtask)
|
return SubtaskResponse.model_validate(subtask)
|
||||||
|
|
||||||
|
# SubtaskUpdate is partial, so the order can only be checked against the
|
||||||
|
# values the write would actually leave in place: existing + incoming.
|
||||||
|
def _resulting(field: str) -> float | None:
|
||||||
|
value = update_data.get(field, getattr(subtask, field))
|
||||||
|
return None if value is None else float(value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_tolerance_order(
|
||||||
|
_resulting("ltl"), _resulting("lwl"), _resulting("nominal"),
|
||||||
|
_resulting("uwl"), _resulting("utl"),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
task, subtask = await _subtask_for_write(db, task, subtask, user)
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(subtask, field, value)
|
setattr(subtask, field, value)
|
||||||
|
|
||||||
@@ -375,12 +525,13 @@ async def update_subtask(
|
|||||||
@router.delete("/api/subtasks/{subtask_id}", status_code=204)
|
@router.delete("/api/subtasks/{subtask_id}", status_code=204)
|
||||||
async def delete_subtask(
|
async def delete_subtask(
|
||||||
subtask_id: int,
|
subtask_id: int,
|
||||||
_user: User = Depends(require_maker),
|
user: User = Depends(require_maker),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Delete a subtask. Its parent task must belong to the current version.
|
"""Delete a subtask. Its parent task must belong to the current version.
|
||||||
|
|
||||||
Requires Maker role.
|
Requires Maker role. Copy-on-write applies as in update_task, one level
|
||||||
|
down: a version with measurements is never touched, the quote's copy is.
|
||||||
"""
|
"""
|
||||||
subtask = await _get_subtask_or_404(db, subtask_id)
|
subtask = await _get_subtask_or_404(db, subtask_id)
|
||||||
|
|
||||||
@@ -388,5 +539,7 @@ async def delete_subtask(
|
|||||||
task = await _get_task_or_404(db, subtask.task_id)
|
task = await _get_task_or_404(db, subtask.task_id)
|
||||||
await _ensure_version_is_current(db, task.version_id)
|
await _ensure_version_is_current(db, task.version_id)
|
||||||
|
|
||||||
|
task, subtask = await _subtask_for_write(db, task, subtask, user)
|
||||||
|
|
||||||
await db.delete(subtask)
|
await db.delete(subtask)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -1,16 +1,26 @@
|
|||||||
"""Users router - CRUD operations (admin only)."""
|
"""Users router - CRUD operations (admin only)."""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import require_admin_user
|
from src.backend.api.middleware.api_key import require_admin_user
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
from src.backend.models.api.user import PinSet, UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
||||||
from services.auth_service import create_user, hash_password, regenerate_api_key
|
from src.backend.services.auth_service import (
|
||||||
|
create_user,
|
||||||
|
hash_password,
|
||||||
|
regenerate_api_key,
|
||||||
|
revoke_all_keys,
|
||||||
|
set_user_pin,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||||
|
|
||||||
|
# Combinable user roles. Supervisor (capoturno) authorizes out-of-tolerance
|
||||||
|
# overrides during measurement without needing full admin privileges.
|
||||||
|
VALID_ROLES = {"Maker", "MeasurementTec", "Metrologist", "Supervisor"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[UserResponse])
|
@router.get("", response_model=list[UserResponse])
|
||||||
async def list_users(
|
async def list_users(
|
||||||
@@ -40,12 +50,11 @@ async def create_new_user(
|
|||||||
detail=f"Username '{data.username}' already exists",
|
detail=f"Username '{data.username}' already exists",
|
||||||
)
|
)
|
||||||
# Validate roles
|
# Validate roles
|
||||||
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
|
|
||||||
for role in data.roles:
|
for role in data.roles:
|
||||||
if role not in valid_roles:
|
if role not in VALID_ROLES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail=f"Invalid role '{role}'. Valid roles: {valid_roles}",
|
detail=f"Invalid role '{role}'. Valid roles: {VALID_ROLES}",
|
||||||
)
|
)
|
||||||
user = await create_user(
|
user = await create_user(
|
||||||
db,
|
db,
|
||||||
@@ -75,15 +84,50 @@ async def update_user(
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
# Validate roles if provided
|
|
||||||
if "roles" in update_data:
|
# roles and is_admin are NOT NULL columns: an explicit null passed the
|
||||||
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
|
# Optional[...] schema check but blew up at flush time as an unhandled
|
||||||
for role in update_data["roles"]:
|
# 500 (#36 point 4).
|
||||||
if role not in valid_roles:
|
if "roles" in update_data and update_data["roles"] is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail=f"Invalid role '{role}'. Valid roles: {valid_roles}",
|
detail="roles cannot be null",
|
||||||
)
|
)
|
||||||
|
if "is_admin" in update_data and update_data["is_admin"] is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="is_admin cannot be null",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate roles if provided
|
||||||
|
if "roles" in update_data:
|
||||||
|
for role in update_data["roles"]:
|
||||||
|
if role not in VALID_ROLES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Invalid role '{role}'. Valid roles: {VALID_ROLES}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Losing the last admin locks everyone out of user management; losing
|
||||||
|
# your own admin flag while acting as admin is the same mistake aimed
|
||||||
|
# at yourself. Neither is reversible without direct DB access.
|
||||||
|
if update_data.get("is_admin") is False and user.is_admin:
|
||||||
|
if user.id == admin.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Cannot remove your own admin privilege",
|
||||||
|
)
|
||||||
|
other_admins = await db.execute(
|
||||||
|
select(func.count()).select_from(User).where(
|
||||||
|
User.is_admin == True, User.id != user.id # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if other_admins.scalar_one() == 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Cannot remove admin privilege from the last admin",
|
||||||
|
)
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(user, field, value)
|
setattr(user, field, value)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -98,16 +142,45 @@ async def change_user_password(
|
|||||||
admin: User = Depends(require_admin_user),
|
admin: User = Depends(require_admin_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Change user password (admin only)."""
|
"""Change user password (admin only). Invalidates the user's API key,
|
||||||
|
|
||||||
|
the same as setup.py's change-user-password: a password change should
|
||||||
|
force everyone logged in as that user to log back in.
|
||||||
|
"""
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
user.password_hash = hash_password(data.password)
|
user.password_hash = hash_password(data.password)
|
||||||
|
await revoke_all_keys(db, user.id)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"message": f"Password changed for user {user.username}"}
|
return {"message": f"Password changed for user {user.username}"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/pin", status_code=status.HTTP_200_OK)
|
||||||
|
async def change_user_pin(
|
||||||
|
user_id: int,
|
||||||
|
data: PinSet,
|
||||||
|
admin: User = Depends(require_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Imposta o toglie (pin null) il PIN di un utente (admin, #59/#51).
|
||||||
|
|
||||||
|
422 se l'utente e' admin. Il PIN non si restituisce mai.
|
||||||
|
"""
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
if user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Admin users cannot have a PIN",
|
||||||
|
)
|
||||||
|
await set_user_pin(db, user, data.pin)
|
||||||
|
return {"message": f"PIN {'set' if data.pin else 'removed'} for user {user.username}"}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def deactivate_user(
|
async def deactivate_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -125,7 +198,7 @@ async def deactivate_user(
|
|||||||
detail="Cannot deactivate yourself",
|
detail="Cannot deactivate yourself",
|
||||||
)
|
)
|
||||||
user.active = False
|
user.active = False
|
||||||
user.api_key = None
|
await revoke_all_keys(db, user.id)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
@@ -140,5 +213,5 @@ async def regenerate_user_key(
|
|||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
new_key = await regenerate_api_key(db, user_id)
|
new_key = await regenerate_api_key(db, user)
|
||||||
return {"api_key": new_key, "message": f"API key regenerated for user {user.username}"}
|
return {"api_key": new_key, "message": f"API key regenerated for user {user.username}"}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
"""The client asks for a measurement and gets an outcome.
|
||||||
|
|
||||||
|
It does not know, and must not know, whether the server or the station computed
|
||||||
|
the numbers: that is what lets a station's configuration change without touching
|
||||||
|
the frontend or the recipes.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.api.middleware.api_key import require_maker, require_measurement_tec
|
||||||
|
from src.backend.api.routers.files import (
|
||||||
|
ALLOWED_IMAGE_TYPES, resolve_upload_path, validate_file_size,
|
||||||
|
)
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.database import get_db
|
||||||
|
from src.backend.models.api.vision import (
|
||||||
|
VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
|
||||||
|
VisionRecipeUploadResponse, VisionReferenceImageResponse,
|
||||||
|
)
|
||||||
|
from src.backend.models.orm.vision import VisionResult
|
||||||
|
from src.backend.models.orm.task import RecipeTask
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services import vision_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/vision", tags=["vision"])
|
||||||
|
|
||||||
|
# A vision graph measures pixels, not a PDF: stricter than files.py's
|
||||||
|
# ALLOWED_TYPES, which also accepts documents for technical drawings.
|
||||||
|
_IMAGE_EXTENSIONS = {
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/png": "png",
|
||||||
|
"image/gif": "gif",
|
||||||
|
"image/webp": "webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sniffed from the first bytes, independent of the declared Content-Type: a
|
||||||
|
# text file uploaded as "image/png" passes the header check above and must
|
||||||
|
# still be caught before it reaches the worker as a bare 500 (#15). BMP and
|
||||||
|
# TIFF are included even though `_IMAGE_EXTENSIONS` does not accept them,
|
||||||
|
# because PIL - and so the worker - can decode both; this check only refuses
|
||||||
|
# what is not a real image at all, it does not narrow the allow-list above.
|
||||||
|
_IMAGE_SIGNATURES: tuple[tuple[bytes, ...], ...] = (
|
||||||
|
(b"\x89PNG\r\n\x1a\n",), # PNG
|
||||||
|
(b"\xff\xd8\xff",), # JPEG
|
||||||
|
(b"BM",), # BMP
|
||||||
|
(b"II*\x00", b"MM\x00*"), # TIFF, little- and big-endian
|
||||||
|
(b"GIF87a", b"GIF89a"), # GIF
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_an_image(content: bytes) -> bool:
|
||||||
|
"""True if `content` starts with a known image format's magic bytes."""
|
||||||
|
for signatures in _IMAGE_SIGNATURES:
|
||||||
|
if any(content.startswith(sig) for sig in signatures):
|
||||||
|
return True
|
||||||
|
# WebP: "RIFF" + 4-byte size + "WEBP", the size in between is not fixed.
|
||||||
|
return len(content) >= 12 and content[:4] == b"RIFF" and content[8:12] == b"WEBP"
|
||||||
|
|
||||||
|
|
||||||
|
async def _task_or_404(db: AsyncSession, task_id: int) -> RecipeTask:
|
||||||
|
"""Shared by the endpoints of this router."""
|
||||||
|
task = (await db.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.id == task_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if task is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_image(image: UploadFile) -> bytes:
|
||||||
|
"""Type checked from the header before reading, size checked once the body
|
||||||
|
is in hand - the same two-step convention `files.py::upload_file` already
|
||||||
|
uses, reused rather than reinvented here.
|
||||||
|
"""
|
||||||
|
if image.content_type not in ALLOWED_IMAGE_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"File type {image.content_type} not allowed. Must be an image.",
|
||||||
|
)
|
||||||
|
content = await image.read()
|
||||||
|
if not validate_file_size(len(content)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
f"File size {len(content)} bytes exceeds maximum "
|
||||||
|
f"{settings.max_upload_size_mb}MB"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not _looks_like_an_image(content):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="the file's content does not match a supported image format",
|
||||||
|
)
|
||||||
|
_reject_animated(content)
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_animated(content: bytes) -> None:
|
||||||
|
"""Animated GIF/WebP/APNG have no single frame to measure (decision of
|
||||||
|
06/10/2026, #43): refuse them instead of silently reading frame 0."""
|
||||||
|
try:
|
||||||
|
with Image.open(io.BytesIO(content)) as img:
|
||||||
|
animated = getattr(img, "is_animated", False) or getattr(img, "n_frames", 1) > 1
|
||||||
|
except Exception:
|
||||||
|
# Unreadable here is not this check's business: the magic bytes passed
|
||||||
|
# and the worker decides what it can decode.
|
||||||
|
return
|
||||||
|
if animated:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="animated images are not supported",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/execute", response_model=VisionExecuteResponse)
|
||||||
|
async def execute(
|
||||||
|
task_id: int = Form(...),
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
lot_number: str | None = Form(None, max_length=100),
|
||||||
|
serial_number: str | None = Form(None, max_length=100),
|
||||||
|
production_run_id: int | None = Form(None, gt=0),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_measurement_tec),
|
||||||
|
) -> VisionExecuteResponse:
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
|
||||||
|
result, saved, report = await vision_service.execute_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
await _read_image(image),
|
||||||
|
current_user.id,
|
||||||
|
version_id=task.version_id,
|
||||||
|
lot_number=lot_number,
|
||||||
|
serial_number=serial_number,
|
||||||
|
production_run_id=production_run_id,
|
||||||
|
image_ext=_IMAGE_EXTENSIONS.get(image.content_type, "png"),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return VisionExecuteResponse(
|
||||||
|
vision_result_id=result.id,
|
||||||
|
engine_version=result.engine_version,
|
||||||
|
measurements=[
|
||||||
|
VisionMeasurementResult(
|
||||||
|
subtask_id=m.subtask_id, value=float(m.value), pass_fail=m.pass_fail,
|
||||||
|
)
|
||||||
|
for m in saved
|
||||||
|
],
|
||||||
|
outcome=report.get("outcome"),
|
||||||
|
overlay=report.get("overlay") or [],
|
||||||
|
image_url=f"/api/vision/results/{result.id}/image",
|
||||||
|
failures=report.get("failures") or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/results/{result_id}/image")
|
||||||
|
async def get_result_image(
|
||||||
|
result_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_measurement_tec),
|
||||||
|
) -> FileResponse:
|
||||||
|
"""The image a vision run measured, as saved by `execute` (#46)."""
|
||||||
|
result = (await db.execute(
|
||||||
|
select(VisionResult).where(VisionResult.id == result_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if result is None or not result.image_path:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Result image not found",
|
||||||
|
)
|
||||||
|
return FileResponse(resolve_upload_path(result.image_path))
|
||||||
|
|
||||||
|
|
||||||
|
def _422(errors: list[str]) -> HTTPException:
|
||||||
|
# detail e' una lista di stringhe: il frontend legge i 422 solo da `detail`.
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
|
||||||
|
async def upload_recipe(
|
||||||
|
task_id: int,
|
||||||
|
recipe: UploadFile = File(...),
|
||||||
|
files: list[UploadFile] = File(default=[]),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> VisionRecipeUploadResponse:
|
||||||
|
"""Load a VisionSuite v2 recipe plus its meta files onto a task (#46)."""
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
if task.task_type not in vision_service.VISION_TASK_TYPES:
|
||||||
|
raise _422([f"task type {task.task_type!r} does not run a vision recipe"])
|
||||||
|
|
||||||
|
raw = await recipe.read()
|
||||||
|
if not validate_file_size(len(raw)):
|
||||||
|
raise _422([f"recipe exceeds {settings.max_upload_size_mb}MB"])
|
||||||
|
try:
|
||||||
|
graph = json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise _422(["the recipe is not valid JSON"])
|
||||||
|
if not isinstance(graph, dict):
|
||||||
|
raise _422(["the recipe must be a JSON object"])
|
||||||
|
|
||||||
|
uploads: list[tuple[str, bytes]] = []
|
||||||
|
for f in files:
|
||||||
|
content = await f.read()
|
||||||
|
if not validate_file_size(len(content)):
|
||||||
|
raise _422([f"file {f.filename!r} exceeds {settings.max_upload_size_mb}MB"])
|
||||||
|
uploads.append((f.filename or "", content))
|
||||||
|
placed, errors = vision_service.match_recipe_files(graph, uploads)
|
||||||
|
if errors:
|
||||||
|
raise _422(errors)
|
||||||
|
|
||||||
|
# The folder is content-addressed and immutable; writing it before the
|
||||||
|
# validation is harmless (an orphan folder is never referenced) and the
|
||||||
|
# worker needs it on disk to validate meta files.
|
||||||
|
try:
|
||||||
|
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise _422([str(exc)])
|
||||||
|
check = await vision_service.validate_recipe(graph, recipe_dir)
|
||||||
|
if not check["ok"]:
|
||||||
|
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
|
||||||
|
|
||||||
|
task.vision_json = graph
|
||||||
|
task.vision_recipe_dir = recipe_dir
|
||||||
|
await db.commit()
|
||||||
|
return VisionRecipeUploadResponse(
|
||||||
|
task_id=task.id,
|
||||||
|
recipe_dir=recipe_dir,
|
||||||
|
files=vision_service.list_recipe_files(recipe_dir),
|
||||||
|
outputs=check["outputs"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}/recipe", response_model=VisionRecipeResponse)
|
||||||
|
async def get_recipe(
|
||||||
|
task_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> VisionRecipeResponse:
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
outputs: list[dict] = []
|
||||||
|
if task.vision_json:
|
||||||
|
check = await vision_service.validate_recipe(
|
||||||
|
task.vision_json, task.vision_recipe_dir,
|
||||||
|
)
|
||||||
|
outputs = check.get("outputs", [])
|
||||||
|
return VisionRecipeResponse(
|
||||||
|
task_id=task.id,
|
||||||
|
recipe_dir=task.vision_recipe_dir,
|
||||||
|
recipe=task.vision_json,
|
||||||
|
files=vision_service.list_recipe_files(task.vision_recipe_dir),
|
||||||
|
outputs=outputs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Composing a recipe and keeping a reference image are Maker operations, not
|
||||||
|
# measuring: every endpoint below gates on `require_maker`, not the
|
||||||
|
# `require_measurement_tec` that `execute` above uses.
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reference-images")
|
||||||
|
async def add_reference_image(
|
||||||
|
task_id: int = Form(...),
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
note: str | None = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> dict:
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
content = await _read_image(image)
|
||||||
|
reference = await vision_service.save_reference_image(
|
||||||
|
db, task.id, content, note=note,
|
||||||
|
extension=_IMAGE_EXTENSIONS[image.content_type],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
# The row never became durable - the file `save_reference_image`
|
||||||
|
# wrote after its flush would otherwise outlive it as an orphan.
|
||||||
|
vision_service.remove_reference_file(reference)
|
||||||
|
raise
|
||||||
|
return {"id": reference.id, "path": reference.path, "note": reference.note}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reference-images", response_model=list[VisionReferenceImageResponse])
|
||||||
|
async def get_reference_images(
|
||||||
|
task_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> list[VisionReferenceImageResponse]:
|
||||||
|
await _task_or_404(db, task_id)
|
||||||
|
return await vision_service.list_reference_images(db, task_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/reference-images/{reference_id}", status_code=204)
|
||||||
|
async def delete_reference_image(
|
||||||
|
reference_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> None:
|
||||||
|
reference = await vision_service.delete_reference_image(db, reference_id)
|
||||||
|
await db.commit()
|
||||||
|
# Only now: if the commit above had failed, the row would still exist and
|
||||||
|
# the file must still be there for it. And only if no other row (a
|
||||||
|
# copy-on-write sibling, #38.6) still references the same path.
|
||||||
|
if not await vision_service.file_still_referenced(db, reference.path):
|
||||||
|
vision_service.remove_reference_file(reference)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview")
|
||||||
|
async def preview(
|
||||||
|
task_id: int = Form(...),
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> dict:
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
return await vision_service.preview(db, task, await _read_image(image))
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""TieMeasureFlow Server Configuration."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import model_validator
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
# Committed defaults. Fine for dev/test (SQLite in-memory, nothing exposed); a
|
||||||
|
# real deployment that still has these is running on secrets anyone can read
|
||||||
|
# in this file's git history.
|
||||||
|
_DEFAULT_SECRET_KEY = "change-this-to-a-random-secret-key"
|
||||||
|
_DEFAULT_DB_PASSWORD = "change_me_in_production"
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings loaded from environment variables."""
|
||||||
|
|
||||||
|
# Deployment environment. "dev" and "test" skip the production-only guard
|
||||||
|
# below; anything else (e.g. "production") is treated as a real
|
||||||
|
# deployment. Local dev and the test suite must keep starting with no
|
||||||
|
# .env at all, which is why this defaults to "dev" rather than requiring
|
||||||
|
# the variable.
|
||||||
|
app_env: str = "dev"
|
||||||
|
|
||||||
|
# Database
|
||||||
|
db_host: str = "localhost"
|
||||||
|
db_port: int = 3306
|
||||||
|
db_name: str = "tiemeasureflow"
|
||||||
|
db_user: str = "tmflow"
|
||||||
|
db_password: str = _DEFAULT_DB_PASSWORD
|
||||||
|
|
||||||
|
# Presentation time zone (DB is UTC, #42)
|
||||||
|
display_timezone: str = "Europe/Rome"
|
||||||
|
|
||||||
|
# Server
|
||||||
|
server_host: str = "0.0.0.0"
|
||||||
|
server_port: int = 8000
|
||||||
|
server_secret_key: str = _DEFAULT_SECRET_KEY
|
||||||
|
server_cors_origins: str = "http://localhost:5000"
|
||||||
|
# Worker processes (e.g. `uvicorn --workers N`). Each rate-limit bucket lives
|
||||||
|
# in process memory, so this is how RateLimitMiddleware divides the
|
||||||
|
# configured limits to keep the cluster-wide effective limit close to what
|
||||||
|
# was configured instead of multiplying it by N.
|
||||||
|
web_concurrency: int = 1
|
||||||
|
|
||||||
|
# API key session lifetime (hours); renewed on use (env API_KEY_TTL_HOURS).
|
||||||
|
api_key_ttl_hours: int = 24
|
||||||
|
# How many reverse proxies between the client and this API are trusted to
|
||||||
|
# have appended their own hop to X-Forwarded-For (Nginx, in the default
|
||||||
|
# deploy). Used by RateLimitMiddleware._client_ip. 0 disables proxy header
|
||||||
|
# parsing entirely (only request.client.host is trusted).
|
||||||
|
trusted_proxy_count: int = 1
|
||||||
|
|
||||||
|
# File Storage
|
||||||
|
upload_dir: str = "uploads"
|
||||||
|
max_upload_size_mb: int = 50
|
||||||
|
|
||||||
|
# Rate Limiting (requests per minute, per real client IP)
|
||||||
|
rate_limit_login: int = 5
|
||||||
|
rate_limit_general: int = 300
|
||||||
|
|
||||||
|
# SSL (Production)
|
||||||
|
ssl_certfile: str | None = None
|
||||||
|
ssl_keyfile: str | None = None
|
||||||
|
|
||||||
|
# Setup page (empty = disabled)
|
||||||
|
setup_password: str | None = None
|
||||||
|
# By default, once an admin user exists, setup's privileged endpoints
|
||||||
|
# (reset-db, seed, manage-user, change-user-password, init-db) refuse to
|
||||||
|
# run again. Set true for scripted re-provisioning of a demo/test deploy.
|
||||||
|
setup_allow_after_init: bool = False
|
||||||
|
|
||||||
|
# AI / OpenRouter (for technical sheet parsing)
|
||||||
|
openrouter_api_key: str | None = None
|
||||||
|
openrouter_model: str = "anthropic/claude-sonnet-4"
|
||||||
|
|
||||||
|
# Vision worker (internal network only, reachable as `vision` in Compose)
|
||||||
|
vision_worker_url: str = "http://vision:8100"
|
||||||
|
|
||||||
|
@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.
|
||||||
|
|
||||||
|
After the V2.0.0 restructure, uploads live at the project root
|
||||||
|
(mounted as a Docker volume), not inside the backend tree.
|
||||||
|
"""
|
||||||
|
# Path(__file__) = src/backend/config.py → parents[2] = project root
|
||||||
|
return Path(__file__).resolve().parents[2] / self.upload_dir
|
||||||
|
|
||||||
|
# Always resolve .env against the project root regardless of cwd
|
||||||
|
# (pydantic-settings would otherwise treat the path as cwd-relative).
|
||||||
|
model_config = {
|
||||||
|
"env_file": str(Path(__file__).resolve().parents[2] / ".env"),
|
||||||
|
"env_file_encoding": "utf-8",
|
||||||
|
"extra": "ignore",
|
||||||
|
}
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _refuse_default_secrets_outside_dev(self) -> "Settings":
|
||||||
|
"""Refuse to boot with a committed default secret in a real deployment.
|
||||||
|
|
||||||
|
Only fires when APP_ENV names an actual deployment (not "dev"/"test"),
|
||||||
|
so local development and the test suite keep starting with no .env at
|
||||||
|
all.
|
||||||
|
"""
|
||||||
|
if self.app_env in ("dev", "test"):
|
||||||
|
return self
|
||||||
|
if self.server_secret_key == _DEFAULT_SECRET_KEY:
|
||||||
|
raise RuntimeError(
|
||||||
|
"SERVER_SECRET_KEY is still the committed default value; "
|
||||||
|
"set it before running with APP_ENV=" + self.app_env
|
||||||
|
)
|
||||||
|
if self.db_password == _DEFAULT_DB_PASSWORD:
|
||||||
|
raise RuntimeError(
|
||||||
|
"DB_PASSWORD is still the committed default value; "
|
||||||
|
"set it before running with APP_ENV=" + self.app_env
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user